~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
135 lines · 4.8 KB Raw History
@testable import Grain
import UIKit
import UserNotifications
import XCTest

/// The seams between UIKit and the app: quick actions arriving through the
/// scene delegate, push taps arriving through the app delegate, and the push
/// registration callbacks in between. None of it is reachable by rendering a
/// view, and all of it decides where the app opens.
@MainActor
final class AppLifecycleTests: GrainTestCase {
    /// Captures whatever a callback or notification hands back.
    private final class Box: @unchecked Sendable {
        private let lock = NSLock()
        private var values: [String] = []

        func append(_ value: String) {
            lock.withLock { values.append(value) }
        }

        var recorded: [String] {
            lock.withLock { values }
        }
    }

    private func actionsPosted(during work: () -> Void) -> [String] {
        let box = Box()
        let token = NotificationCenter.default.addObserver(
            forName: .grainShortcutAction, object: nil, queue: nil
        ) { notification in
            if let action = notification.object as? String {
                box.append(action)
            }
        }
        defer { NotificationCenter.default.removeObserver(token) }

        work()
        return box.recorded
    }

    // MARK: - Scene delegate quick actions

    func testEachQuickActionMapsToItsOwnAction() throws {
        let delegate = GrainSceneDelegate()
        let scene = try XCTUnwrap(
            UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first,
            "No window scene in this test host"
        )

        for (type, expected) in [
            ("social.grain.shortcut.createStory", "createStory"),
            ("social.grain.shortcut.createGallery", "createGallery"),
        ] {
            let item = UIApplicationShortcutItem(type: type, localizedTitle: "Title")
            let posted = actionsPosted {
                delegate.windowScene(scene, performActionFor: item) { _ in }
            }
            XCTAssertEqual(posted, [expected])
        }
    }

    /// A shortcut type the app doesn't recognise must be dropped rather than
    /// dispatched as something else.
    func testAnUnknownQuickActionIsIgnored() throws {
        let delegate = GrainSceneDelegate()
        let scene = try XCTUnwrap(
            UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first,
            "No window scene in this test host"
        )
        let item = UIApplicationShortcutItem(type: "social.grain.shortcut.somethingElse", localizedTitle: "?")

        var completed: Bool?
        let posted = actionsPosted {
            delegate.windowScene(scene, performActionFor: item) { completed = $0 }
        }

        XCTAssertTrue(posted.isEmpty)
        XCTAssertEqual(completed, true, "The completion still has to be called or iOS logs a violation")
    }

    // MARK: - App delegate

    func testTheAppDelegateTakesOverNotificationDelivery() {
        let delegate = AppDelegate()

        XCTAssertTrue(delegate.application(UIApplication.shared, didFinishLaunchingWithOptions: nil))
        XCTAssertTrue(UNUserNotificationCenter.current().delegate === delegate)
    }

    // MARK: - Push registration callbacks

    /// The APNs token is handed straight to the push manager; without a manager
    /// wired up it has to be dropped rather than trapping.
    func testAnAPNsTokenWithNoPushManagerIsHarmless() {
        let delegate = AppDelegate()

        delegate.application(
            UIApplication.shared,
            didRegisterForRemoteNotificationsWithDeviceToken: Data([0xDE, 0xAD, 0xBE, 0xEF])
        )
        delegate.application(
            UIApplication.shared,
            didFailToRegisterForRemoteNotificationsWithError: URLError(.notConnectedToInternet)
        )

        XCTAssertNil(delegate.pushManager)
    }

    /// A failed registration is logged and swallowed — there is nothing the
    /// user can do about it, and it must not block launch.
    func testAFailedAPNsRegistrationIsSwallowed() {
        let manager = PushManager()

        manager.didFailToRegisterForRemoteNotifications(error: URLError(.notConnectedToInternet))

        XCTAssertNotNil(manager)
    }

    /// Unregistering is deliberately a no-op without the outgoing account's
    /// context — unregistering somebody else's device token is the bug this
    /// avoids.
    func testUnregisteringWithoutAnAuthContextDoesNothing() async {
        let manager = PushManager()
        var requestMade = false
        MockURLProtocol.handler = { request in
            requestMade = true
            return (Data("{}".utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
        }
        defer { MockURLProtocol.handler = nil }

        await manager.unregisterToken(auth: nil)

        XCTAssertFalse(requestMade)
    }
}