diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 9e39a7e..d62dca6 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -364,10 +364,23 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { var documentsCount = 0 var errors: [String] = [] var collections: [OutlineCollection] = [] - do { - collections = try await listCollections(offset: 0, limit: 250) - } catch { - errors.append(errorDescription(error)) + var collectionsOffset = 0 + let collectionsLimit = 100 + // Outline rejects any `limit` over 100 outright — page in increments + // of that instead of guessing a total up front (the protocol doesn't + // expose `pagination`'s total count, only the page itself); a page + // shorter than the limit is what signals "that was the last one". + while true { + let page: [OutlineCollection] + do { + page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit) + } catch { + errors.append(errorDescription(error)) + break + } + collections.append(contentsOf: page) + guard page.count == collectionsLimit else { break } + collectionsOffset += collectionsLimit } for collection in collections { diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index f53b18e..a5ab991 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -12,6 +12,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)? var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? var deletePinHandler: (@Sendable (String) async throws -> Void)? + var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } @@ -21,7 +22,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable } func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { - throw NotStubbed() + guard let handler = listDocumentsHandler else { throw NotStubbed() } + return try await handler(collectionId, parentDocumentId, offset, limit) } func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } @@ -339,4 +341,48 @@ final class CachingOutlineAPIClientTests: XCTestCase { let clearedSummary = await sut.cacheStorageSummary() XCTAssertEqual(clearedSummary.itemCount, 0) } + + // MARK: - Full sync + + func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws { + let stub = StubOutlineAPIClient() + var requestedLimits: [Int] = [] + // 105 collections across two pages (100 + 5) — regression test for a + // real bug: this used to ask for `limit: 250` in one shot, which + // Outline's server rejects outright ("Pagination limit is too large + // (max 100)"), turning the whole sync into a single silent failure. + stub.listCollectionsHandler = { offset, limit in + requestedLimits.append(limit) + let remaining = max(0, 105 - offset) + let count = min(limit, remaining) + return (0..