createDocument now queues and syncs like the other offline-capable writes, closing the gap deliberately left open earlier this session. Synthesizes a pending-<uuid> document (same placeholder scheme as pin/star/subscribe), caches it so it's immediately readable/openable, and queues the real create. On sync, the server's real document replaces the placeholder in the cache — no dead orphan entries. Editing that same still-unsynced document folds the edit into the pending create's payload instead of queuing a separate update, which would otherwise target the placeholder id and 404 forever once flushed. CreateDocumentRequest widened Encodable -> Codable for the queue round-trip. Known limitation: a reader still open on that exact document at the moment it syncs in the background keeps holding the stale placeholder id until navigated away and back. Narrow, not solved generally here. 3 new OutlineKit tests (57/57). Removed the now-unneeded offline restriction on the reader's New Document button/menu item.
501 lines
24 KiB
Swift
501 lines
24 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 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 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-"))
|
|
}
|
|
|
|
/// 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")] : [] }
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|