Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.
Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.
Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.
8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
343 lines
16 KiB
Swift
343 lines
16 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)?
|
|
|
|
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] {
|
|
throw NotStubbed()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|