gitbayTests/PagedListTests.swift

v1.1.0
gitbay-ios/gitbayTests/PagedListTests.swift history · blame · raw

145 lines · 5406 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 nonisolated struct Row: Decodable, Sendable, Equatable {
 16    let number: Int
 17}
 18
 19private func page(_ numbers: [Int], next: String? = nil) -> String {
 20    let items = numbers.map { #"{"number":\#($0)}"# }.joined(separator: ",")
 21    let nextField = next.map { #","next":"\#($0)""# } ?? ""
 22    return #"{"protocol_version":1,"data":{"items":[\#(items)]\#(nextField)},"exit_code":0}"#
 23}
 24
 25struct ClientPagingTests {
 26
 27    @Test func readPageSendsLimitAndCursorAndDecodesNext() async throws {
 28        let (client, stub) = try makeClient()
 29        stub.enqueue(.init(status: 200, json: page([1, 2], next: "abc")))
 30        stub.enqueue(.init(status: 200, json: page([3])))
 31
 32        let first = try await client.readPage(["issue", "list", "krz/gitbay"], of: Row.self, limit: 2)
 33        let second = try await client.readPage(
 34            ["issue", "list", "krz/gitbay"], of: Row.self, limit: 2, cursor: first.next
 35        )
 36
 37        #expect(first.items == [Row(number: 1), Row(number: 2)])
 38        #expect(first.next == "abc")
 39        #expect(second.items == [Row(number: 3)])
 40        #expect(second.next == nil)
 41        let firstSeen = try #require(stub.seen.first)
 42        #expect(firstSeen.url.query() ==
 43            "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2")
 44        let secondSeen = try #require(stub.seen.dropFirst().first)
 45        #expect(secondSeen.url.query() ==
 46            "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc")
 47    }
 48}
 49
 50@MainActor
 51struct PagedListModelTests {
 52
 53    private func makeList(pageSize: Int = 2) throws -> (PagedListModel<Row>, StubProtocol.Box) {
 54        let (client, stub) = try makeClient()
 55        let list = PagedListModel<Row>(
 56            client: client,
 57            argv: ["issue", "list", "krz/gitbay"],
 58            emptyMessage: "nothing",
 59            pageSize: pageSize
 60        )
 61        return (list, stub)
 62    }
 63
 64    @Test func loadMoreAppendsUntilTheCursorRunsOut() async throws {
 65        let (list, stub) = try makeList()
 66        stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
 67        stub.enqueue(.init(status: 200, json: page([3, 4], next: "c2")))
 68        stub.enqueue(.init(status: 200, json: page([5])))
 69
 70        await list.reload()
 71        #expect(list.state.value == [Row(number: 1), Row(number: 2)])
 72        #expect(list.hasMore)
 73
 74        await list.loadMore()
 75        await list.loadMore()
 76        #expect(list.state.value?.map(\.number) == [1, 2, 3, 4, 5])
 77        #expect(!list.hasMore)
 78
 79        // Further calls are no-ops, not requests.
 80        await list.loadMore()
 81        #expect(stub.seen.count == 3)
 82        let second = try #require(stub.seen.dropFirst().first)
 83        #expect(second.url.query()?.contains("argv=--cursor&argv=c1") == true)
 84        let third = try #require(stub.seen.dropFirst(2).first)
 85        #expect(third.url.query()?.contains("argv=--cursor&argv=c2") == true)
 86    }
 87
 88    @Test func anEmptyFirstPageIsAnEmptyState() async throws {
 89        let (list, stub) = try makeList()
 90        stub.enqueue(.init(status: 200, json: page([])))
 91
 92        await list.reload()
 93
 94        guard case .empty(let message) = list.state else {
 95            Issue.record("expected .empty, got \(list.state)")
 96            return
 97        }
 98        #expect(message == "nothing")
 99    }
100
101    @Test func aFailedLaterPageKeepsTheListAndTheCursor() async throws {
102        let (list, stub) = try makeList()
103        stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
104        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
105        stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
106
107        await list.reload()
108        await list.loadMore()
109
110        #expect(list.state.value == [Row(number: 1), Row(number: 2)])
111        #expect(list.hasMore) // the cursor survives for a retry
112
113        stub.enqueue(.init(status: 200, json: page([3])))
114        await list.loadMore()
115        #expect(list.state.value?.map(\.number) == [1, 2, 3])
116    }
117
118    /// A cancelled reload (e.g. superseded by a newer one from a fast
119    /// filter change) must not write a `.failed` state — whatever error
120    /// the cancellation surfaces as, it belongs to a fetch nobody wants
121    /// the result of any more.
122    @Test func aCancelledReloadDoesNotWriteAFailedState() async throws {
123        let (list, _) = try makeList()
124
125        let task = Task { await list.reload() }
126        task.cancel()
127        await task.value
128
129        if case .failed(let message) = list.state {
130            Issue.record("cancelled reload left the screen failed: \(message)")
131        }
132    }
133
134    @Test func reloadDropsTheOldCursorAndItems() async throws {
135        let (list, stub) = try makeList()
136        stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
137        stub.enqueue(.init(status: 200, json: page([9])))
138
139        await list.reload()
140        await list.reload()
141
142        #expect(list.state.value == [Row(number: 9)])
143        #expect(!list.hasMore)
144    }
145}