gitbayTests/NotificationTests.swift

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

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}