// ===----------------------------------------------------------------------=== // // This source file is part of the ASConnectService open source project // // Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors // Licensed under Apache license v2.0 // // See LICENSE for license information // See CONTRIBUTORS for the list of ASConnectService project authors // // SPDX-License-Identifier: Apache-2.0 // // ===----------------------------------------------------------------------=== import Foundation import HTTPTypes import OpenAPIRuntime import Testing // A non-testable import ensures that the sequence can be created from outside the module. import ASConnectService @Suite("PagedSequence") struct PagedSequenceTests { // MARK: Properties private let server = Server(pages: 3) // MARK: Tests @Test("Fetches every page, passing the cursor of each next link") func fetchesEveryPage() async throws { var pages: [Int] = [] for try await page in makeSequence() { pages.append(page.number) } #expect(pages == [1, 2, 3]) #expect(await server.cursors == [nil, "2", "3"]) } @Test("Does not fetch any page until the sequence is iterated") func fetchesLazily() async throws { _ = makeSequence() #expect(await server.cursors.isEmpty) } @Test("Finishes the iteration after a fetch throws") func finishesAfterError() async throws { await server.fail(atPage: 2) var iterator = makeSequence().makeAsyncIterator() #expect(try await iterator.next()?.number == 1) do { _ = try await iterator.next() Issue.record("Fetching the second page should throw") } catch { #expect(error is ServerError) } #expect(try await iterator.next() == nil) #expect(await server.cursors == [nil, "2"]) } @Test("Pages through a generated operation, sending the cursor as a query parameter") func pagesThroughGeneratedOperation() async throws { let transport = PagedTransport() let client = Client( serverURL: try Servers.Server1.url(), transport: transport ) let pages = PagedSequence(links: \.links) { cursor in try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json } var count = 0 for try await _ in pages { count += 1 } #expect(count == 2) #expect(await transport.paths == ["/v1/apps?limit=200", "/v1/apps?limit=200&cursor=Mg"]) } } // MARK: - Helpers private extension PagedSequenceTests { func makeSequence() -> PagedSequence { let server = server return PagedSequence(links: \.links) { cursor in try await server.page(at: cursor) } } } /// A page of a paged operation. private struct TestPage: Sendable { let number: Int let links: Components.Schemas.PagedDocumentLinks } /// An actor that serves a number of pages, and records the cursors it is asked for. private actor Server { private let pages: Int private var failingPage: Int? private(set) var cursors: [String?] = [] init(pages: Int) { self.pages = pages } func fail(atPage page: Int) { failingPage = page } func page(at cursor: String?) throws -> TestPage { cursors.append(cursor) let number = cursor.flatMap { Int($0) } ?? 1 if number == failingPage { throw ServerError.unavailable } let base = "https://api.appstoreconnect.apple.com/v1/apps" return TestPage( number: number, links: .init( _self: base, next: number < pages ? "\(base)?cursor=\(number + 1)" : nil ) ) } } /// An error thrown by the server. private enum ServerError: Error { case unavailable } /// A transport that serves two pages of apps, and records the paths of the requests it sends. private actor PagedTransport: ClientTransport { private(set) var paths: [String?] = [] func send( _ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String ) async throws -> (HTTPResponse, HTTPBody?) { paths.append(request.path) let isFirstPage = paths.count == 1 let next = isFirstPage ? #", "next": "https://api.appstoreconnect.apple.com/v1/apps?cursor=Mg&limit=200""# : "" let json = #"{ "data": [], "links": { "self": "https://api.appstoreconnect.apple.com/v1/apps"\#(next) } }"# return ( HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), HTTPBody(json) ) } }