- 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.
389 lines
18 KiB
Swift
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")
|
|
}
|
|
}
|