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) } }