gitbayTests/NotificationTests.swift
218 lines · 8420 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}