Commit e576103c5e
Unsigned
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr06-notifications.md added +345
| @@ -0,0 +1,345 @@ | |||
| 1 | # MR 6: Notification inbox Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Close the `notification inbox` parity row — read the inbox, mark things read, and carry the unread count on the Dashboard. | ||
| 6 | |||
| 7 | **Architecture:** `notifications list` is paginated, so it goes through the existing `PagedListModel` like every other list. `notifications read` marks one or all. The unread badge costs no extra request: `dashboard` already emits `unread` for exactly this purpose and iOS simply does not decode it yet. | ||
| 8 | |||
| 9 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | ||
| 10 | |||
| 11 | ## Global Constraints | ||
| 12 | |||
| 13 | - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. | ||
| 14 | - Swift Testing only — never XCTest. | ||
| 15 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | ||
| 16 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. | ||
| 17 | - The label model is `IssueLabel`, never `Label`. | ||
| 18 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | ||
| 19 | - Never commit to `main`. | ||
| 20 | |||
| 21 | **The commands, verbatim from the registry:** | ||
| 22 | |||
| 23 | ``` | ||
| 24 | notifications list [--all] [--limit <n>] [--cursor <c>] | ||
| 25 | notifications read <id>... | --all | ||
| 26 | ``` | ||
| 27 | |||
| 28 | `notifications list` **is paginated** — unlike `search` in MR 3 — so it uses `PagedListModel`, which appends `--limit`/`--cursor` itself. The default listing is **unread only**; `--all` includes read ones. | ||
| 29 | |||
| 30 | **The item shape** (`internal/control/notifications.go:117-125`): | ||
| 31 | |||
| 32 | ```json | ||
| 33 | {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci", | ||
| 34 | "summary":"build 966 failed: test on runner-podman-exec-144", | ||
| 35 | "path":"krz/gitbay/builds/966","created_at":"2026-09-06T22:14:46.912Z", | ||
| 36 | "read_at":"2026-09-06T22:20:24.040Z"} | ||
| 37 | ``` | ||
| 38 | |||
| 39 | Everything is always present except `read_at`, which is `omitempty` — **absent means unread**, and that is the common case for the default listing. | ||
| 40 | |||
| 41 | **Counting tests reliably:** | ||
| 42 | |||
| 43 | ```bash | ||
| 44 | grep -cE "^Test case '[^']*' passed" /tmp/out.txt | ||
| 45 | grep -cE "^Test case '[^']*' failed" /tmp/out.txt | ||
| 46 | grep -cE "^Test case '[^']*' skipped" /tmp/out.txt | ||
| 47 | grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l | ||
| 48 | ``` | ||
| 49 | |||
| 50 | `declared - skipped == passed`. One test is always skipped (`LiveInstanceTests`). | ||
| 51 | |||
| 52 | Baseline: **285 declared, 284 passed, 1 skipped, 0 failed.** | ||
| 53 | |||
| 54 | --- | ||
| 55 | |||
| 56 | ## File Structure | ||
| 57 | |||
| 58 | | File | Responsibility | | ||
| 59 | |------|----------------| | ||
| 60 | | `gitbay/Account/NotificationsViewModel.swift` (create) | `Notification` wire model, routing, and the paged view model | | ||
| 61 | | `gitbayTests/NotificationTests.swift` (create) | Every test in this plan | | ||
| 62 | | `gitbay/Views/Account/NotificationsView.swift` (create) | The inbox screen | | ||
| 63 | | `gitbay/Dashboard/DashboardModels.swift` (modify) | Decode `unread` | | ||
| 64 | | `gitbay/Views/Dashboard/DashboardView.swift` (modify) | Badge + entry point | | ||
| 65 | | `gitbay/Views/Repos/RepoRoute.swift` (modify) | `notifications` case | | ||
| 66 | | `gitbay/ContentView.swift` (modify) | Route to `NotificationsView` | | ||
| 67 | |||
| 68 | --- | ||
| 69 | |||
| 70 | ### Task 1: The model, its routing, and the view model | ||
| 71 | |||
| 72 | **Files:** Create `gitbay/Account/NotificationsViewModel.swift`; test in `gitbayTests/NotificationTests.swift` | ||
| 73 | |||
| 74 | **Interfaces produced:** | ||
| 75 | - `nonisolated struct Notification: Decodable, Sendable, Hashable, Identifiable` — `id: Int64`, `repo: String`, `kind: String`, `actor: String`, `summary: String`, `path: String`, `createdAt: Date`, `readAt: Date?`; `var isUnread: Bool`; `var destination: NotificationDestination?` | ||
| 76 | - `nonisolated enum NotificationDestination: Hashable, Sendable` — `.issue(repo:number:)`, `.mr(repo:number:)`, `.build(repo:number:)` | ||
| 77 | - `@Observable @MainActor final class NotificationsViewModel` — `list: PagedListModel<Notification>`, `showAll: Bool`, `state`, `actionError`, `working`, `load()`, `markRead(_:)`, `markAllRead()` | ||
| 78 | |||
| 79 | **The routing table**, derived from the server (`internal/control/issue.go:153,279,349`, `mr.go:322,702,778,832,1173,1198,1382`, `diffcomment.go:111`, `build.go:522`, `deps/worker.go:249`) and confirmed against live data. `path` is a **web-style route**, `<owner>/<repo>/<section>/<n>`: | ||
| 80 | |||
| 81 | | `kind` | `path` | destination | | ||
| 82 | |---------|------------------------------|-------------| | ||
| 83 | | `issue` | `<owner>/<repo>/issues/<n>` | `.issue` | | ||
| 84 | | `mr` | `<owner>/<repo>/mrs/<n>` | `.mr` | | ||
| 85 | | `build` | `<owner>/<repo>/builds/<n>` | `.build` | | ||
| 86 | |||
| 87 | **The merge request segment is `mrs`, not `merge_requests`.** Matching the wrong spelling produces a silently untappable row — no error, no crash, just a row that does nothing. | ||
| 88 | |||
| 89 | Parse defensively: split on `/`, require exactly four components, require the last to be an integer, and return `nil` for anything else. A `nil` destination renders an untappable row rather than crashing — the same rule the search results screen follows for a missing number. | ||
| 90 | |||
| 91 | - [ ] **Step 1: Write the failing tests** | ||
| 92 | |||
| 93 | Create `gitbayTests/NotificationTests.swift`: | ||
| 94 | |||
| 95 | ```swift | ||
| 96 | import Foundation | ||
| 97 | import Testing | ||
| 98 | @testable import gitbay | ||
| 99 | |||
| 100 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 101 | let box = StubProtocol.box() | ||
| 102 | let client = GitbayClient( | ||
| 103 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 104 | token: "test-token", | ||
| 105 | session: box.session() | ||
| 106 | ) | ||
| 107 | return (client, box) | ||
| 108 | } | ||
| 109 | |||
| 110 | private func argvFrom(_ url: URL) -> [String] { | ||
| 111 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | ||
| 112 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | ||
| 113 | } | ||
| 114 | |||
| 115 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 116 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 117 | return try #require(body["argv"] as? [String]) | ||
| 118 | } | ||
| 119 | |||
| 120 | private func decode(_ json: String) throws -> Notification { | ||
| 121 | let decoder = JSONDecoder() | ||
| 122 | decoder.dateDecodingStrategy = .iso8601 | ||
| 123 | return try decoder.decode(Notification.self, from: Data(json.utf8)) | ||
| 124 | } | ||
| 125 | |||
| 126 | private let unreadBuild = """ | ||
| 127 | {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ | ||
| 128 | "summary":"build 966 failed","path":"krz/gitbay/builds/966",\ | ||
| 129 | "created_at":"2026-09-06T22:14:46Z"} | ||
| 130 | """ | ||
| 131 | |||
| 132 | struct NotificationDecodingTests { | ||
| 133 | |||
| 134 | /// read_at is omitempty; absent means unread, and that is the common | ||
| 135 | /// case for the default listing. | ||
| 136 | @Test func anAbsentReadAtMeansUnread() throws { | ||
| 137 | let n = try decode(unreadBuild) | ||
| 138 | #expect(n.readAt == nil) | ||
| 139 | #expect(n.isUnread) | ||
| 140 | } | ||
| 141 | |||
| 142 | @Test func aPresentReadAtMeansRead() throws { | ||
| 143 | let n = try decode(""" | ||
| 144 | {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\ | ||
| 145 | "summary":"x","path":"krz/gitbay/builds/966",\ | ||
| 146 | "created_at":"2026-09-06T22:14:46Z","read_at":"2026-09-06T22:20:24Z"} | ||
| 147 | """) | ||
| 148 | #expect(n.readAt != nil) | ||
| 149 | #expect(n.isUnread == false) | ||
| 150 | } | ||
| 151 | |||
| 152 | @Test func everyKindRoutesToItsScreen() throws { | ||
| 153 | let issue = try decode(""" | ||
| 154 | {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ | ||
| 155 | "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46Z"} | ||
| 156 | """) | ||
| 157 | #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168)) | ||
| 158 | |||
| 159 | // The segment is "mrs", not "merge_requests". | ||
| 160 | let mr = try decode(""" | ||
| 161 | {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\ | ||
| 162 | "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46Z"} | ||
| 163 | """) | ||
| 164 | #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282)) | ||
| 165 | |||
| 166 | let build = try decode(unreadBuild) | ||
| 167 | #expect(build.destination == .build(repo: "krz/gitbay", number: 966)) | ||
| 168 | } | ||
| 169 | |||
| 170 | /// A path the app cannot route must yield nil, not a crash and not a | ||
| 171 | /// wrong destination. | ||
| 172 | @Test func anUnroutablePathIsNil() throws { | ||
| 173 | for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber", | ||
| 174 | "too/short", "krz/gitbay/issues/1/extra", ""] { | ||
| 175 | let n = try decode(""" | ||
| 176 | {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\ | ||
| 177 | "path":"\(path)","created_at":"2026-09-06T22:14:46Z"} | ||
| 178 | """) | ||
| 179 | #expect(n.destination == nil, "\(path) should not route") | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | /// The repo comes from the path, not the `repo` field, so a | ||
| 184 | /// mismatch cannot send the user to the wrong repository. | ||
| 185 | @Test func theDestinationRepoComesFromThePath() throws { | ||
| 186 | let n = try decode(""" | ||
| 187 | {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\ | ||
| 188 | "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46Z"} | ||
| 189 | """) | ||
| 190 | #expect(n.destination == .issue(repo: "krz/gitbay", number: 7)) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | @MainActor | ||
| 195 | struct NotificationsViewModelTests { | ||
| 196 | |||
| 197 | private let page = """ | ||
| 198 | {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0} | ||
| 199 | """ | ||
| 200 | |||
| 201 | @Test func theDefaultListingIsUnreadOnly() 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 | |||
| 207 | let argv = argvFrom(try #require(stub.seen.last).url) | ||
| 208 | #expect(argv.prefix(2) == ["notifications", "list"]) | ||
| 209 | #expect(argv.contains("--all") == false) | ||
| 210 | #expect(model.state.value?.count == 1) | ||
| 211 | } | ||
| 212 | |||
| 213 | @Test func showingAllAddsTheFlag() async throws { | ||
| 214 | let (client, stub) = try makeClient() | ||
| 215 | stub.enqueue(.init(status: 200, json: page)) | ||
| 216 | let model = NotificationsViewModel(client: client) | ||
| 217 | await model.load() | ||
| 218 | |||
| 219 | stub.enqueue(.init(status: 200, json: page)) | ||
| 220 | model.showAll = true | ||
| 221 | try await Task.sleep(for: .milliseconds(150)) | ||
| 222 | |||
| 223 | #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) | ||
| 224 | } | ||
| 225 | |||
| 226 | @Test func markingOneReadSendsItsId() async throws { | ||
| 227 | let (client, stub) = try makeClient() | ||
| 228 | stub.enqueue(.init(status: 200, json: page)) | ||
| 229 | let model = NotificationsViewModel(client: client) | ||
| 230 | await model.load() | ||
| 231 | |||
| 232 | stub.enqueue(.init(status: 200, json: """ | ||
| 233 | {"protocol_version":1,"exit_code":0} | ||
| 234 | """)) | ||
| 235 | stub.enqueue(.init(status: 200, json: page)) | ||
| 236 | await model.markRead(15) | ||
| 237 | |||
| 238 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 239 | #expect(try argvOf(write) == ["notifications", "read", "15"]) | ||
| 240 | } | ||
| 241 | |||
| 242 | @Test func markingAllReadUsesTheFlagNotAList() async throws { | ||
| 243 | let (client, stub) = try makeClient() | ||
| 244 | stub.enqueue(.init(status: 200, json: page)) | ||
| 245 | let model = NotificationsViewModel(client: client) | ||
| 246 | await model.load() | ||
| 247 | |||
| 248 | stub.enqueue(.init(status: 200, json: """ | ||
| 249 | {"protocol_version":1,"exit_code":0} | ||
| 250 | """)) | ||
| 251 | stub.enqueue(.init(status: 200, json: page)) | ||
| 252 | await model.markAllRead() | ||
| 253 | |||
| 254 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 255 | #expect(try argvOf(write) == ["notifications", "read", "--all"]) | ||
| 256 | } | ||
| 257 | |||
| 258 | @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws { | ||
| 259 | let (client, stub) = try makeClient() | ||
| 260 | stub.enqueue(.init(status: 200, json: """ | ||
| 261 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 262 | """)) | ||
| 263 | let model = NotificationsViewModel(client: client) | ||
| 264 | await model.load() | ||
| 265 | |||
| 266 | guard case .empty = model.state else { | ||
| 267 | Testing.Issue.record("expected empty, got \(model.state)") | ||
| 268 | return | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | @Test func aRefusalSurfacesAndDoesNotReload() async throws { | ||
| 273 | let (client, stub) = try makeClient() | ||
| 274 | stub.enqueue(.init(status: 200, json: page)) | ||
| 275 | let model = NotificationsViewModel(client: client) | ||
| 276 | await model.load() | ||
| 277 | let before = stub.seen.count | ||
| 278 | |||
| 279 | stub.enqueue(.init(status: 200, json: """ | ||
| 280 | {"protocol_version":1,"error":"denied","exit_code":4} | ||
| 281 | """)) | ||
| 282 | await model.markRead(15) | ||
| 283 | |||
| 284 | #expect(model.actionError?.isEmpty == false) | ||
| 285 | #expect(model.working == false) | ||
| 286 | // One POST, no reload after a refusal. | ||
| 287 | #expect(stub.seen.count == before + 1) | ||
| 288 | } | ||
| 289 | } | ||
| 290 | ``` | ||
| 291 | |||
| 292 | - [ ] **Step 2: Run to verify failure.** | ||
| 293 | - [ ] **Step 3: Implement.** Follow `IssueListViewModel` for the `PagedListModel` shape and `RepoSettingsViewModel.perform(argv:)` for the write half (copy it; do not extract a shared helper — that duplication is a standing decision in this codebase). | ||
| 294 | - [ ] **Step 4: Run the tests.** | ||
| 295 | - [ ] **Step 5: Commit** — `git commit -m "Notification inbox model and view model"` | ||
| 296 | |||
| 297 | --- | ||
| 298 | |||
| 299 | ### Task 2: The screen, the badge and the route | ||
| 300 | |||
| 301 | **Files:** Create `gitbay/Views/Account/NotificationsView.swift`; modify `DashboardModels.swift`, `DashboardView.swift`, `RepoRoute.swift`, `ContentView.swift` | ||
| 302 | |||
| 303 | **The badge is free.** `dashboard` already emits `unread: int` — the server comments it as existing "so a client showing one does not need a second read to fill it" (`internal/control/dashboard.go:78`). iOS does not decode it yet. | ||
| 304 | |||
| 305 | **`DashboardData` has a hand-written `init(from:)`** (`DashboardModels.swift:27-37`) that decodes each field with `decodeIfPresent ?? []`. Adding `unread` means editing that initialiser — check field by field that you drop nothing, the same care MR 4's `MRDetail` initialiser needed. Decode it as `decodeIfPresent(Int.self, forKey: .unread) ?? 0`, since a server predating the field would omit it. | ||
| 306 | |||
| 307 | **The screen:** a `List` over the paged notifications with `PageFooter`, a toggle for unread-only versus all, a "Mark all read" action, and per-row swipe-to-mark-read. An unread row should read as unread — the web uses the count, so weight or a dot is enough; do not invent a colour that means nothing elsewhere in the app. | ||
| 308 | |||
| 309 | Each row shows `actor`, `summary` and the relative `createdAt`, and navigates to `destination` when it is non-nil. A row whose destination is `nil` renders plainly and is not tappable. | ||
| 310 | |||
| 311 | **Entry point:** a toolbar bell on the Dashboard carrying the unread count, matching where the web puts it (its rail). `DashboardView` already has a `.toolbar` with a `.topBarTrailing` item — add beside it. | ||
| 312 | |||
| 313 | Add `RepoRoute.notifications` and its `ContentView` destination. | ||
| 314 | |||
| 315 | - [ ] **Step 1: Decode `unread` in `DashboardData`** | ||
| 316 | - [ ] **Step 2: `NotificationsView`** | ||
| 317 | - [ ] **Step 3: Route case + `ContentView` destination** | ||
| 318 | - [ ] **Step 4: Dashboard bell with the count** | ||
| 319 | - [ ] **Step 5: Build and run the full suite.** No drop. | ||
| 320 | - [ ] **Step 6: Commit** — `git commit -m "Notification inbox screen and dashboard badge"` | ||
| 321 | |||
| 322 | --- | ||
| 323 | |||
| 324 | ### Task 3: Flip the parity row and open the merge request | ||
| 325 | |||
| 326 | - [ ] **Step 1:** In the Accounts table, `notification inbox` goes to `yes` for iOS. Touch no other row. | ||
| 327 | |||
| 328 | Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. | ||
| 329 | |||
| 330 | - [ ] **Step 2:** Run the full suite; record the real number. | ||
| 331 | - [ ] **Step 3:** Open the merge request. | ||
| 332 | |||
| 333 | --- | ||
| 334 | |||
| 335 | ## Notes for whoever executes this | ||
| 336 | |||
| 337 | **`notifications list` IS paginated.** Use `PagedListModel`. This differs from `search` in MR 3, which is not. | ||
| 338 | |||
| 339 | **The merge request path segment is `mrs`.** Not `merge_requests`. The wrong spelling gives a silently untappable row. | ||
| 340 | |||
| 341 | **Absent `read_at` means unread**, and it is the common case. Do not require the key. | ||
| 342 | |||
| 343 | **Take the repo from the path, not the `repo` field** — a test pins this, so a row can never send the user to a different repository than the one its link names. | ||
| 344 | |||
| 345 | **The badge needs no extra request.** If you find yourself adding a `notifications list` call to fill the Dashboard count, stop: `dashboard` already carries it. | ||
gitbay/Account/NotificationsViewModel.swift added +119
| @@ -0,0 +1,119 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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. | ||
| 6 | nonisolated struct InboxNotification: 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`. | ||
| 44 | nonisolated 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 | ||
| 54 | final class NotificationsViewModel { | ||
| 55 | |||
| 56 | let list: PagedListModel<InboxNotification> | ||
| 57 | /// Off shows unread only, the default listing; on adds `--all`. A | ||
| 58 | /// segmented control is quick to tap repeatedly, so a stale reload | ||
| 59 | /// from an earlier tap must not land after a later one. | ||
| 60 | var showAll = false { | ||
| 61 | didSet { | ||
| 62 | guard showAll != oldValue else { return } | ||
| 63 | actionError = nil | ||
| 64 | configureList() | ||
| 65 | reloadTask?.cancel() | ||
| 66 | reloadTask = Task { await list.reload() } | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | private(set) var actionError: String? | ||
| 71 | private(set) var working = false | ||
| 72 | |||
| 73 | private let client: GitbayClient | ||
| 74 | private var reloadTask: Task<Void, Never>? | ||
| 75 | |||
| 76 | init(client: GitbayClient) { | ||
| 77 | self.client = client | ||
| 78 | list = PagedListModel( | ||
| 79 | client: client, | ||
| 80 | argv: ["notifications", "list"], | ||
| 81 | emptyMessage: "No notifications." | ||
| 82 | ) | ||
| 83 | } | ||
| 84 | |||
| 85 | var state: LoadState<[InboxNotification]> { list.state } | ||
| 86 | |||
| 87 | func load() async { | ||
| 88 | reloadTask?.cancel() | ||
| 89 | actionError = nil | ||
| 90 | await list.reload() | ||
| 91 | } | ||
| 92 | |||
| 93 | func markRead(_ id: Int64) async { | ||
| 94 | guard !working else { return } | ||
| 95 | await perform(["notifications", "read", String(id)]) | ||
| 96 | } | ||
| 97 | |||
| 98 | func markAllRead() async { | ||
| 99 | await perform(["notifications", "read", "--all"]) | ||
| 100 | } | ||
| 101 | |||
| 102 | private func configureList() { | ||
| 103 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) | ||
| 104 | } | ||
| 105 | |||
| 106 | private func perform(_ argv: [String]) async { | ||
| 107 | working = true | ||
| 108 | actionError = nil | ||
| 109 | defer { working = false } | ||
| 110 | do { | ||
| 111 | try await client.run(argv) | ||
| 112 | await load() | ||
| 113 | } catch let error as GitbayError { | ||
| 114 | actionError = error.userFacingMessage | ||
| 115 | } catch { | ||
| 116 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } | ||
gitbay/ContentView.swift +2
| @@ -114,6 +114,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 114 | AccountView(client: client) | 114 | AccountView(client: client) |
| 115 | case .addAccount: | 115 | case .addAccount: |
| 116 | SignInView() | 116 | SignInView() |
| 117 | case .notifications: | ||
| 118 | NotificationsView(client: client) | ||
| 117 | } | 119 | } |
| 118 | } | 120 | } |
| 119 | 121 | ||
gitbay/Dashboard/DashboardModels.swift +5 −1
| @@ -14,9 +14,12 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | |||
| 14 | /// Retained by the CLI for compatibility; builds are not a web | 14 | /// Retained by the CLI for compatibility; builds are not a web |
| 15 | /// dashboard section. | 15 | /// dashboard section. |
| 16 | let builds: [DashboardBuild] | 16 | let builds: [DashboardBuild] |
| 17 | /// The unread notification count, so a client showing a badge does | ||
| 18 | /// not need a second read to fill it. | ||
| 19 | let unread: Int | ||
| 17 | 20 | ||
| 18 | enum CodingKeys: String, CodingKey { | 21 | enum CodingKeys: String, CodingKey { |
| 19 | case pinned, builds | 22 | case pinned, builds, unread |
| 20 | case reviewQueue = "review_queue" | 23 | case reviewQueue = "review_queue" |
| 21 | case openMRs = "open_mrs" | 24 | case openMRs = "open_mrs" |
| 22 | case assignedIssues = "assigned_issues" | 25 | case assignedIssues = "assigned_issues" |
| @@ -33,6 +36,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | |||
| 33 | pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] | 36 | pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] |
| 34 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] | 37 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] |
| 35 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] | 38 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] |
| 39 | unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0 | ||
| 36 | } | 40 | } |
| 37 | } | 41 | } |
| 38 | 42 | ||
gitbay/Views/Account/NotificationsView.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The notification inbox: `notifications list [--all]`, paginated, with | ||
| 4 | /// a switch between the default unread-only listing and everything. | ||
| 5 | struct NotificationsView: View { | ||
| 6 | |||
| 7 | @State private var model: NotificationsViewModel | ||
| 8 | |||
| 9 | init(client: GitbayClient) { | ||
| 10 | _model = State(initialValue: NotificationsViewModel(client: client)) | ||
| 11 | } | ||
| 12 | |||
| 13 | var body: some View { | ||
| 14 | List { | ||
| 15 | Picker("Filter", selection: Bindable(model).showAll) { | ||
| 16 | Text("Unread").tag(false) | ||
| 17 | Text("All").tag(true) | ||
| 18 | } | ||
| 19 | .pickerStyle(.segmented) | ||
| 20 | .listRowBackground(Color.clear) | ||
| 21 | .listRowInsets(EdgeInsets()) | ||
| 22 | |||
| 23 | if let actionError = model.actionError { | ||
| 24 | Section { GBNotice(actionError) } | ||
| 25 | } | ||
| 26 | |||
| 27 | ForEach(model.state.value ?? []) { notification in | ||
| 28 | NotificationLink(notification: notification) | ||
| 29 | .swipeActions { | ||
| 30 | if notification.isUnread { | ||
| 31 | Button("Mark read") { | ||
| 32 | Task { await model.markRead(notification.id) } | ||
| 33 | } | ||
| 34 | .tint(.gbAccent) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | PageFooter(list: model.list) | ||
| 39 | } | ||
| 40 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 41 | .navigationTitle("Notifications") | ||
| 42 | .navigationBarTitleDisplayMode(.inline) | ||
| 43 | .toolbar { | ||
| 44 | ToolbarItem(placement: .topBarTrailing) { | ||
| 45 | Button("Mark all read") { | ||
| 46 | Task { await model.markAllRead() } | ||
| 47 | } | ||
| 48 | .disabled(model.working) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | .task { await model.load() } | ||
| 52 | .refreshable { await model.load() } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Routes to `destination` when the path resolves to a screen; a | ||
| 57 | /// notification the app cannot route renders plainly and is not | ||
| 58 | /// tappable. | ||
| 59 | private struct NotificationLink: View { | ||
| 60 | let notification: InboxNotification | ||
| 61 | |||
| 62 | @ViewBuilder | ||
| 63 | var body: some View { | ||
| 64 | switch notification.destination { | ||
| 65 | case .issue(let repo, let number): | ||
| 66 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { | ||
| 67 | NotificationRow(notification: notification) | ||
| 68 | } | ||
| 69 | case .mr(let repo, let number): | ||
| 70 | NavigationLink(value: MRRoute.mr(repo: repo, number: number)) { | ||
| 71 | NotificationRow(notification: notification) | ||
| 72 | } | ||
| 73 | case .build(let repo, let number): | ||
| 74 | NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) { | ||
| 75 | NotificationRow(notification: notification) | ||
| 76 | } | ||
| 77 | case nil: | ||
| 78 | NotificationRow(notification: notification) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | private struct NotificationRow: View { | ||
| 84 | let notification: InboxNotification | ||
| 85 | |||
| 86 | var body: some View { | ||
| 87 | VStack(alignment: .leading, spacing: 4) { | ||
| 88 | HStack(alignment: .firstTextBaseline) { | ||
| 89 | Text(notification.actor) | ||
| 90 | .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) | ||
| 91 | Spacer() | ||
| 92 | Text(notification.createdAt, format: .relative(presentation: .named)) | ||
| 93 | .font(.gbSans(.caption)) | ||
| 94 | .foregroundStyle(.secondary) | ||
| 95 | } | ||
| 96 | Text(notification.summary) | ||
| 97 | .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) | ||
| 98 | .foregroundStyle(notification.isUnread ? .primary : .secondary) | ||
| 99 | .lineLimit(2) | ||
| 100 | } | ||
| 101 | .padding(.vertical, 2) | ||
| 102 | } | ||
| 103 | } | ||
gitbay/Views/Dashboard/DashboardView.swift +18
| @@ -44,6 +44,19 @@ struct DashboardView: View { | |||
| 44 | .overlay { LoadStateOverlay(state: model.state) } | 44 | .overlay { LoadStateOverlay(state: model.state) } |
| 45 | .navigationTitle("Dashboard") | 45 | .navigationTitle("Dashboard") |
| 46 | .toolbar { | 46 | .toolbar { |
| 47 | ToolbarItem(placement: .topBarTrailing) { | ||
| 48 | NavigationLink(value: RepoRoute.notifications) { | ||
| 49 | HStack(spacing: 2) { | ||
| 50 | Image(systemName: "bell") | ||
| 51 | if let unread = model.state.value?.unread, unread > 0 { | ||
| 52 | Text(String(unread)) | ||
| 53 | .font(.gbSans(.caption2)) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | .accessibilityIdentifier("dashboard-notifications-button") | ||
| 58 | .accessibilityLabel(bellLabel) | ||
| 59 | } | ||
| 47 | ToolbarItem(placement: .topBarTrailing) { | 60 | ToolbarItem(placement: .topBarTrailing) { |
| 48 | Button { | 61 | Button { |
| 49 | composing = true | 62 | composing = true |
| @@ -66,6 +79,11 @@ struct DashboardView: View { | |||
| 66 | .refreshable { await model.load() } | 79 | .refreshable { await model.load() } |
| 67 | } | 80 | } |
| 68 | 81 | ||
| 82 | private var bellLabel: String { | ||
| 83 | guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" } | ||
| 84 | return "Notifications, \(unread) unread" | ||
| 85 | } | ||
| 86 | |||
| 69 | private func itemSection( | 87 | private func itemSection( |
| 70 | _ title: String, | 88 | _ title: String, |
| 71 | items: [DashboardItem], | 89 | items: [DashboardItem], |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -20,4 +20,5 @@ nonisolated enum RepoRoute: Hashable { | |||
| 20 | case profile(String) | 20 | case profile(String) |
| 21 | case account | 21 | case account |
| 22 | case addAccount | 22 | case addAccount |
| 23 | case notifications | ||
| 23 | } | 24 | } |
gitbayTests/DashboardViewModelTests.swift +3 −1
| @@ -27,7 +27,8 @@ private let dashboardJSON = """ | |||
| 27 | "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\ | 27 | "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\ |
| 28 | "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ | 28 | "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ |
| 29 | "sha":"65ba14e0000000000000","ref":"refs/heads/main",\ | 29 | "sha":"65ba14e0000000000000","ref":"refs/heads/main",\ |
| 30 | "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\ | 30 | "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}],\ |
| 31 | "unread":3},\ | ||
| 31 | "exit_code":0} | 32 | "exit_code":0} |
| 32 | """ | 33 | """ |
| 33 | 34 | ||
| @@ -54,6 +55,7 @@ struct DashboardViewModelTests { | |||
| 54 | #expect(data.openIssues.first?.number == 11) | 55 | #expect(data.openIssues.first?.number == 11) |
| 55 | #expect(data.recentActivity.first?.phrase == "merged !2") | 56 | #expect(data.recentActivity.first?.phrase == "merged !2") |
| 56 | #expect(data.builds.first?.status == "success") | 57 | #expect(data.builds.first?.status == "success") |
| 58 | #expect(data.unread == 3) | ||
| 57 | // The whole screen cost exactly one request. | 59 | // The whole screen cost exactly one request. |
| 58 | #expect(stub.seen.count == 1) | 60 | #expect(stub.seen.count == 1) |
| 59 | #expect(stub.seen.first?.url.query() == "argv=dashboard") | 61 | #expect(stub.seen.first?.url.query() == "argv=dashboard") |
gitbayTests/NotificationTests.swift added +215
| @@ -0,0 +1,215 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | |||
| 15 | private func argvFrom(_ url: URL) -> [String] { | ||
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | ||
| 17 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | ||
| 18 | } | ||
| 19 | |||
| 20 | private 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 | |||
| 25 | private 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 | |||
| 31 | private 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 | |||
| 37 | struct 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 | ||
| 100 | struct 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 | try await Task.sleep(for: .milliseconds(150)) | ||
| 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 | try await Task.sleep(for: .milliseconds(150)) | ||
| 147 | |||
| 148 | let argv = argvFrom(try #require(stub.seen.last).url) | ||
| 149 | #expect(argv.contains("--cursor") == false) | ||
| 150 | } | ||
| 151 | |||
| 152 | @Test func markingOneReadSendsItsId() async throws { | ||
| 153 | let (client, stub) = try makeClient() | ||
| 154 | stub.enqueue(.init(status: 200, json: page)) | ||
| 155 | let model = NotificationsViewModel(client: client) | ||
| 156 | await model.load() | ||
| 157 | |||
| 158 | stub.enqueue(.init(status: 200, json: """ | ||
| 159 | {"protocol_version":1,"exit_code":0} | ||
| 160 | """)) | ||
| 161 | stub.enqueue(.init(status: 200, json: page)) | ||
| 162 | await model.markRead(15) | ||
| 163 | |||
| 164 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 165 | #expect(try argvOf(write) == ["notifications", "read", "15"]) | ||
| 166 | } | ||
| 167 | |||
| 168 | @Test func markingAllReadUsesTheFlagNotAList() async throws { | ||
| 169 | let (client, stub) = try makeClient() | ||
| 170 | stub.enqueue(.init(status: 200, json: page)) | ||
| 171 | let model = NotificationsViewModel(client: client) | ||
| 172 | await model.load() | ||
| 173 | |||
| 174 | stub.enqueue(.init(status: 200, json: """ | ||
| 175 | {"protocol_version":1,"exit_code":0} | ||
| 176 | """)) | ||
| 177 | stub.enqueue(.init(status: 200, json: page)) | ||
| 178 | await model.markAllRead() | ||
| 179 | |||
| 180 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 181 | #expect(try argvOf(write) == ["notifications", "read", "--all"]) | ||
| 182 | } | ||
| 183 | |||
| 184 | @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws { | ||
| 185 | let (client, stub) = try makeClient() | ||
| 186 | stub.enqueue(.init(status: 200, json: """ | ||
| 187 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 188 | """)) | ||
| 189 | let model = NotificationsViewModel(client: client) | ||
| 190 | await model.load() | ||
| 191 | |||
| 192 | guard case .empty = model.state else { | ||
| 193 | Testing.Issue.record("expected empty, got \(model.state)") | ||
| 194 | return | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | @Test func aRefusalSurfacesAndDoesNotReload() async throws { | ||
| 199 | let (client, stub) = try makeClient() | ||
| 200 | stub.enqueue(.init(status: 200, json: page)) | ||
| 201 | let model = NotificationsViewModel(client: client) | ||
| 202 | await model.load() | ||
| 203 | let before = stub.seen.count | ||
| 204 | |||
| 205 | stub.enqueue(.init(status: 200, json: """ | ||
| 206 | {"protocol_version":1,"error":"denied","exit_code":4} | ||
| 207 | """)) | ||
| 208 | await model.markRead(15) | ||
| 209 | |||
| 210 | #expect(model.actionError?.isEmpty == false) | ||
| 211 | #expect(model.working == false) | ||
| 212 | // One POST, no reload after a refusal. | ||
| 213 | #expect(stub.seen.count == before + 1) | ||
| 214 | } | ||
| 215 | } | ||