Signal-iOS/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift

190 lines
6.7 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import XCTest
@testable import SignalServiceKit
final class InactiveLinkedDeviceFinderTest: XCTestCase {
private var mockDateProvider: DateProvider!
private var mockDB: DB!
private var mockDeviceStore: OWSDeviceStore!
private var mockDevicesService: MockDevicesService!
private var mockTSAccountManager: MockTSAccountManager!
private var inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinderImpl!
private var activeLastSeenAt: Date {
return mockDateProvider()
.addingTimeInterval(-.minute)
}
private var inactiveLastSeenAt: Date {
// The finder will consider anything not seen for (1 month - 1 week) to
// be inactive, so we'll go back exactly that far and then go one more
// hour back to avoid any boundary-time issues.
return mockDateProvider()
.addingTimeInterval(-45 * .day)
.addingTimeInterval(.week)
.addingTimeInterval(-.hour)
}
override func setUp() {
// Use the same date for all usages of the date provider across a test.
let nowDate = Date()
mockDateProvider = { nowDate }
mockDB = InMemoryDB()
mockDeviceStore = OWSDeviceStore()
mockDevicesService = MockDevicesService()
mockTSAccountManager = MockTSAccountManager()
inactiveLinkedDeviceFinder = InactiveLinkedDeviceFinderImpl(
dateProvider: { self.mockDateProvider() },
db: mockDB,
deviceService: mockDevicesService,
deviceStore: mockDeviceStore,
remoteConfigProvider: MockRemoteConfigProvider(),
tsAccountManager: mockTSAccountManager,
)
}
func testRefreshing() async throws {
// Skip if linked device.
mockTSAccountManager.registrationStateMock = { .provisioned }
try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 0)
// Make a first attempt, failing to refresh.
mockTSAccountManager.registrationStateMock = { .registered }
mockDevicesService.shouldFail = true
try? await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 1)
// Make a second attempt, succeeding.
mockTSAccountManager.registrationStateMock = { .registered }
mockDevicesService.shouldFail = false
try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 2)
}
func testFetching() async throws {
func findLeastActive() -> InactiveLinkedDevice? {
return mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) }
}
// Do a refresh...
mockTSAccountManager.registrationStateMock = { .registered }
try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 1)
// Only include inactive devices.
mockTSAccountManager.registrationStateMock = { .registered }
setMockDevices([
.primary(),
.fixture(name: "eye pad", deviceId: 2, lastSeenAt: inactiveLastSeenAt),
.fixture(name: "lap top", deviceId: 3, lastSeenAt: activeLastSeenAt),
])
XCTAssertEqual(
findLeastActive()?.displayName,
"eye pad",
)
// If multiple inactive devices, pick the "least active" one.
mockTSAccountManager.registrationStateMock = { .registered }
setMockDevices([
.primary(),
.fixture(name: "🏖️", deviceId: 4, lastSeenAt: inactiveLastSeenAt.addingTimeInterval(-.second)),
.fixture(name: "🦩", deviceId: 5, lastSeenAt: inactiveLastSeenAt),
])
XCTAssertEqual(
findLeastActive()?.displayName,
"🏖️",
)
// Nothing if no linked devices.
mockTSAccountManager.registrationStateMock = { .registered }
setMockDevices([.primary()])
XCTAssertNil(findLeastActive())
// Nothing if not a primary.
mockTSAccountManager.registrationStateMock = { .provisioned }
setMockDevices([
.primary(),
.fixture(name: "eye pad", deviceId: 6, lastSeenAt: inactiveLastSeenAt),
])
XCTAssertNil(findLeastActive())
}
func testPermanentlyDisabling() async throws {
mockTSAccountManager.registrationStateMock = { .registered }
setMockDevices([
.primary(),
.fixture(name: "a sedentary device", deviceId: 7, lastSeenAt: inactiveLastSeenAt),
])
mockDB.write { inactiveLinkedDeviceFinder.permanentlyDisableFinders(tx: $0) }
try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 0)
XCTAssertFalse(mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) != nil })
// Re-enable (only available in tests) and run more tests, to prove the
// disabling is why the first battery passed.
mockDB.write { inactiveLinkedDeviceFinder.reenablePermanentlyDisabledFinders(tx: $0) }
try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
XCTAssertEqual(mockDevicesService.refreshCount, 1)
XCTAssertTrue(mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) != nil })
}
private func setMockDevices(_ devices: [OWSDevice]) {
mockDB.write { tx in
_ = mockDeviceStore.replaceAll(with: devices, tx: tx)
}
}
}
private extension OWSDevice {
static func primary() -> OWSDevice {
return OWSDevice(
deviceId: .primary,
createdAt: .distantPast,
lastSeenAt: Date(),
name: nil,
)
}
static func fixture(
name: String,
deviceId: Int8,
lastSeenAt: Date,
) -> OWSDevice {
return OWSDevice(
deviceId: DeviceId(validating: deviceId)!,
createdAt: .distantPast,
lastSeenAt: lastSeenAt,
name: name,
)
}
}
// MARK: - Mocks
private class MockDevicesService: OWSDeviceService {
var shouldFail: Bool = false
var refreshCount: Int = 0
func refreshDevices() async throws -> Bool {
refreshCount += 1
if shouldFail { throw OWSGenericError("") }
return true
}
func unlinkDevice(deviceId: DeviceId) async throws {}
func renameDevice(device: OWSDevice, newName: String) async throws {}
}