Foundation for turning the app's try?-swallowed API failures (see the pins bug) into something self-diagnosing instead of silent, without a manual "Retry" button nagging the user for every blip. RetryPolicy.withRetry wraps a call with exponential backoff, but only for OutlineAPIError.transport - a decode/auth/server error will look identical on a second try, so those fail immediately instead of burning the cooldown window. CachingOutlineAPIClient now runs every live call (both the cached-read path and the queueable-write path) through it, and keeps a per-category sliding-window failure log: repeatedFailureSummaries() surfaces a category only once it's failed 3+ times in 5 minutes with a structural (non-transport) error - plain connectivity loss is deliberately excluded since that already has its own offline UI elsewhere, and logging it here too would just be a redundant second banner every time Wi-Fi drops. Pull-based (polled), not push - this actor has no UI dependency of its own, so the Outpost-side banner reads this periodically instead of the client taking a callback. Categories are coarse (documents, collections, pins, subscriptions, stars, drafts, etc.) and the summaries carry no document content or server URL, only a generic error description - safe to show a user or attach to a bug report as-is. 10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests), 92/92 passing overall.
655 lines
32 KiB
Swift
655 lines
32 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])?
|
|
var createDocumentHandler: (@Sendable (CreateDocumentRequest) 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 listDrafts(_ request: ListDraftsRequest) 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 {
|
|
guard let handler = createDocumentHandler else { throw NotStubbed() }
|
|
return try await handler(request)
|
|
}
|
|
|
|
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 listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
|
|
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
|
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
|
|
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
|
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
|
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
|
func removeReaction(commentId: String, emoji: String) async throws { 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() }
|
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
|
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
|
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
|
|
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
|
func deleteAccount() async throws { throw NotStubbed() }
|
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
|
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
|
func installationInfo() async throws -> OutlineInstallationInfo { 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-"))
|
|
}
|
|
|
|
/// Regression test: manual offline mode used to only gate writes —
|
|
/// reads (listCollections, documentInfo, etc.) still hit `live` first
|
|
/// any time the device actually had a connection, silently defeating
|
|
/// "skip the network entirely" for the one thing that mattered most:
|
|
/// the sidebar showing more than what was actually cached.
|
|
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
|
|
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
|
|
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
|
|
|
|
let stub = StubOutlineAPIClient()
|
|
let cachedCollection = makeCollection(id: "col-1")
|
|
var liveCallCount = 0
|
|
stub.listCollectionsHandler = { _, _ in
|
|
liveCallCount += 1
|
|
return [cachedCollection]
|
|
}
|
|
let cache = try makeCache()
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
|
|
|
// Online first — populates the cache normally.
|
|
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
|
XCTAssertEqual(firstResult, [cachedCollection])
|
|
XCTAssertEqual(liveCallCount, 1)
|
|
|
|
// Flip manual offline mode on, still "connected" (stub would happily
|
|
// answer) — the live call must not be attempted at all.
|
|
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
|
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
|
|
|
|
XCTAssertEqual(secondResult, [cachedCollection])
|
|
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
|
|
}
|
|
|
|
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")] : [] }
|
|
// Must return empty for any non-nil parentDocumentId (no children) —
|
|
// performFullSync now recurses into every document's own children,
|
|
// so a stub that ignores parentDocumentId and always returns the
|
|
// same root documents regardless would recurse into itself forever.
|
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
|
guard parentDocumentId == nil else { return [] }
|
|
return 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")
|
|
}
|
|
|
|
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
|
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) —
|
|
// regression test for the real gap this fixed: only root-level
|
|
// documents were ever cached before, so a document's own
|
|
// sub-documents were never reachable offline at all unless
|
|
// something else happened to open them individually first.
|
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
|
guard offset == 0 else { return [] }
|
|
switch parentDocumentId {
|
|
case nil: return [self.makeDocument(id: "doc-1")]
|
|
case "doc-1": return [self.makeDocument(id: "doc-2")]
|
|
case "doc-2": return [self.makeDocument(id: "doc-3")]
|
|
default: return []
|
|
}
|
|
}
|
|
let cache = try makeCache()
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
|
|
|
let summary = await sut.performFullSync()
|
|
|
|
XCTAssertEqual(summary.documentsCount, 3)
|
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
|
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
|
|
XCTAssertEqual(cachedGrandchild.id, "doc-3")
|
|
}
|
|
|
|
// MARK: - Offline document creation
|
|
|
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
let created = try await sut.createDocument(
|
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
|
)
|
|
|
|
XCTAssertTrue(created.id.hasPrefix("pending-"))
|
|
XCTAssertEqual(created.title, "New Doc")
|
|
XCTAssertEqual(created.text, "hello")
|
|
|
|
// It's immediately readable, same as any other cached document.
|
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
|
let reopened = try await sut.documentInfo(id: created.id)
|
|
XCTAssertEqual(reopened.title, "New Doc")
|
|
|
|
let pending = await sut.pendingOperations()
|
|
XCTAssertEqual(pending.count, 1)
|
|
XCTAssertEqual(pending.first?.kind, "createDocument")
|
|
}
|
|
|
|
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
let created = try await sut.createDocument(
|
|
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
|
)
|
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
|
|
|
|
let pending = await sut.pendingOperations()
|
|
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
|
|
XCTAssertEqual(pending.first?.kind, "createDocument")
|
|
|
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
|
let reopened = try await sut.documentInfo(id: created.id)
|
|
XCTAssertEqual(reopened.title, "Real Title")
|
|
XCTAssertEqual(reopened.text, "Real body")
|
|
}
|
|
|
|
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
let created = try await sut.createDocument(
|
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
|
)
|
|
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
|
|
stub.createDocumentHandler = { _ in realDocument }
|
|
|
|
let summary = await sut.flushPendingOperations()
|
|
XCTAssertEqual(summary.succeeded, 1)
|
|
XCTAssertEqual(summary.failed, 0)
|
|
|
|
// The real document is now readable under its real id...
|
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
|
let byRealId = try await sut.documentInfo(id: "real-server-id")
|
|
XCTAssertEqual(byRealId.id, "real-server-id")
|
|
|
|
// ...and the placeholder is gone rather than left as a dead orphan.
|
|
do {
|
|
_ = try await sut.documentInfo(id: created.id)
|
|
XCTFail("Expected the placeholder cache entry to have been removed")
|
|
} catch {
|
|
// expected — nothing left under the old id
|
|
}
|
|
}
|
|
|
|
// MARK: - Repeated-failure tracking
|
|
|
|
/// A structural failure (decode/auth/server) isn't retried — it fails
|
|
/// the same way every time, so `RetryPolicy` gives up after one attempt
|
|
/// and it's logged immediately. No cache entry means no fallback either,
|
|
/// so every call rethrows.
|
|
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
for _ in 0..<3 {
|
|
_ = try? await sut.documentInfo(id: "doc-1")
|
|
}
|
|
|
|
let summaries = await sut.repeatedFailureSummaries()
|
|
XCTAssertEqual(summaries.count, 1)
|
|
XCTAssertEqual(summaries.first?.category, "document")
|
|
XCTAssertEqual(summaries.first?.count, 3)
|
|
}
|
|
|
|
/// Two failures alone shouldn't trip the banner — only three or more
|
|
/// within the window counts as "repeated."
|
|
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
for _ in 0..<2 {
|
|
_ = try? await sut.documentInfo(id: "doc-1")
|
|
}
|
|
|
|
let summaries = await sut.repeatedFailureSummaries()
|
|
XCTAssertTrue(summaries.isEmpty)
|
|
}
|
|
|
|
/// A later success clears the category entirely — a transient run of
|
|
/// bad luck shouldn't leave a stale banner up after things recover.
|
|
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
let document = makeDocument()
|
|
var callCount = 0
|
|
stub.documentInfoHandler = { _ in
|
|
callCount += 1
|
|
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
|
return document
|
|
}
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
for _ in 0..<3 {
|
|
_ = try? await sut.documentInfo(id: "doc-1")
|
|
}
|
|
let beforeRecovery = await sut.repeatedFailureSummaries()
|
|
XCTAssertEqual(beforeRecovery.count, 1)
|
|
|
|
_ = try await sut.documentInfo(id: "doc-1")
|
|
|
|
let afterRecovery = await sut.repeatedFailureSummaries()
|
|
XCTAssertTrue(afterRecovery.isEmpty)
|
|
}
|
|
|
|
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
|
|
/// own offline UI elsewhere — it shouldn't also pile up in the repeated-
|
|
/// failure log and pop a second, redundant banner.
|
|
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
_ = try? await sut.documentInfo(id: "doc-1")
|
|
|
|
let summaries = await sut.repeatedFailureSummaries()
|
|
XCTAssertTrue(summaries.isEmpty)
|
|
}
|
|
|
|
/// Different categories (document reads vs. pin writes) track
|
|
/// independently — a broken pins endpoint shouldn't mask, or be masked
|
|
/// by, unrelated document failures, and one crossing the threshold
|
|
/// shouldn't drag an unrelated one along with it.
|
|
func testFailuresInDifferentCategoriesDoNotMix() async throws {
|
|
let stub = StubOutlineAPIClient()
|
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
|
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
|
|
|
// Document reads cross the threshold...
|
|
for _ in 0..<3 {
|
|
_ = try? await sut.documentInfo(id: "doc-1")
|
|
}
|
|
// ...pin creates don't.
|
|
for _ in 0..<2 {
|
|
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
|
|
}
|
|
|
|
let summaries = await sut.repeatedFailureSummaries()
|
|
XCTAssertEqual(summaries.map(\.category), ["document"])
|
|
}
|
|
}
|