Files
Outpost/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift
T
Puranjay Savar Mattas ce235d656d fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error
- performFullSync was requesting listCollections with limit: 250 —
  Outline caps pagination at 100 and rejects anything over that
  outright, which was the actual "Synced with 1 error" (now visible
  in the UI as of the last fix: "Pagination limit is too large (max
  100)"). Paginate collections in increments of 100 the same way
  documents already were, continuing until a short page signals the
  end — the protocol doesn't expose a total count to ask for up front,
  so this is the only way to know when to stop. 2 new regression tests.
- OfflineBanner only ever reflected NetworkMonitor (a real dropped
  connection) — turning on the manual "Offline Mode" toggle did
  nothing to it, since that's a separate AppStorage flag the banner
  never read. ContentView_macOS's sidebar now shows the banner for
  either condition, with distinct copy for "you turned this on" vs
  "the network's actually down".
- NetworkMonitor: the previous fix (weak self only on the inner Task)
  traded one Swift 6 error for another ("'weak' ownership of capture
  'self' differs from implicitly-captured strong reference in outer
  scope") since the inner closure's capture forced the outer one to
  implicitly capture self too. Standard fix: weak capture on the outer
  closure, guard-let into a strong local immediately, let the inner
  Task closure capture that plain local instead.
2026-08-15 00:45:09 +01:00

389 lines
18 KiB
Swift

import XCTest
@testable import OutlineKit
private struct NotStubbed: Error {}
/// Conforms to the full protocol (so it can stand in for `live`), but only
/// the two methods under test in this file have real behavior — everything
/// else throws loudly if a test accidentally exercises it.
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
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() }
func documentInfo(id: String) async throws -> OutlineDocument {
guard let handler = documentInfoHandler else { throw NotStubbed() }
return try await handler(id)
}
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
func revokeShare(id: String) async throws { throw NotStubbed() }
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
guard let handler = createPinHandler else { throw NotStubbed() }
return try await handler(request)
}
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
func deletePin(id: String) async throws {
guard let handler = deletePinHandler else { throw NotStubbed() }
try await handler(id)
}
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
guard let handler = listCollectionsHandler else { throw NotStubbed() }
return try await handler(offset, limit)
}
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
func deleteCollection(id: String) async throws { throw NotStubbed() }
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
}
private struct StubTransportError: Error {}
final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
}
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
OutlineDocument(
id: id,
title: title,
text: "body",
url: "/doc/\(id)",
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
OutlineCollection(
id: id,
name: name,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
}
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
// Second call fails live — should transparently return the cached value.
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
XCTAssertEqual(callCount, 2)
}
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.documentInfo(id: "doc-1")
XCTFail("Expected an error")
} catch is StubTransportError {
// expected
}
}
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let collections = [makeCollection()]
var callCount = 0
stub.listCollectionsHandler = { _, _ in
callCount += 1
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(result, collections)
}
func testDifferentDocumentIdsAreCachedSeparately() async throws {
let stub = StubOutlineAPIClient()
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedOne = try await sut.documentInfo(id: "doc-1")
let cachedTwo = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedOne, docOne)
XCTAssertEqual(cachedTwo, docTwo)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected — nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back — replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline — replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
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..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
}