gitbayTests/NotificationTests.swift

v1.13.1
gitbay-ios/gitbayTests/NotificationTests.swift history · blame · raw

323 lines · 12796 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
  6    let box = StubProtocol.box()
  7    let client = GitbayClient(
  8        instance: try GitbayInstance(url: "https://gitbay.org"),
  9        token: "test-token",
 10        session: box.session()
 11    )
 12    return (client, box)
 13}
 14
 15private func argvFrom(_ url: URL) -> [String] {
 16    URLComponents(url: url, resolvingAgainstBaseURL: false)?
 17        .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
 18}
 19
 20private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
 21    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 22    return try #require(body["argv"] as? [String])
 23}
 24
 25private func decode(_ json: String) throws -> InboxNotification {
 26    let decoder = GitbayClient.decoder()
 27    return try decoder.decode(InboxNotification.self, from: Data(json.utf8))
 28}
 29
 30private let unreadBuild = """
 31    {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
 32    "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
 33    "created_at":"2026-09-06T22:14:46.000Z"}
 34    """
 35
 36struct NotificationDecodingTests {
 37
 38    /// read_at is omitempty; absent means unread, and that is the common
 39    /// case for the default listing.
 40    @Test func anAbsentReadAtMeansUnread() throws {
 41        let n = try decode(unreadBuild)
 42        #expect(n.readAt == nil)
 43        #expect(n.isUnread)
 44    }
 45
 46    @Test func aPresentReadAtMeansRead() throws {
 47        let n = try decode("""
 48            {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
 49            "summary":"x","path":"krz/gitbay/builds/966",\
 50            "created_at":"2026-09-06T22:14:46.000Z","read_at":"2026-09-06T22:20:24.000Z"}
 51            """)
 52        #expect(n.readAt != nil)
 53        #expect(n.isUnread == false)
 54    }
 55
 56    @Test func everyKindRoutesToItsScreen() throws {
 57        let issue = try decode("""
 58            {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
 59            "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46.000Z"}
 60            """)
 61        #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))
 62
 63        // The segment is "mrs", not "merge_requests".
 64        let mr = try decode("""
 65            {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
 66            "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46.000Z"}
 67            """)
 68        #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))
 69
 70        let build = try decode(unreadBuild)
 71        #expect(build.destination == .build(repo: "krz/gitbay", number: 966))
 72    }
 73
 74    /// A path the app cannot route must yield nil, not a crash and not a
 75    /// wrong destination.
 76    @Test func anUnroutablePathIsNil() throws {
 77        for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber",
 78                     "too/short", "krz/gitbay/issues/1/extra", ""] {
 79            let n = try decode("""
 80                {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
 81                "path":"\(path)","created_at":"2026-09-06T22:14:46.000Z"}
 82                """)
 83            #expect(n.destination == nil, "\(path) should not route")
 84        }
 85    }
 86
 87    /// The repo comes from the path, not the `repo` field, so a
 88    /// mismatch cannot send the user to the wrong repository.
 89    @Test func theDestinationRepoComesFromThePath() throws {
 90        let n = try decode("""
 91            {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
 92            "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46.000Z"}
 93            """)
 94        #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
 95    }
 96}
 97
 98@MainActor
 99struct NotificationsViewModelTests {
100
101    private let page = """
102        {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
103        """
104
105    private let pageWithCursor = """
106        {"protocol_version":1,"data":{"items":[\(unreadBuild)],"next":"c1"},"exit_code":0}
107        """
108
109    @Test func theDefaultListingIsUnreadOnly() async throws {
110        let (client, stub) = try makeClient()
111        stub.enqueue(.init(status: 200, json: page))
112        let model = NotificationsViewModel(client: client)
113        await model.load()
114
115        let argv = argvFrom(try #require(stub.seen.last).url)
116        #expect(argv.prefix(2) == ["notifications", "list"])
117        #expect(argv.contains("--all") == false)
118        #expect(model.state.value?.count == 1)
119    }
120
121    @Test func showingAllAddsTheFlag() async throws {
122        let (client, stub) = try makeClient()
123        stub.enqueue(.init(status: 200, json: page))
124        let model = NotificationsViewModel(client: client)
125        await model.load()
126
127        stub.enqueue(.init(status: 200, json: page))
128        model.showAll = true
129        await until { stub.seen.count == 2 }
130
131        #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
132    }
133
134    /// A filter change reloads rather than pages, so it must not carry
135    /// forward a cursor from whatever page was on screen.
136    @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
137        let (client, stub) = try makeClient()
138        stub.enqueue(.init(status: 200, json: pageWithCursor))
139        let model = NotificationsViewModel(client: client)
140        await model.load()
141        #expect(model.list.hasMore)
142
143        stub.enqueue(.init(status: 200, json: page))
144        model.showAll = true
145        // Poll for the read that actually carries the new filter, not
146        // whatever happens to be last: a reload that never landed would
147        // otherwise leave `last` pointing at the initial request, which
148        // also carries no cursor and would pass just as wrongly.
149        await until { stub.seen.contains { argvFrom($0.url).contains("--all") } }
150
151        let reload = try #require(stub.seen.first { argvFrom($0.url).contains("--all") })
152        #expect(!argvFrom(reload.url).contains("--cursor"))
153    }
154
155    @Test func markingOneReadSendsItsId() async throws {
156        let (client, stub) = try makeClient()
157        stub.enqueue(.init(status: 200, json: page))
158        let model = NotificationsViewModel(client: client)
159        await model.load()
160
161        stub.enqueue(.init(status: 200, json: """
162            {"protocol_version":1,"exit_code":0}
163            """))
164        stub.enqueue(.init(status: 200, json: page))
165        await model.markRead(15)
166
167        let write = try #require(stub.seen.first { $0.method == "POST" })
168        #expect(try argvOf(write) == ["notifications", "read", "15"])
169    }
170
171    @Test func markingAllReadUsesTheFlagNotAList() async throws {
172        let (client, stub) = try makeClient()
173        stub.enqueue(.init(status: 200, json: page))
174        let model = NotificationsViewModel(client: client)
175        await model.load()
176
177        stub.enqueue(.init(status: 200, json: """
178            {"protocol_version":1,"exit_code":0}
179            """))
180        stub.enqueue(.init(status: 200, json: page))
181        await model.markAllRead()
182
183        let write = try #require(stub.seen.first { $0.method == "POST" })
184        #expect(try argvOf(write) == ["notifications", "read", "--all"])
185    }
186
187    @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
188        let (client, stub) = try makeClient()
189        stub.enqueue(.init(status: 200, json: """
190            {"protocol_version":1,"data":{"items":[]},"exit_code":0}
191            """))
192        let model = NotificationsViewModel(client: client)
193        await model.load()
194
195        guard case .empty = model.state else {
196            Testing.Issue.record("expected empty, got \(model.state)")
197            return
198        }
199    }
200
201    @Test func aRefusalSurfacesAndDoesNotReload() async throws {
202        let (client, stub) = try makeClient()
203        stub.enqueue(.init(status: 200, json: page))
204        let model = NotificationsViewModel(client: client)
205        await model.load()
206        let before = stub.seen.count
207
208        stub.enqueue(.init(status: 200, json: """
209            {"protocol_version":1,"error":"denied","exit_code":4}
210            """))
211        await model.markRead(15)
212
213        #expect(model.actionError?.isEmpty == false)
214        #expect(model.working == false)
215        // One POST, no reload after a refusal.
216        #expect(stub.seen.count == before + 1)
217    }
218
219    // MARK: - Push
220
221    private let settingsPushOn = """
222        {"protocol_version":1,"data":{"mail":true,"watch":false,"push":true},"exit_code":0}
223        """
224
225    private let noDevices = """
226        {"protocol_version":1,"data":[],"exit_code":0}
227        """
228
229    @Test func loadingPushSettingsReadsThePreferenceAndTheDevices() async throws {
230        let (client, stub) = try makeClient()
231        stub.enqueue(.init(status: 200, json: settingsPushOn))
232        stub.enqueue(.init(status: 200, json: """
233            {"protocol_version":1,"data":[\
234            {"id":1,"label":"cmc's iPhone","token":"a1b2…","added":"2026-09-20T00:00:00Z"}\
235            ],"exit_code":0}
236            """))
237        let model = NotificationsViewModel(client: client)
238        await model.loadPushSettings()
239
240        #expect(model.pushEnabled)
241        #expect(model.devices.map(\.id) == [1])
242        // The token is whatever the server sent — this model never
243        // requests or reconstructs the full value.
244        #expect(model.devices.first?.token == "a1b2…")
245    }
246
247    /// `push` is absent on a server that predates it; the read fails to
248    /// decode and the defaults stand rather than crashing.
249    @Test func aServerWithoutPushLeavesTheDefaults() async throws {
250        let (client, stub) = try makeClient()
251        stub.enqueue(.init(status: 200, json: """
252            {"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}
253            """))
254        let model = NotificationsViewModel(client: client)
255        await model.loadPushSettings()
256
257        #expect(model.pushEnabled == false)
258        #expect(model.devices.isEmpty)
259    }
260
261    /// Flipping the toggle must not touch the inbox: three requests
262    /// total (the write, then the two reads `loadPushSettings` makes for
263    /// its own state), never a fourth for `notifications list`. That
264    /// count is what stops the notification list on screen from
265    /// blanking out behind a spinner for an unrelated fetch.
266    @Test func settingPushReloadsItsOwnStateOnlyNotTheInbox() async throws {
267        let (client, stub) = try makeClient()
268        let model = NotificationsViewModel(client: client)
269
270        stub.enqueue(.init(status: 200, json: """
271            {"protocol_version":1,"exit_code":0}
272            """))
273        stub.enqueue(.init(status: 200, json: settingsPushOn))
274        stub.enqueue(.init(status: 200, json: noDevices))
275        await model.setPush(true)
276
277        #expect(stub.seen.count == 3)
278        let write = try #require(stub.seen.first { $0.method == "POST" })
279        #expect(try argvOf(write) == ["notifications", "settings", "push", "on"])
280        #expect(model.pushEnabled)
281    }
282
283    /// See `settingPushReloadsItsOwnStateOnlyNotTheInbox` — the same
284    /// reasoning applies to a device removal.
285    @Test func removingADeviceSendsItsIdAndDoesNotReloadTheInbox() async throws {
286        let (client, stub) = try makeClient()
287        let model = NotificationsViewModel(client: client)
288
289        stub.enqueue(.init(status: 200, json: """
290            {"protocol_version":1,"exit_code":0}
291            """))
292        stub.enqueue(.init(status: 200, json: """
293            {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0}
294            """))
295        stub.enqueue(.init(status: 200, json: noDevices))
296        await model.removeDevice(7)
297
298        #expect(stub.seen.count == 3)
299        let write = try #require(stub.seen.first { $0.method == "POST" })
300        #expect(try argvOf(write) == ["notifications", "device", "remove", "7"])
301        #expect(model.devices.isEmpty)
302    }
303
304    /// A fast double-tap must not fire the write twice — `working` guards
305    /// it, the same pattern `markRead` already uses.
306    @Test func aSecondSetPushWhileWorkingIsIgnored() async throws {
307        let (client, stub) = try makeClient()
308        let model = NotificationsViewModel(client: client)
309
310        stub.enqueue(.init(status: 200, json: """
311            {"protocol_version":1,"exit_code":0}
312            """))
313        stub.enqueue(.init(status: 200, json: settingsPushOn))
314        stub.enqueue(.init(status: 200, json: noDevices))
315
316        async let first: Void = model.setPush(true)
317        await model.setPush(true)
318        _ = await first
319
320        // Only the first call's three requests; the guard drops the rest.
321        #expect(stub.seen.count == 3)
322    }
323}