CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the existing protocol boundary: always tries live, falls back to a SwiftData key/value cache only on failure, and overwrites the cache on every live success. Applies to the read/browse path only (collections, document lists, document content) — search, writes, and sharing pass straight through uncached. Wired in once at SessionStore.makeAPIClient, so no view model needed to change. 5 new tests via a stub OutlineAPIClient (43/43 passing). NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the cache's own fallback logic — it only drives a new sidebar OfflineBanner so the user knows when they might be looking at stale content.
178 lines
9.1 KiB
Swift
178 lines
9.1 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])?
|
|
|
|
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 { throw NotStubbed() }
|
|
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 { throw NotStubbed() }
|
|
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
|
|
func deletePin(id: String) async throws { throw NotStubbed() }
|
|
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)
|
|
}
|
|
}
|