gitbayTests/PushRegistrarTests.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbayTests/PushRegistrarTests.swift history · blame · raw

132 lines · 5598 bytes

11 symbols in this file
  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private let whoami = """
  6    {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
  7    """
  8private let registeredJSON = """
  9    {"protocol_version":1,"data":{"id":7,"status":"registered"},"exit_code":0}
 10    """
 11private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
 12private let serverErrorJSON = #"{"protocol_version":1,"error":"boom","exit_code":1}"#
 13
 14@MainActor
 15private func makeSession() -> (SessionStore, StubProtocol.Box) {
 16    let box = StubProtocol.box()
 17    let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
 18    let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in
 19        GitbayClient(instance: instance, token: token, session: box.session())
 20    }
 21    return (session, box)
 22}
 23
 24/// Signs two accounts in, on different instances.
 25@MainActor
 26private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) {
 27    let (session, box) = makeSession()
 28    box.enqueue(.init(status: 200, json: whoami))
 29    try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
 30    box.enqueue(.init(status: 200, json: whoami))
 31    try await session.signIn(instanceURL: "https://dev.local", token: "t2")
 32    return (session, box)
 33}
 34
 35private func argv(of seen: StubProtocol.Seen) -> [String] {
 36    guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else {
 37        return []
 38    }
 39    return body["argv"] as? [String] ?? []
 40}
 41
 42private func stdin(of seen: StubProtocol.Seen) -> String? {
 43    guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else {
 44        return nil
 45    }
 46    return body["stdin"] as? String
 47}
 48
 49/// A UserDefaults suite unique to the call, so stored device ids never
 50/// leak between parallel tests.
 51private func scratchDefaults() -> UserDefaults {
 52    UserDefaults(suiteName: "test.\(UUID().uuidString)")!
 53}
 54
 55@MainActor
 56struct PushRegistrarTests {
 57
 58    @Test func registrarRegistersEveryAccountOnce() async throws {
 59        let (session, box) = try await twoAccounts()
 60        box.enqueue(.init(status: 200, json: registeredJSON))
 61        box.enqueue(.init(status: 200, json: registeredJSON))
 62        let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
 63
 64        await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef]))
 65
 66        let adds = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) }
 67        #expect(adds.count == 2)
 68        // The token is the lowercase hex of the raw bytes, on stdin, never argv.
 69        #expect(stdin(of: adds[0]) == "deadbeef")
 70        #expect(!argv(of: adds[0]).contains("deadbeef"))
 71    }
 72
 73    @Test func registrarDeregistersWithTheStoredID() async throws {
 74        let (session, box) = try await twoAccounts()
 75        box.enqueue(.init(status: 200, json: registeredJSON))
 76        box.enqueue(.init(status: 200, json: registeredJSON))
 77        let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
 78        await registrar.deviceTokenArrived(Data([0x01]))
 79
 80        box.enqueue(.init(status: 200, json: okJSON))
 81        await registrar.deregister(try #require(session.accounts.first))
 82
 83        let removes = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) }
 84        #expect(removes.count == 1)
 85        #expect(argv(of: removes[0]).last == "7")
 86    }
 87
 88    // What SessionStore.didAddAccount calls: the account that just
 89    // signed in gets a row, and no other account is touched.
 90    @Test func registrarRegistersOneAccountOnDemand() async throws {
 91        let (session, box) = try await twoAccounts()
 92        box.enqueue(.init(status: 200, json: registeredJSON))
 93        box.enqueue(.init(status: 200, json: registeredJSON))
 94        let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
 95        await registrar.deviceTokenArrived(Data([0x01]))
 96        let before = box.seen.count
 97
 98        box.enqueue(.init(status: 200, json: registeredJSON))
 99        await registrar.register(try #require(session.accounts.last))
100
101        let adds = box.seen.dropFirst(before).filter {
102            argv(of: $0).starts(with: ["notifications", "device", "add"])
103        }
104        #expect(adds.count == 1)
105    }
106
107    // There is nothing to register with before APNs answers; the
108    // token's arrival then registers every account, this one included.
109    @Test func registrarRegistersNothingBeforeTheTokenArrives() async throws {
110        let (session, box) = try await twoAccounts()
111        let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
112        let before = box.seen.count
113
114        await registrar.register(try #require(session.accounts.first))
115
116        #expect(box.seen.count == before)
117    }
118
119    // Registration is a side channel: a failure leaves the account working
120    // and is retried on the next launch, not surfaced.
121    @Test func registrarSurvivesAFailedRegistration() async throws {
122        let (session, box) = makeSession()
123        box.enqueue(.init(status: 200, json: whoami))
124        try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
125        // The transport retries a 5xx once before giving up.
126        box.enqueue(.init(status: 500, json: serverErrorJSON))
127        box.enqueue(.init(status: 500, json: serverErrorJSON))
128        let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
129
130        await registrar.deviceTokenArrived(Data([0x01]))  // must not throw
131    }
132}