Files
Outpost/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift
T
Puranjay Savar Mattas 960919227b feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification
Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications
(users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed
against the "All notifications" master toggle live too).
2026-08-17 21:51:07 +01:00

511 lines
25 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() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { 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() }
}
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
}
}
}