import Foundation import Testing @testable import gitbay private let whoami = """ {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} """ private let registeredJSON = """ {"protocol_version":1,"data":{"id":7,"status":"registered"},"exit_code":0} """ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# private let serverErrorJSON = #"{"protocol_version":1,"error":"boom","exit_code":1}"# @MainActor private func makeSession() -> (SessionStore, StubProtocol.Box) { let box = StubProtocol.box() let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in GitbayClient(instance: instance, token: token, session: box.session()) } return (session, box) } /// Signs two accounts in, on different instances. @MainActor private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { let (session, box) = makeSession() box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://dev.local", token: "t2") return (session, box) } private func argv(of seen: StubProtocol.Seen) -> [String] { guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else { return [] } return body["argv"] as? [String] ?? [] } private func stdin(of seen: StubProtocol.Seen) -> String? { guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else { return nil } return body["stdin"] as? String } /// A UserDefaults suite unique to the call, so stored device ids never /// leak between parallel tests. private func scratchDefaults() -> UserDefaults { UserDefaults(suiteName: "test.\(UUID().uuidString)")! } @MainActor struct PushRegistrarTests { @Test func registrarRegistersEveryAccountOnce() async throws { let (session, box) = try await twoAccounts() box.enqueue(.init(status: 200, json: registeredJSON)) box.enqueue(.init(status: 200, json: registeredJSON)) let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef])) let adds = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) } #expect(adds.count == 2) // The token is the lowercase hex of the raw bytes, on stdin, never argv. #expect(stdin(of: adds[0]) == "deadbeef") #expect(!argv(of: adds[0]).contains("deadbeef")) } @Test func registrarDeregistersWithTheStoredID() async throws { let (session, box) = try await twoAccounts() box.enqueue(.init(status: 200, json: registeredJSON)) box.enqueue(.init(status: 200, json: registeredJSON)) let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0x01])) box.enqueue(.init(status: 200, json: okJSON)) await registrar.deregister(try #require(session.accounts.first)) let removes = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) } #expect(removes.count == 1) #expect(argv(of: removes[0]).last == "7") } // What SessionStore.didAddAccount calls: the account that just // signed in gets a row, and no other account is touched. @Test func registrarRegistersOneAccountOnDemand() async throws { let (session, box) = try await twoAccounts() box.enqueue(.init(status: 200, json: registeredJSON)) box.enqueue(.init(status: 200, json: registeredJSON)) let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0x01])) let before = box.seen.count box.enqueue(.init(status: 200, json: registeredJSON)) await registrar.register(try #require(session.accounts.last)) let adds = box.seen.dropFirst(before).filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) } #expect(adds.count == 1) } // There is nothing to register with before APNs answers; the // token's arrival then registers every account, this one included. @Test func registrarRegistersNothingBeforeTheTokenArrives() async throws { let (session, box) = try await twoAccounts() let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) let before = box.seen.count await registrar.register(try #require(session.accounts.first)) #expect(box.seen.count == before) } // Registration is a side channel: a failure leaves the account working // and is retried on the next launch, not surfaced. @Test func registrarSurvivesAFailedRegistration() async throws { let (session, box) = makeSession() box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") // The transport retries a 5xx once before giving up. box.enqueue(.init(status: 500, json: serverErrorJSON)) box.enqueue(.init(status: 500, json: serverErrorJSON)) let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0x01])) // must not throw } }