// // Copyright 2026 Element Creations Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine @testable import ElementX import Foundation import Testing import UIKit @MainActor struct PresenceServiceTests { let clientProxy: ClientProxyMock let appSettings: AppSettings let notificationCenter: NotificationCenter let sendsSubject = PassthroughSubject<(presence: ClientProxyPresence, sendImmediately: Bool), Never>() init() { clientProxy = ClientProxyMock(.init()) appSettings = AppSettings.volatile() notificationCenter = NotificationCenter() clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in sendsSubject.send((presence, sendImmediately)) return .success(()) } } private func makeService(initialApplicationState: UIApplication.State) -> PresenceService { PresenceService(clientProxy: clientProxy, appSettings: appSettings, notificationCenter: notificationCenter, initialApplicationState: initialApplicationState) } @Test func serviceSeededActiveWhileSharingReportsOnline() async throws { appSettings.sharePresence = true let deferred = deferFulfillment(sendsSubject) { $0.presence == .online } let service = makeService(initialApplicationState: .active) let send = try await deferred.fulfill() #expect(send.presence == .online) #expect(send.sendImmediately) #expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.filter { $0.presence == .online }.count == 1) _ = service } @Test func serviceSeededBackgroundedWhileSharingReportsUnavailable() async throws { appSettings.sharePresence = true let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable } let service = makeService(initialApplicationState: .background) let send = try await deferred.fulfill() #expect(send.presence == .unavailable) #expect(send.sendImmediately) _ = service } @Test func serviceSeededActiveWhileNotSharingReportsOffline() async throws { appSettings.sharePresence = false let deferred = deferFulfillment(sendsSubject) { $0.presence == .offline } let service = makeService(initialApplicationState: .active) let send = try await deferred.fulfill() #expect(send.presence == .offline) #expect(send.sendImmediately) _ = service } @Test func becomingActiveNotificationWhileSharingReportsOnlineImmediately() async throws { appSettings.sharePresence = true let service = makeService(initialApplicationState: .inactive) let deferred = deferFulfillment(sendsSubject) { $0.presence == .online } notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) let send = try await deferred.fulfill() #expect(send.presence == .online) #expect(send.sendImmediately) _ = service } @Test func resigningActiveNotificationWhileSharingReportsUnavailableImmediately() async throws { appSettings.sharePresence = true let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let service = makeService(initialApplicationState: .active) _ = try await initialDeferred.fulfill() let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable } notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) let send = try await deferred.fulfill() #expect(send.presence == .unavailable) #expect(send.sendImmediately) _ = service } @Test func togglingSharingWhileActiveDrivesOnlineAndOffline() async throws { appSettings.sharePresence = false let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline } let service = makeService(initialApplicationState: .active) _ = try await initialDeferred.fulfill() let onlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } appSettings.sharePresence = true let online = try await onlineDeferred.fulfill() #expect(online.presence == .online) #expect(online.sendImmediately) let offlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline } appSettings.sharePresence = false let offline = try await offlineDeferred.fulfill() #expect(offline.presence == .offline) #expect(offline.sendImmediately) _ = service } @Test func repeatingTheSameStateSendsOnlyOnce() async throws { appSettings.sharePresence = true let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let service = makeService(initialApplicationState: .active) _ = try await initialDeferred.fulfill() await Task.yield() notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) await Task.yield() #expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online]) _ = service } @Test func failedImmediateSendIsNotRetriedUntilTheDesiredPresenceChanges() async throws { appSettings.sharePresence = true clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in sendsSubject.send((presence, sendImmediately)) return presence == .online ? .failure(.forbiddenAccess) : .success(()) } let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let service = makeService(initialApplicationState: .active) _ = try await initialDeferred.fulfill() await Task.yield() notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) await Task.yield() #expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online]) let transitionDeferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable } notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) _ = try await transitionDeferred.fulfill() #expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online, .unavailable]) _ = service } @Test func newerTransitionWinsWhenAnEarlierSendIsInFlight() async throws { appSettings.sharePresence = true let (onlineStartedStream, onlineStartedContinuation) = AsyncStream.makeStream() let (resumeOnlineSendStream, resumeOnlineSendContinuation) = AsyncStream.makeStream() clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in if presence == .online { onlineStartedContinuation.yield() var iterator = resumeOnlineSendStream.makeAsyncIterator() _ = await iterator.next() } sendsSubject.send((presence, sendImmediately)) return .success(()) } let onlineStartedDeferred = deferFulfillment(onlineStartedStream) { _ in true } let service = makeService(initialApplicationState: .active) try await onlineStartedDeferred.fulfill() let unavailableDeferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable } notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) resumeOnlineSendContinuation.yield() resumeOnlineSendContinuation.finish() try await unavailableDeferred.fulfill() let applied = clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) #expect(applied == [.online, .unavailable], "Expected in-order latest-wins drain, got \(applied)") _ = service } @Test func deinitCancelsPendingSend() async throws { appSettings.sharePresence = true let (onlineStartedStream, onlineStartedContinuation) = AsyncStream.makeStream(of: Void.self) let (onlineFinishedStream, onlineFinishedContinuation) = AsyncStream.makeStream(of: Void.self) clientProxy.configurePresenceSendImmediatelyClosure = { presence, _ in if presence == .online { onlineStartedContinuation.yield() try? await Task.sleep(for: .seconds(3600)) // Interrupted by the service's deinit cancelling the send task. onlineFinishedContinuation.yield() } return .success(()) } let onlineStartedDeferred = deferFulfillment(onlineStartedStream) { _ in true } var service: PresenceService? = makeService(initialApplicationState: .active) #expect(service != nil) try await onlineStartedDeferred.fulfill() // Queue an unavailable send behind the in-flight online one. notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) let onlineFinishedDeferred = deferFulfillment(onlineFinishedStream) { _ in true } service = nil try await onlineFinishedDeferred.fulfill() let applied = clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) #expect(applied == [.online], "Expected deinit to cancel the queued unavailable send, got \(applied)") } }