gitbayTests/PushRegistrarTests.swift
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}