import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private func decode(_ json: String) throws -> InboxNotification { let decoder = GitbayClient.decoder() return try decoder.decode(InboxNotification.self, from: Data(json.utf8)) } private let unreadBuild = """ {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ "summary":"build 966 failed","path":"krz/gitbay/builds/966",\ "created_at":"2026-09-06T22:14:46.000Z"} """ struct NotificationDecodingTests { /// read_at is omitempty; absent means unread, and that is the common /// case for the default listing. @Test func anAbsentReadAtMeansUnread() throws { let n = try decode(unreadBuild) #expect(n.readAt == nil) #expect(n.isUnread) } @Test func aPresentReadAtMeansRead() throws { let n = try decode(""" {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ "summary":"x","path":"krz/gitbay/builds/966",\ "created_at":"2026-09-06T22:14:46.000Z","read_at":"2026-09-06T22:20:24.000Z"} """) #expect(n.readAt != nil) #expect(n.isUnread == false) } @Test func everyKindRoutesToItsScreen() throws { let issue = try decode(""" {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46.000Z"} """) #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168)) // The segment is "mrs", not "merge_requests". let mr = try decode(""" {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\ "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46.000Z"} """) #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282)) let build = try decode(unreadBuild) #expect(build.destination == .build(repo: "krz/gitbay", number: 966)) } /// A path the app cannot route must yield nil, not a crash and not a /// wrong destination. @Test func anUnroutablePathIsNil() throws { for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber", "too/short", "krz/gitbay/issues/1/extra", ""] { let n = try decode(""" {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ "path":"\(path)","created_at":"2026-09-06T22:14:46.000Z"} """) #expect(n.destination == nil, "\(path) should not route") } } /// The repo comes from the path, not the `repo` field, so a /// mismatch cannot send the user to the wrong repository. @Test func theDestinationRepoComesFromThePath() throws { let n = try decode(""" {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\ "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46.000Z"} """) #expect(n.destination == .issue(repo: "krz/gitbay", number: 7)) } } @MainActor struct NotificationsViewModelTests { private let page = """ {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0} """ private let pageWithCursor = """ {"protocol_version":1,"data":{"items":[\(unreadBuild)],"next":"c1"},"exit_code":0} """ @Test func theDefaultListingIsUnreadOnly() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() let argv = argvFrom(try #require(stub.seen.last).url) #expect(argv.prefix(2) == ["notifications", "list"]) #expect(argv.contains("--all") == false) #expect(model.state.value?.count == 1) } @Test func showingAllAddsTheFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: page)) model.showAll = true await until { stub.seen.count == 2 } #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) } /// A filter change reloads rather than pages, so it must not carry /// forward a cursor from whatever page was on screen. @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: pageWithCursor)) let model = NotificationsViewModel(client: client) await model.load() #expect(model.list.hasMore) stub.enqueue(.init(status: 200, json: page)) model.showAll = true // Poll for the read that actually carries the new filter, not // whatever happens to be last: a reload that never landed would // otherwise leave `last` pointing at the initial request, which // also carries no cursor and would pass just as wrongly. await until { stub.seen.contains { argvFrom($0.url).contains("--all") } } let reload = try #require(stub.seen.first { argvFrom($0.url).contains("--all") }) #expect(!argvFrom(reload.url).contains("--cursor")) } @Test func markingOneReadSendsItsId() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: page)) await model.markRead(15) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "read", "15"]) } @Test func markingAllReadUsesTheFlagNotAList() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: page)) await model.markAllRead() let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "read", "--all"]) } @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"items":[]},"exit_code":0} """)) let model = NotificationsViewModel(client: client) await model.load() guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } @Test func aRefusalSurfacesAndDoesNotReload() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page)) let model = NotificationsViewModel(client: client) await model.load() let before = stub.seen.count stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) await model.markRead(15) #expect(model.actionError?.isEmpty == false) #expect(model.working == false) // One POST, no reload after a refusal. #expect(stub.seen.count == before + 1) } // MARK: - Push private let settingsPushOn = """ {"protocol_version":1,"data":{"mail":true,"watch":false,"push":true},"exit_code":0} """ private let noDevices = """ {"protocol_version":1,"data":[],"exit_code":0} """ @Test func loadingPushSettingsReadsThePreferenceAndTheDevices() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: settingsPushOn)) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[\ {"id":1,"label":"cmc's iPhone","token":"a1b2…","added":"2026-09-20T00:00:00Z"}\ ],"exit_code":0} """)) let model = NotificationsViewModel(client: client) await model.loadPushSettings() #expect(model.pushEnabled) #expect(model.devices.map(\.id) == [1]) // The token is whatever the server sent — this model never // requests or reconstructs the full value. #expect(model.devices.first?.token == "a1b2…") } /// `push` is absent on a server that predates it; the read fails to /// decode and the defaults stand rather than crashing. @Test func aServerWithoutPushLeavesTheDefaults() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0} """)) let model = NotificationsViewModel(client: client) await model.loadPushSettings() #expect(model.pushEnabled == false) #expect(model.devices.isEmpty) } /// Flipping the toggle must not touch the inbox: three requests /// total (the write, then the two reads `loadPushSettings` makes for /// its own state), never a fourth for `notifications list`. That /// count is what stops the notification list on screen from /// blanking out behind a spinner for an unrelated fetch. @Test func settingPushReloadsItsOwnStateOnlyNotTheInbox() async throws { let (client, stub) = try makeClient() let model = NotificationsViewModel(client: client) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: settingsPushOn)) stub.enqueue(.init(status: 200, json: noDevices)) await model.setPush(true) #expect(stub.seen.count == 3) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "settings", "push", "on"]) #expect(model.pushEnabled) } /// See `settingPushReloadsItsOwnStateOnlyNotTheInbox` — the same /// reasoning applies to a device removal. @Test func removingADeviceSendsItsIdAndDoesNotReloadTheInbox() async throws { let (client, stub) = try makeClient() let model = NotificationsViewModel(client: client) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0} """)) stub.enqueue(.init(status: 200, json: noDevices)) await model.removeDevice(7) #expect(stub.seen.count == 3) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["notifications", "device", "remove", "7"]) #expect(model.devices.isEmpty) } /// A fast double-tap must not fire the write twice — `working` guards /// it, the same pattern `markRead` already uses. @Test func aSecondSetPushWhileWorkingIsIgnored() async throws { let (client, stub) = try makeClient() let model = NotificationsViewModel(client: client) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: settingsPushOn)) stub.enqueue(.init(status: 200, json: noDevices)) async let first: Void = model.setPush(true) await model.setPush(true) _ = await first // Only the first call's three requests; the guard drops the rest. #expect(stub.seen.count == 3) } }