Commit 5e4785bac9

5e4785bac961211a49cb3ae9a2b27465be9af6cd

parent: 1e17346377

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 22:51 UTC

Notification inbox model and view model

Layout: unified · split

gitbay/Account/NotificationsViewModel.swift added +111
@@ -0,0 +1,111 @@
1import Foundation
2import Observation
3
4/// One row of `notifications list`. `read_at` is omitempty: absent means
5/// unread, which is the common case for the default (unread-only) listing.
6nonisolated struct Notification: Decodable, Sendable, Hashable, Identifiable {
7 let id: Int64
8 let repo: String
9 let kind: String
10 let actor: String
11 let summary: String
12 let path: String
13 let createdAt: Date
14 let readAt: Date?
15
16 enum CodingKeys: String, CodingKey {
17 case id, repo, kind, actor, summary, path
18 case createdAt = "created_at"
19 case readAt = "read_at"
20 }
21
22 var isUnread: Bool { readAt == nil }
23
24 /// Where this notification points, derived from `path` — a
25 /// web-style route, `<owner>/<repo>/<section>/<n>` — not from `kind`
26 /// or the `repo` field. A shape the app cannot route (wrong section,
27 /// missing or non-numeric id, wrong number of components) yields
28 /// nil, which a row renders as untappable rather than crashing.
29 var destination: NotificationDestination? {
30 let parts = path.split(separator: "/", omittingEmptySubsequences: false)
31 guard parts.count == 4, let number = Int64(parts[3]) else { return nil }
32 let repo = "\(parts[0])/\(parts[1])"
33 switch parts[2] {
34 case "issues": return .issue(repo: repo, number: number)
35 case "mrs": return .mr(repo: repo, number: number)
36 case "builds": return .build(repo: repo, number: number)
37 default: return nil
38 }
39 }
40}
41
42/// The screen a notification's `destination` opens. The merge request
43/// section is `mrs` on the wire, never `merge_requests`.
44nonisolated enum NotificationDestination: Hashable, Sendable {
45 case issue(repo: String, number: Int64)
46 case mr(repo: String, number: Int64)
47 case build(repo: String, number: Int64)
48}
49
50/// The notification inbox: `notifications list [--all]`, paginated, plus
51/// `notifications read`.
52@Observable
53@MainActor
54final class NotificationsViewModel {
55
56 let list: PagedListModel<Notification>
57 /// Off shows unread only, the default listing; on adds `--all`.
58 var showAll = false {
59 didSet {
60 guard showAll != oldValue else { return }
61 configureList()
62 Task { await list.reload() }
63 }
64 }
65
66 private(set) var actionError: String?
67 private(set) var working = false
68
69 private let client: GitbayClient
70
71 init(client: GitbayClient) {
72 self.client = client
73 list = PagedListModel(
74 client: client,
75 argv: ["notifications", "list"],
76 emptyMessage: "No notifications."
77 )
78 }
79
80 var state: LoadState<[Notification]> { list.state }
81
82 func load() async {
83 await list.reload()
84 }
85
86 func markRead(_ id: Int64) async {
87 await perform(["notifications", "read", String(id)])
88 }
89
90 func markAllRead() async {
91 await perform(["notifications", "read", "--all"])
92 }
93
94 private func configureList() {
95 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
96 }
97
98 private func perform(_ argv: [String]) async {
99 working = true
100 actionError = nil
101 defer { working = false }
102 do {
103 try await client.run(argv)
104 await load()
105 } catch let error as GitbayError {
106 actionError = error.userFacingMessage
107 } catch {
108 actionError = GitbayError.transport(error).userFacingMessage
109 }
110 }
111}
gitbayTests/NotificationTests.swift added +194
@@ -0,0 +1,194 @@
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 -> gitbay.Notification {
26 let decoder = JSONDecoder()
27 decoder.dateDecodingStrategy = .iso8601
28 return try decoder.decode(gitbay.Notification.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:46Z"}
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:46Z","read_at":"2026-09-06T22:20:24Z"}
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:46Z"}
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:46Z"}
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:46Z"}
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:46Z"}
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 @Test func theDefaultListingIsUnreadOnly() async throws {
107 let (client, stub) = try makeClient()
108 stub.enqueue(.init(status: 200, json: page))
109 let model = NotificationsViewModel(client: client)
110 await model.load()
111
112 let argv = argvFrom(try #require(stub.seen.last).url)
113 #expect(argv.prefix(2) == ["notifications", "list"])
114 #expect(argv.contains("--all") == false)
115 #expect(model.state.value?.count == 1)
116 }
117
118 @Test func showingAllAddsTheFlag() async throws {
119 let (client, stub) = try makeClient()
120 stub.enqueue(.init(status: 200, json: page))
121 let model = NotificationsViewModel(client: client)
122 await model.load()
123
124 stub.enqueue(.init(status: 200, json: page))
125 model.showAll = true
126 try await Task.sleep(for: .milliseconds(150))
127
128 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
129 }
130
131 @Test func markingOneReadSendsItsId() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: page))
134 let model = NotificationsViewModel(client: client)
135 await model.load()
136
137 stub.enqueue(.init(status: 200, json: """
138 {"protocol_version":1,"exit_code":0}
139 """))
140 stub.enqueue(.init(status: 200, json: page))
141 await model.markRead(15)
142
143 let write = try #require(stub.seen.first { $0.method == "POST" })
144 #expect(try argvOf(write) == ["notifications", "read", "15"])
145 }
146
147 @Test func markingAllReadUsesTheFlagNotAList() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: page))
150 let model = NotificationsViewModel(client: client)
151 await model.load()
152
153 stub.enqueue(.init(status: 200, json: """
154 {"protocol_version":1,"exit_code":0}
155 """))
156 stub.enqueue(.init(status: 200, json: page))
157 await model.markAllRead()
158
159 let write = try #require(stub.seen.first { $0.method == "POST" })
160 #expect(try argvOf(write) == ["notifications", "read", "--all"])
161 }
162
163 @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
164 let (client, stub) = try makeClient()
165 stub.enqueue(.init(status: 200, json: """
166 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
167 """))
168 let model = NotificationsViewModel(client: client)
169 await model.load()
170
171 guard case .empty = model.state else {
172 Testing.Issue.record("expected empty, got \(model.state)")
173 return
174 }
175 }
176
177 @Test func aRefusalSurfacesAndDoesNotReload() async throws {
178 let (client, stub) = try makeClient()
179 stub.enqueue(.init(status: 200, json: page))
180 let model = NotificationsViewModel(client: client)
181 await model.load()
182 let before = stub.seen.count
183
184 stub.enqueue(.init(status: 200, json: """
185 {"protocol_version":1,"error":"denied","exit_code":4}
186 """))
187 await model.markRead(15)
188
189 #expect(model.actionError?.isEmpty == false)
190 #expect(model.working == false)
191 // One POST, no reload after a refusal.
192 #expect(stub.seen.count == before + 1)
193 }
194}