gitbayTests/NotificationTests.swift

v1.0.6
gitbay-ios/gitbayTests/NotificationTests.swift history · blame · raw

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}