gitbayTests/NotificationTests.swift
219 lines · 8455 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 = JSONDecoder()
27 decoder.dateDecodingStrategy = .iso8601
28 return try decoder.decode(InboxNotification.self, from: Data(json.utf8))
29}
30
31private let unreadBuild = """
32 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
33 "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
34 "created_at":"2026-09-06T22:14:46.000Z"}
35 """
36
37struct NotificationDecodingTests {
38
39 /// read_at is omitempty; absent means unread, and that is the common
40 /// case for the default listing.
41 @Test func anAbsentReadAtMeansUnread() throws {
42 let n = try decode(unreadBuild)
43 #expect(n.readAt == nil)
44 #expect(n.isUnread)
45 }
46
47 @Test func aPresentReadAtMeansRead() throws {
48 let n = try decode("""
49 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
50 "summary":"x","path":"krz/gitbay/builds/966",\
51 "created_at":"2026-09-06T22:14:46.000Z","read_at":"2026-09-06T22:20:24.000Z"}
52 """)
53 #expect(n.readAt != nil)
54 #expect(n.isUnread == false)
55 }
56
57 @Test func everyKindRoutesToItsScreen() throws {
58 let issue = try decode("""
59 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
60 "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46.000Z"}
61 """)
62 #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))
63
64 // The segment is "mrs", not "merge_requests".
65 let mr = try decode("""
66 {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
67 "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46.000Z"}
68 """)
69 #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))
70
71 let build = try decode(unreadBuild)
72 #expect(build.destination == .build(repo: "krz/gitbay", number: 966))
73 }
74
75 /// A path the app cannot route must yield nil, not a crash and not a
76 /// wrong destination.
77 @Test func anUnroutablePathIsNil() throws {
78 for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber",
79 "too/short", "krz/gitbay/issues/1/extra", ""] {
80 let n = try decode("""
81 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
82 "path":"\(path)","created_at":"2026-09-06T22:14:46.000Z"}
83 """)
84 #expect(n.destination == nil, "\(path) should not route")
85 }
86 }
87
88 /// The repo comes from the path, not the `repo` field, so a
89 /// mismatch cannot send the user to the wrong repository.
90 @Test func theDestinationRepoComesFromThePath() throws {
91 let n = try decode("""
92 {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
93 "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46.000Z"}
94 """)
95 #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
96 }
97}
98
99@MainActor
100struct NotificationsViewModelTests {
101
102 private let page = """
103 {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
104 """
105
106 private let pageWithCursor = """
107 {"protocol_version":1,"data":{"items":[\(unreadBuild)],"next":"c1"},"exit_code":0}
108 """
109
110 @Test func theDefaultListingIsUnreadOnly() async throws {
111 let (client, stub) = try makeClient()
112 stub.enqueue(.init(status: 200, json: page))
113 let model = NotificationsViewModel(client: client)
114 await model.load()
115
116 let argv = argvFrom(try #require(stub.seen.last).url)
117 #expect(argv.prefix(2) == ["notifications", "list"])
118 #expect(argv.contains("--all") == false)
119 #expect(model.state.value?.count == 1)
120 }
121
122 @Test func showingAllAddsTheFlag() async throws {
123 let (client, stub) = try makeClient()
124 stub.enqueue(.init(status: 200, json: page))
125 let model = NotificationsViewModel(client: client)
126 await model.load()
127
128 stub.enqueue(.init(status: 200, json: page))
129 model.showAll = true
130 await until { stub.seen.count == 2 }
131
132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
133 }
134
135 /// A filter change reloads rather than pages, so it must not carry
136 /// forward a cursor from whatever page was on screen.
137 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
138 let (client, stub) = try makeClient()
139 stub.enqueue(.init(status: 200, json: pageWithCursor))
140 let model = NotificationsViewModel(client: client)
141 await model.load()
142 #expect(model.list.hasMore)
143
144 stub.enqueue(.init(status: 200, json: page))
145 model.showAll = true
146 // Poll for the read that actually carries the new filter, not
147 // whatever happens to be last: a reload that never landed would
148 // otherwise leave `last` pointing at the initial request, which
149 // also carries no cursor and would pass just as wrongly.
150 await until { stub.seen.contains { argvFrom($0.url).contains("--all") } }
151
152 let reload = try #require(stub.seen.first { argvFrom($0.url).contains("--all") })
153 #expect(!argvFrom(reload.url).contains("--cursor"))
154 }
155
156 @Test func markingOneReadSendsItsId() async throws {
157 let (client, stub) = try makeClient()
158 stub.enqueue(.init(status: 200, json: page))
159 let model = NotificationsViewModel(client: client)
160 await model.load()
161
162 stub.enqueue(.init(status: 200, json: """
163 {"protocol_version":1,"exit_code":0}
164 """))
165 stub.enqueue(.init(status: 200, json: page))
166 await model.markRead(15)
167
168 let write = try #require(stub.seen.first { $0.method == "POST" })
169 #expect(try argvOf(write) == ["notifications", "read", "15"])
170 }
171
172 @Test func markingAllReadUsesTheFlagNotAList() async throws {
173 let (client, stub) = try makeClient()
174 stub.enqueue(.init(status: 200, json: page))
175 let model = NotificationsViewModel(client: client)
176 await model.load()
177
178 stub.enqueue(.init(status: 200, json: """
179 {"protocol_version":1,"exit_code":0}
180 """))
181 stub.enqueue(.init(status: 200, json: page))
182 await model.markAllRead()
183
184 let write = try #require(stub.seen.first { $0.method == "POST" })
185 #expect(try argvOf(write) == ["notifications", "read", "--all"])
186 }
187
188 @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
189 let (client, stub) = try makeClient()
190 stub.enqueue(.init(status: 200, json: """
191 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
192 """))
193 let model = NotificationsViewModel(client: client)
194 await model.load()
195
196 guard case .empty = model.state else {
197 Testing.Issue.record("expected empty, got \(model.state)")
198 return
199 }
200 }
201
202 @Test func aRefusalSurfacesAndDoesNotReload() async throws {
203 let (client, stub) = try makeClient()
204 stub.enqueue(.init(status: 200, json: page))
205 let model = NotificationsViewModel(client: client)
206 await model.load()
207 let before = stub.seen.count
208
209 stub.enqueue(.init(status: 200, json: """
210 {"protocol_version":1,"error":"denied","exit_code":4}
211 """))
212 await model.markRead(15)
213
214 #expect(model.actionError?.isEmpty == false)
215 #expect(model.working == false)
216 // One POST, no reload after a refusal.
217 #expect(stub.seen.count == before + 1)
218 }
219}