diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 8b3880105..bc3c23158 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 17E81F2F261FF758001003C2 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 17E81F32261FF758001003C2 /* Localizable.stringsdict */; }; 17E9AE0559A0BB077B74727A /* AudiobookSkipIntervalSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A379F8B76503AB9D2BB9DB48 /* AudiobookSkipIntervalSettings.swift */; }; 17F97CFDEAB38ABDEC9504CA /* SpyAudiobookNetworkExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122AFD9575652073A29BA7A7 /* SpyAudiobookNetworkExecutor.swift */; }; + 181FDBB5DF170830CFDA55D2 /* AccountRegistryCacheSeamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2745714D1B395FCB45D9C3 /* AccountRegistryCacheSeamTests.swift */; }; 1821A08D1697605DF91F7831 /* PalaceTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF90048A6DDD9A6025494F1A /* PalaceTestCase.swift */; }; 182E9D0BBE43C29F3F06182B /* BookReturnServiceAuthCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F74FFEBA03277AC76B3882 /* BookReturnServiceAuthCoordinatorTests.swift */; }; 184131D3C04F95ED70463EB4 /* TPPCredentialsCoverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55BD3620F51707395548D0 /* TPPCredentialsCoverageTests.swift */; }; @@ -1066,6 +1067,7 @@ AEAF14B65600C055E3C65996 /* MyBooksDownloadCenter+AccountScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B8F01A32F29AD53EEF2F39 /* MyBooksDownloadCenter+AccountScope.swift */; }; AEBFC0D1E2F304152637484A /* BookReturnServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC0D1E2F30415263748495B /* BookReturnServiceTests.swift */; }; AEBFC0D1E2F304152637485B /* CredentialPromptCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC0D1E2F30415263748496C /* CredentialPromptCoordinatorTests.swift */; }; + AEFABDFE2619088183740385 /* AccountRegistryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3B037BFF273D82D2D88CE5 /* AccountRegistryCache.swift */; }; AF365318D94A1ED196775AC5 /* PalaceAudiobookToolkit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7F9BABE2A8E6811001697DE /* PalaceAudiobookToolkit.framework */; }; AF890D5839204832223A2287 /* OPDSParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FDA12704CE45280D151E7AC /* OPDSParsingTests.swift */; }; AF9A6A3E4D9878C6C80DD57F /* NetworkCacheClearRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1582EB5A4DE21F7E9F715EE0 /* NetworkCacheClearRoutingTests.swift */; }; @@ -1324,6 +1326,7 @@ D84A746313A44F09DD90DC0C /* ReadiumPDFReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4494F50352E88455B28AD34E /* ReadiumPDFReaderView.swift */; }; D8572FF3816736AA50C83DA5 /* TPPBookRegistryRebuildRefusalContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE80265D45D506F30737A616 /* TPPBookRegistryRebuildRefusalContractTests.swift */; }; D88A3E4B3868A2FAEC978110 /* AppRatingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8634A05347B14A483EBB459D /* AppRatingService.swift */; }; + D8AEB498F4BA7DC04FC5A30E /* AccountRegistryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF3B037BFF273D82D2D88CE5 /* AccountRegistryCache.swift */; }; D8C46AF7754FE634B2354952 /* TPPUserAccountConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48EA85A0DBA077D1BBAD063 /* TPPUserAccountConcurrencyTests.swift */; }; D8E3E2F8DB67110026272CFB /* MockVisualNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B6896C9742C07208D935E /* MockVisualNavigator.swift */; }; D91512129048356CDC3C9542 /* PalaceCheckPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFC130078A169AED6A9B47B /* PalaceCheckPropertyTests.swift */; }; @@ -2765,6 +2768,7 @@ 787DCAF97FD5E190363EC3CA /* AudiobookSessionManagerFlagGatePresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookSessionManagerFlagGatePresentationTests.swift; sourceTree = ""; }; 794351D0F625F9610414FA23 /* SpyAudiobookSessionPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpyAudiobookSessionPresenter.swift; sourceTree = ""; }; 79DE74A085DC8D24F0CC2348 /* MockBackgroundDownloadDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackgroundDownloadDelegate.swift; sourceTree = ""; }; + 7A2745714D1B395FCB45D9C3 /* AccountRegistryCacheSeamTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountRegistryCacheSeamTests.swift; sourceTree = ""; }; 7AC813FFA86DD9F5C27FC9B9 /* TPPUserAccountTestFactoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPUserAccountTestFactoryTests.swift; sourceTree = ""; }; 7B2C3D4E5F6A7B8C9D0E1F2A /* DownloadAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAlertPresenter.swift; sourceTree = ""; }; 7B8D9E0F102132435465A1B9 /* BookSignInRedirectHandlerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSignInRedirectHandlerTests.swift; sourceTree = ""; }; @@ -3475,6 +3479,7 @@ EECE59544C34814CE86EF2AB /* ColorExtensionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ColorExtensionTests.swift; sourceTree = ""; }; EEE0C3EAFC52054E7144B795 /* DownloadStateManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadStateManagerTests.swift; sourceTree = ""; }; EF1557BA5E024E70AE55ACE2 /* ForceResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceResetTests.swift; sourceTree = ""; }; + EF3B037BFF273D82D2D88CE5 /* AccountRegistryCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountRegistryCache.swift; sourceTree = ""; }; EF6CA6FD20109963823CABD1 /* AppLaunchTrackerExtendedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchTrackerExtendedTests.swift; sourceTree = ""; }; F002381F5C11990812629F32 /* TPPSignInAdobeSkipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPSignInAdobeSkipTests.swift; sourceTree = ""; }; F00D000000000000000F5502 /* MyBooksDownloadSessionInvalidationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MyBooksDownloadSessionInvalidationTests.swift; sourceTree = ""; }; @@ -4970,6 +4975,7 @@ 692DF9B78A5B64F59033D35C /* CatalogCrawlScheduler.swift */, 2C353AC0344431D385FC89E6 /* AccountSwitchDependencies.swift */, 5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */, + EF3B037BFF273D82D2D88CE5 /* AccountRegistryCache.swift */, ); path = Library; sourceTree = ""; @@ -6060,6 +6066,7 @@ 333733730937345770AF9416 /* TPPSignInBusinessLogicCharacterizationTests.swift */, 41F50CFA2EBD6E2F19F568E0 /* TPPSignInCapabilitiesCharacterizationTests.swift */, 9963A35F959660D52B46E3EC /* AccountNetworkingSeamTests.swift */, + 7A2745714D1B395FCB45D9C3 /* AccountRegistryCacheSeamTests.swift */, ); name = Decomp; path = Decomp; @@ -8060,6 +8067,7 @@ 4E3744149885D84DD020BB3B /* AccountsManagerCatalogLoadJoinTests.swift in Sources */, 2FEE2557210153D5C94C4D28 /* MyBooksDownloadCenterAccountScopeSeamTests.swift in Sources */, DE253F8C8C4CC3BA89F45090 /* AccountNetworkingSeamTests.swift in Sources */, + 181FDBB5DF170830CFDA55D2 /* AccountRegistryCacheSeamTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -8628,6 +8636,7 @@ 674A6E70BD51E2989885ECAC /* MyBooksDownloadCenter+AccountScope.swift in Sources */, 4BD9BDFB996BF9E0576420CB /* AccountNetworking.swift in Sources */, A7D4A0D17DACC00FB34F5289 /* TPPNetworkExecutor+AccountNetworking.swift in Sources */, + AEFABDFE2619088183740385 /* AccountRegistryCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9199,6 +9208,7 @@ AEAF14B65600C055E3C65996 /* MyBooksDownloadCenter+AccountScope.swift in Sources */, A81B474445D23D25DD33F00F /* AccountNetworking.swift in Sources */, 31E755A021BE24D982712F6B /* TPPNetworkExecutor+AccountNetworking.swift in Sources */, + D8AEB498F4BA7DC04FC5A30E /* AccountRegistryCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Palace/Accounts/Library/AccountRegistryCache.swift b/Palace/Accounts/Library/AccountRegistryCache.swift new file mode 100644 index 000000000..16110765d --- /dev/null +++ b/Palace/Accounts/Library/AccountRegistryCache.swift @@ -0,0 +1,298 @@ +// +// AccountRegistryCache.swift +// Palace +// +// god-class decomposition — Wave 3 / 3a-1 (the first in-target collaborator +// split out of `AccountsManager`). +// +// The on-disk catalog cache concern: the stale-while-revalidate metadata value +// type and the FileManager-backed read/write/staleness/clear operations the +// registry loader and launch-preload paths use. Extracted behind an injected +// `AccountRegistryCaching` seam so the hub carries no disk-I/O body and the +// collaborator is a spy-testable double — the `AccountNetworking` / S3 precedent. +// Travels into `PalaceAccounts` with `AccountsManager` at the package move. +// +// `Sendable`: catalog writes/reads run inside the loader's owned-crawl +// `@Sendable` Tasks (`spawnOwnedCrawlTask`), so the injected cache must cross that +// boundary. `DiskAccountRegistryCache` is a stateless value type, so the +// conformance is free. +// +// Copyright © 2026 The Palace Project. All rights reserved. +// + +import Foundation + +// MARK: - Cache Metadata + +/// Metadata for tracking cache freshness in stale-while-revalidate pattern +struct CatalogCacheMetadata: Codable { + let timestamp: Date + let hash: String + /// `true` when this cache entry was populated from the build-time + /// bundled snapshot (`Palace/Accounts/Library/bundled_registry.json`), + /// `false` for entries written from a network response. Bundled-origin + /// caches return `true` from `isStale(serverMaxAge:now:)` regardless + /// of timestamp so the refresh trigger keeps firing on every + /// `loadCatalogs` call until a real network response overwrites the + /// metadata with `isBundled = false`. Decodes as `false` for legacy + /// metadata files written before this field existed. + let isBundled: Bool + + init(timestamp: Date, hash: String, isBundled: Bool = false) { + self.timestamp = timestamp + self.hash = hash + self.isBundled = isBundled + } + + private enum CodingKeys: String, CodingKey { + case timestamp, hash, isBundled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + self.hash = try container.decode(String.self, forKey: .hash) + self.isBundled = try container.decodeIfPresent(Bool.self, forKey: .isBundled) ?? false + } + + /// Default stale TTL: 6 hours (half the server's typical Cache-Control + /// max-age of 12hr). Overridden dynamically by `staleTTL(serverMaxAge:)`. + private static let defaultStaleTTL: TimeInterval = 21600 + + /// Cache expires after 24 hours (must not be used) + private static let maxAge: TimeInterval = 86400 + + /// Returns the stale TTL, dynamically adjusted from the server's + /// Cache-Control max-age if available. Uses half the server's value + /// with a floor of 5 minutes and a ceiling of 12 hours. + static func staleTTL(serverMaxAge: TimeInterval?) -> TimeInterval { + guard let serverMax = serverMaxAge, serverMax > 0 else { + return defaultStaleTTL + } + let half = serverMax / 2 + return min(max(half, 300), 43200) // clamp to [5min, 12hr] + } + + /// Returns true if cache is stale given the server's max-age hint. + func isStale(serverMaxAge: TimeInterval?) -> Bool { + isStale(serverMaxAge: serverMaxAge, now: Date()) + } + + func isStale(serverMaxAge: TimeInterval?, now: Date) -> Bool { + // Bundled-origin caches are always stale for refresh purposes + // regardless of timestamp. The bundled bytes are usable for + // immediate display but not authoritative, so refresh has to + // keep firing until a real network response overwrites with + // `isBundled = false`. + if isBundled { return true } + return now.timeIntervalSince(timestamp) > Self.staleTTL(serverMaxAge: serverMaxAge) + } + + /// Returns true if cache is stale using the default TTL (no server hint). + var isStale: Bool { + isStale(serverMaxAge: nil) + } + + /// Returns true if cache is expired (older than 24 hours) + var isExpired: Bool { + isExpired(now: Date()) + } + + func isExpired(now: Date) -> Bool { + now.timeIntervalSince(timestamp) > Self.maxAge + } + + /// Pure staleness predicate over an optional metadata + server hint. + /// Returns `true` when metadata is missing (⇒ refresh) OR when the metadata + /// reports staleness against `serverMaxAge`. Lives on the metadata type (it + /// is metadata logic) so the nil-metadata → refresh path stays unit-testable + /// without touching the file system (F-013). Moved from `AccountsManager` in + /// the Wave 3 / 3a-1 disk-cache extraction. + static func isCacheStale( + metadata: CatalogCacheMetadata?, + serverMaxAge: TimeInterval? + ) -> Bool { + guard let metadata else { + // No metadata means we should refresh + return true + } + return metadata.isStale(serverMaxAge: serverMaxAge) + } +} + +// MARK: - Registry cache seam + +/// The disk-cache surface `AccountsManager` consumes on the launch-preload, +/// `loadCatalogs` stale-while-revalidate, and `clearCache` paths. Declared beside +/// the hub (moves into `PalaceAccounts` at the package move); `DiskAccountRegistryCache` +/// is the production implementation, tests inject a recording double. +protocol AccountRegistryCaching: Sendable { + /// Persist catalog bytes for `hash` and stamp fresh metadata. `isBundled` + /// distinguishes build-time-snapshot writes from authoritative network writes + /// so staleness logic keeps refresh alive on bundled-origin caches. + func writeCatalogData(_ data: Data, hash: String, isBundled: Bool) + + /// The catalog blob for `hash`, or nil if absent/unreadable. + func readCatalogData(hash: String) -> Data? + + /// True if cached data exists and is not expired (may be stale but usable). + /// Probes existence with `fileExists` — never reads the ~2.4MB blob. + func hasFreshCatalogData(hash: String) -> Bool + + /// True if cache exists and is stale (needs a background refresh). + func isCatalogStale(hash: String) -> Bool + + /// On-disk location of the CP-D1 slim launch snapshot for `hash`. + func slimSnapshotURL(hash: String) -> URL? + + /// Delete all on-disk catalog/metadata/list/crawl caches (the `clearCache` + /// file sweep). Does NOT touch the network response cache — that is the + /// caller's `networkExecutor.clearCache()` (the `AccountNetworking` seam). + func clearFileCaches() +} + +extension AccountRegistryCaching { + /// Convenience for the common network-write path (`isBundled: false`). A + /// protocol requirement can't carry a default argument, and the concrete + /// impl's default is invisible through the `any AccountRegistryCaching` + /// existential — this restores the call-site ergonomics the hub relied on. + func writeCatalogData(_ data: Data, hash: String) { + writeCatalogData(data, hash: hash, isBundled: false) + } +} + +/// `FileManager`-backed production `AccountRegistryCaching`. Stateless ⇒ `Sendable`. +struct DiskAccountRegistryCache: AccountRegistryCaching { + + func writeCatalogData(_ data: Data, hash: String, isBundled: Bool) { + // Save catalog data + guard let url = accountsCatalogUrl(hash: hash) else { return } + try? data.write(to: url) + + // Save metadata with current timestamp. `isBundled` distinguishes + // build-time-snapshot writes from authoritative network writes so + // staleness logic can keep refresh alive on bundled-origin caches. + let metadata = CatalogCacheMetadata(timestamp: Date(), hash: hash, isBundled: isBundled) + if let metadataUrl = cacheMetadataUrl(hash: hash), + let metadataData = try? JSONEncoder().encode(metadata) { + try? metadataData.write(to: metadataUrl) + } + } + + func readCatalogData(hash: String) -> Data? { + guard let url = accountsCatalogUrl(hash: hash) else { return nil } + return try? Data(contentsOf: url) + } + + /// Returns true if cached data exists and is not expired (can be stale but usable). + /// + /// Existence is probed with `FileManager.fileExists` — NOT a full + /// `Data(contentsOf:)` — so this check never reads the ~2.4MB catalog blob + /// off disk. The single authoritative byte read happens exactly once per + /// launch at the caller (`preloadAccountsFromDiskCacheSync` and the + /// `loadCatalogs` stale-while-revalidate branch), which pair this gate with + /// one `readCatalogData` and thread those bytes into the decode. + func hasFreshCatalogData(hash: String) -> Bool { + guard let url = accountsCatalogUrl(hash: hash), + FileManager.default.fileExists(atPath: url.path) else { return false } + guard let metadata = readCacheMetadata(hash: hash) else { + // Data exists but no metadata - treat as usable but stale + return false + } + return !metadata.isExpired + } + + func isCatalogStale(hash: String) -> Bool { + let metadata = readCacheMetadata(hash: hash) + let serverMaxAge = readCrawlState(hash: hash)?.serverMaxAge + return CatalogCacheMetadata.isCacheStale(metadata: metadata, serverMaxAge: serverMaxAge) + } + + /// On-disk location of the CP-D1 slim launch snapshot (current + settings + /// accounts). The `accounts_catalog_slim_` name shares the + /// `accounts_catalog_` prefix, so `clearFileCaches()` and the wiring-suite + /// disk-cache purge already sweep it with no extra bookkeeping. CP-D1. + func slimSnapshotURL(hash: String) -> URL? { + guard let appSupport = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + else { return nil } + return appSupport.appendingPathComponent("accounts_catalog_slim_\(hash).json") + } + + func clearFileCaches() { + // file caches — delete all files matching known prefixes written to the + // Application Support root. (`authentication_document_` was removed: no + // code path writes a file with that prefix — auth docs live in `Account` + // state / are re-fetched, not persisted as files — so it cleared nothing.) + let prefixes = [ + "library_list_", + "accounts_catalog_", + "accounts_catalog_metadata_", + "crawl_state_", + ] + let fm = FileManager.default + guard let appSupport = try? fm.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) else { return } + + guard let files = try? fm.contentsOfDirectory( + at: appSupport, + includingPropertiesForKeys: nil + ) else { return } + + for file in files { + let name = file.lastPathComponent + if prefixes.contains(where: { name.hasPrefix($0) }) { + try? fm.removeItem(at: file) + } + } + } + + // MARK: - Private disk helpers + + private func accountsCatalogUrl(hash: String) -> URL? { + guard let appSupport = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + else { return nil } + return appSupport.appendingPathComponent("accounts_catalog_\(hash).json") + } + + private func cacheMetadataUrl(hash: String) -> URL? { + guard let appSupport = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + else { return nil } + return appSupport.appendingPathComponent("accounts_catalog_metadata_\(hash).json") + } + + /// Reads cache metadata for the given hash + private func readCacheMetadata(hash: String) -> CatalogCacheMetadata? { + guard let url = cacheMetadataUrl(hash: hash), + let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(CatalogCacheMetadata.self, from: data) + } + + /// Reads crawl state for the given hash (used for dynamic TTL adjustment) + private func readCrawlState(hash: String) -> CrawlState? { + guard let appSupport = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) else { return nil } + let url = appSupport.appendingPathComponent("crawl_state_\(hash).json") + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(CrawlState.self, from: data) + } +} diff --git a/Palace/Accounts/Library/AccountsManager.swift b/Palace/Accounts/Library/AccountsManager.swift index ddbf1183d..8ff1f167f 100644 --- a/Palace/Accounts/Library/AccountsManager.swift +++ b/Palace/Accounts/Library/AccountsManager.swift @@ -6,86 +6,8 @@ import PalaceBookRegistry let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" -// MARK: - Cache Metadata - -/// Metadata for tracking cache freshness in stale-while-revalidate pattern -struct CatalogCacheMetadata: Codable { - let timestamp: Date - let hash: String - /// `true` when this cache entry was populated from the build-time - /// bundled snapshot (`Palace/Accounts/Library/bundled_registry.json`), - /// `false` for entries written from a network response. Bundled-origin - /// caches return `true` from `isStale(serverMaxAge:now:)` regardless - /// of timestamp so the refresh trigger keeps firing on every - /// `loadCatalogs` call until a real network response overwrites the - /// metadata with `isBundled = false`. Decodes as `false` for legacy - /// metadata files written before this field existed. - let isBundled: Bool - - init(timestamp: Date, hash: String, isBundled: Bool = false) { - self.timestamp = timestamp - self.hash = hash - self.isBundled = isBundled - } - - private enum CodingKeys: String, CodingKey { - case timestamp, hash, isBundled - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.timestamp = try container.decode(Date.self, forKey: .timestamp) - self.hash = try container.decode(String.self, forKey: .hash) - self.isBundled = try container.decodeIfPresent(Bool.self, forKey: .isBundled) ?? false - } - - /// Default stale TTL: 6 hours (half the server's typical Cache-Control - /// max-age of 12hr). Overridden dynamically by `staleTTL(serverMaxAge:)`. - private static let defaultStaleTTL: TimeInterval = 21600 - - /// Cache expires after 24 hours (must not be used) - private static let maxAge: TimeInterval = 86400 - - /// Returns the stale TTL, dynamically adjusted from the server's - /// Cache-Control max-age if available. Uses half the server's value - /// with a floor of 5 minutes and a ceiling of 12 hours. - static func staleTTL(serverMaxAge: TimeInterval?) -> TimeInterval { - guard let serverMax = serverMaxAge, serverMax > 0 else { - return defaultStaleTTL - } - let half = serverMax / 2 - return min(max(half, 300), 43200) // clamp to [5min, 12hr] - } - - /// Returns true if cache is stale given the server's max-age hint. - func isStale(serverMaxAge: TimeInterval?) -> Bool { - isStale(serverMaxAge: serverMaxAge, now: Date()) - } - - func isStale(serverMaxAge: TimeInterval?, now: Date) -> Bool { - // Bundled-origin caches are always stale for refresh purposes - // regardless of timestamp. The bundled bytes are usable for - // immediate display but not authoritative, so refresh has to - // keep firing until a real network response overwrites with - // `isBundled = false`. - if isBundled { return true } - return now.timeIntervalSince(timestamp) > Self.staleTTL(serverMaxAge: serverMaxAge) - } - - /// Returns true if cache is stale using the default TTL (no server hint). - var isStale: Bool { - isStale(serverMaxAge: nil) - } - - /// Returns true if cache is expired (older than 24 hours) - var isExpired: Bool { - isExpired(now: Date()) - } - - func isExpired(now: Date) -> Bool { - now.timeIntervalSince(timestamp) > Self.maxAge - } -} +// `CatalogCacheMetadata` and the on-disk catalog cache moved to +// `AccountRegistryCache.swift` (Wave 3 / 3a-1) — injected via `registryCache`. @objc protocol TPPCurrentLibraryAccountProvider: NSObjectProtocol { var currentAccount: Account? { get } @@ -175,7 +97,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { /// Its four background `Task { [weak self] in … }` crawl/refresh/preload /// closures require a `@Sendable` capture of `self` — and they cannot be /// rewritten to "snapshot Sendable fields at the site" because each one drives -/// `self`'s instance I/O pipeline (`cacheAccountsCatalogData`, +/// `self`'s instance I/O pipeline (`registryCache.writeCatalogData`, /// `loadAccountSetsAndAuthDoc`, `fallbackFetchFromNetwork`, `triggerCatalogPreload`, /// `catalogPreloader`, `currentAccount`), which is the whole purpose of the Task. /// So the type itself must be `Sendable`. It is safe to share because EVERY @@ -284,6 +206,12 @@ private struct CrawlerHandoffBox: @unchecked Sendable { /// Injectable background-crawl spawn seam (see `CatalogCrawlScheduler`). /// Immutable `Sendable` `let`; `.production` by default, recording under test. private let crawlScheduler: CrawlTaskScheduler + /// On-disk catalog cache collaborator (Wave 3 / 3a-1). Immutable `Sendable` + /// `let`; the stateless `DiskAccountRegistryCache` by default, a recording + /// double under test. All catalog read/write/staleness/clear disk I/O routes + /// here, so the hub carries none of it and a packaged manager names no + /// FileManager cache body. + private let registryCache: any AccountRegistryCaching /// Owns every background catalog-crawl Task — production included (PP-4754): /// the previously fire-and-forget crawl now has an owner the reset /// choreography cancels + awaits, so no un-drainable write channel survives a @@ -584,12 +512,14 @@ private struct CrawlerHandoffBox: @unchecked Sendable { defaults: UserDefaults = .standard, borrowReauthResetter: any BorrowReauthResetting = DownloadCenterBorrowReauthResetter(), crawlScheduler: CrawlTaskScheduler = .production, - switchDependencies: AccountSwitchDependencies = .production + switchDependencies: AccountSwitchDependencies = .production, + registryCache: any AccountRegistryCaching = DiskAccountRegistryCache() ) { self.defaults = defaults self.borrowReauthResetter = borrowReauthResetter self.crawlScheduler = crawlScheduler self.switchDeps = switchDependencies + self.registryCache = registryCache self.settings = TPPSettings() self.accountSet = TPPConfiguration.customUrlHash() ?? (settings.useBetaLibraries @@ -681,10 +611,10 @@ private struct CrawlerHandoffBox: @unchecked Sendable { // `Account.awaitReady()` gate, which reads `AccountStateStore` keyed by // uuid and so survives the slim→full Account-instance swap. // - // Gated on `hasCachedCatalogData` (full-cache freshness): the slim + // Gated on `registryCache.hasFreshCatalogData` (full-cache freshness): the slim // snapshot carries no separate metadata, so a truly-expired cache still // falls through to the no-op below rather than hydrating stale data. - if hasCachedCatalogData(hash: hash), hydrateSlimLaunchSnapshot(hash: hash) { + if registryCache.hasFreshCatalogData(hash: hash), hydrateSlimLaunchSnapshot(hash: hash) { refreshSlimLaunchSnapshotOffMain(hash: hash) return } @@ -693,8 +623,8 @@ private struct CrawlerHandoffBox: @unchecked Sendable { // set synchronously exactly as before so behaviour is unchanged on that // one launch, then seed a slim snapshot off-main so the NEXT launch takes // the fast path above. - guard hasCachedCatalogData(hash: hash), - let cachedData = readCachedAccountsCatalogData(hash: hash) else { + guard registryCache.hasFreshCatalogData(hash: hash), + let cachedData = registryCache.readCatalogData(hash: hash) else { return } hydrateFullAccountSets(fromCatalogData: cachedData, hash: hash) @@ -710,7 +640,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { /// it is empty, or it fails to decode — the caller then takes the full sync /// path. CP-D1. private func hydrateSlimLaunchSnapshot(hash: String) -> Bool { - guard let url = slimSnapshotUrl(hash: hash), + guard let url = registryCache.slimSnapshotURL(hash: hash), FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url) else { return false @@ -836,7 +766,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { guard !Self._isRunningUnderXCTest else { return } spawnOwnedCrawlTask(priority: .utility, detached: true) { [weak self] in guard let self, !Task.isCancelled else { return } - guard let data = self.readCachedAccountsCatalogData(hash: hash) else { return } + guard let data = self.registryCache.readCatalogData(hash: hash) else { return } guard !Task.isCancelled else { return } self.writeSlimSnapshot(fromFullCatalogData: data, hash: hash) } @@ -847,7 +777,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { /// Runs on a background queue (never the launch main thread). CP-D1. private func writeSlimSnapshot(fromFullCatalogData data: Data, hash: String) { let keepUUIDs = slimSnapshotUUIDs() - guard !keepUUIDs.isEmpty, let url = slimSnapshotUrl(hash: hash) else { return } + guard !keepUUIDs.isEmpty, let url = registryCache.slimSnapshotURL(hash: hash) else { return } guard let carved = Self.carveSlimFeed(fromFullCatalogData: data, keepUUIDs: keepUUIDs) else { Log.warn(#file, "CP-D1: slim snapshot carve produced no data (hash=\(hash))") return @@ -1273,15 +1203,15 @@ private struct CrawlerHandoffBox: @unchecked Sendable { driveCurrentAccountAuthDocIfNeeded() completion?(true) // Still refresh in background if stale - if isCacheStale(hash: hash) { + if registryCache.isCatalogStale(hash: hash) { refreshInBackground(targetUrl: targetUrl, hash: hash) } return } // 2. Try disk cache first (stale-while-revalidate) - if hasCachedCatalogData(hash: hash), - let cachedData = readCachedAccountsCatalogData(hash: hash) { + if registryCache.hasFreshCatalogData(hash: hash), + let cachedData = registryCache.readCatalogData(hash: hash) { Log.info(#file, "Loading catalogs from cache (stale-while-revalidate), hash=\(hash), dataSize=\(cachedData.count)") // dedupe concurrent loads for initial cache load @@ -1333,7 +1263,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { // isBundled=true keeps the cache flagged as non-authoritative so // every subsequent loadCatalogs call still triggers refresh until // a real network response overwrites the metadata. - self.cacheAccountsCatalogData(bundledData, hash: hash, isBundled: true) + self.registryCache.writeCatalogData(bundledData, hash: hash, isBundled: true) self.loadAccountSetsAndAuthDoc(fromCatalogData: bundledData, key: hash) { _ in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) } @@ -1384,7 +1314,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { // Cooperative cancellation observation (swarm_4b64e4e0 Fix 2): // if `cancelBackgroundWork()` was called while we were awaiting // `crawlFirstPage`, drop the result on the floor instead of - // writing through to `accountSets` / `cacheAccountsCatalogData`. + // writing through to `accountSets` / `registryCache.writeCatalogData`. // Without this, a test that `_resetForTesting()`s the AppContainer // mid-fetch still sees the prior `AccountsManager`'s response // land in its caches a few ms later. @@ -1393,7 +1323,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { switch firstPageResult { case .success(let firstPageData, let firstPage): // Display first page immediately - self.cacheAccountsCatalogData(firstPageData, hash: hash) + self.registryCache.writeCatalogData(firstPageData, hash: hash) self.loadAccountSetsAndAuthDoc(fromCatalogData: firstPageData, key: hash) { success in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) self.callAndClearLoadingHandlers(for: hash, success) @@ -1426,7 +1356,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { if case .success(let fullData) = remainingResult { let fullCount = (try? OPDS2CatalogsFeed.fromData(fullData))?.catalogs.count ?? -1 Log.info(#file, "Background pagination complete: \(fullCount) total libraries cached") - self.cacheAccountsCatalogData(fullData, hash: hash) + self.registryCache.writeCatalogData(fullData, hash: hash) self.loadAccountSetsAndAuthDoc(fromCatalogData: fullData, key: hash) { _ in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) } @@ -1461,7 +1391,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { do { let (data, _) = try await self.networkExecutor.GET(targetUrl, useTokenIfAvailable: false) if Task.isCancelled { return } - self.cacheAccountsCatalogData(data, hash: hash) + self.registryCache.writeCatalogData(data, hash: hash) self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) self.callAndClearLoadingHandlers(for: hash, success) @@ -1469,7 +1399,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { } catch { if Task.isCancelled { return } Log.error(#file, "Failed to load catalogs from network: \(error.localizedDescription)") - if let data = self.readCachedAccountsCatalogData(hash: hash) { + if let data = self.registryCache.readCatalogData(hash: hash) { Log.info(#file, "Using cached catalog data as fallback after network failure") self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { success in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) @@ -1499,7 +1429,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { // Parse existing cached publications for merge let existingPubs: [OPDS2Publication] let existingMetadata: OPDS2CatalogsFeed.Metadata? - if let cachedData = self.readCachedAccountsCatalogData(hash: hash), + if let cachedData = self.registryCache.readCatalogData(hash: hash), let feed = try? OPDS2CatalogsFeed.fromData(cachedData) { existingPubs = feed.catalogs existingMetadata = feed.metadata @@ -1519,7 +1449,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { case .success(let data): let pubCount = (try? OPDS2CatalogsFeed.fromData(data))?.catalogs.count ?? -1 Log.info(#file, "Background crawl successful for hash \(hash), \(pubCount) libraries in result, dataSize=\(data.count)") - self.cacheAccountsCatalogData(data, hash: hash) + self.registryCache.writeCatalogData(data, hash: hash) self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { [weak self] _ in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) // Preload catalogs for active accounts @@ -1549,7 +1479,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable { let (data, _) = try await self.networkExecutor.GET(targetUrl, useTokenIfAvailable: false) if Task.isCancelled { return } Log.info(#file, "Fallback direct refresh successful for hash \(hash)") - self.cacheAccountsCatalogData(data, hash: hash) + self.registryCache.writeCatalogData(data, hash: hash) self.loadAccountSetsAndAuthDoc(fromCatalogData: data, key: hash) { _ in NotificationCenter.default.post(name: .TPPCatalogDidLoad, object: nil) } @@ -1571,112 +1501,11 @@ private struct CrawlerHandoffBox: @unchecked Sendable { } } - // MARK: – Disk cache helpers - - private func accountsCatalogUrl(hash: String) -> URL? { - guard let appSupport = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true) - else { return nil } - return appSupport.appendingPathComponent("accounts_catalog_\(hash).json") - } - - private func cacheMetadataUrl(hash: String) -> URL? { - guard let appSupport = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true) - else { return nil } - return appSupport.appendingPathComponent("accounts_catalog_metadata_\(hash).json") - } - - /// On-disk location of the CP-D1 slim launch snapshot (current + settings - /// accounts). The `accounts_catalog_slim_` name shares the - /// `accounts_catalog_` prefix, so `clearCache()` and the wiring-suite - /// disk-cache purge already sweep it with no extra bookkeeping. CP-D1. - private func slimSnapshotUrl(hash: String) -> URL? { - guard let appSupport = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true) - else { return nil } - return appSupport.appendingPathComponent("accounts_catalog_slim_\(hash).json") - } - - private func cacheAccountsCatalogData(_ data: Data, hash: String, isBundled: Bool = false) { - // Save catalog data - guard let url = accountsCatalogUrl(hash: hash) else { return } - try? data.write(to: url) - - // Save metadata with current timestamp. `isBundled` distinguishes - // build-time-snapshot writes from authoritative network writes so - // staleness logic can keep refresh alive on bundled-origin caches. - let metadata = CatalogCacheMetadata(timestamp: Date(), hash: hash, isBundled: isBundled) - if let metadataUrl = cacheMetadataUrl(hash: hash), - let metadataData = try? JSONEncoder().encode(metadata) { - try? metadataData.write(to: metadataUrl) - } - } - - private func readCachedAccountsCatalogData(hash: String) -> Data? { - guard let url = accountsCatalogUrl(hash: hash) else { return nil } - return try? Data(contentsOf: url) - } - - /// Reads cache metadata for the given hash - private func readCacheMetadata(hash: String) -> CatalogCacheMetadata? { - guard let url = cacheMetadataUrl(hash: hash), - let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(CatalogCacheMetadata.self, from: data) - } - - /// Returns true if cached data exists and is not expired (can be stale but usable). - /// - /// Existence is probed with `FileManager.fileExists` — NOT a full - /// `Data(contentsOf:)` — so this check never reads the ~2.4MB catalog blob - /// off disk. The single authoritative byte read happens exactly once per - /// launch at the caller (`preloadAccountsFromDiskCacheSync` and the - /// `loadCatalogs` stale-while-revalidate branch), which pair this gate with - /// one `readCachedAccountsCatalogData` and thread those bytes into the - /// decode. The prior implementation read the entire file here purely to - /// test existence, then the caller read the same file again — two full - /// reads of the same 2.4MB blob per launch. - private func hasCachedCatalogData(hash: String) -> Bool { - guard let url = accountsCatalogUrl(hash: hash), - FileManager.default.fileExists(atPath: url.path) else { return false } - guard let metadata = readCacheMetadata(hash: hash) else { - // Data exists but no metadata - treat as usable but stale - return false - } - return !metadata.isExpired - } - - /// Returns true if cache exists and is stale (needs background refresh) - private func isCacheStale(hash: String) -> Bool { - let metadata = readCacheMetadata(hash: hash) - let serverMaxAge = readCrawlState(hash: hash)?.serverMaxAge - return Self.isCacheStale(metadata: metadata, serverMaxAge: serverMaxAge) - } - - /// Pure variant of `isCacheStale(hash:)` that doesn't touch the file - /// system. Returns `true` when metadata is missing OR when the metadata - /// reports staleness against `serverMaxAge`. Extracted from the private - /// version so tests can exercise the nil-metadata → refresh path that - /// previously had no test coverage (F-013). - static func isCacheStale( - metadata: CatalogCacheMetadata?, - serverMaxAge: TimeInterval? - ) -> Bool { - guard let metadata else { - // No metadata means we should refresh - return true - } - return metadata.isStale(serverMaxAge: serverMaxAge) - } + // MARK: – Account-switch pure helpers + // + // The disk-cache helpers that lived here moved to `AccountRegistryCache.swift` + // (Wave 3 / 3a-1). These two remain: they are pure switch-pipeline predicates, + // not cache I/O, and travel with the current-account extraction later. /// Pure helper for `cleanupActiveContentBeforeAccountSwitch`'s /// `pathCount > 0` guard — extracted so the bound check is testable. @@ -1696,19 +1525,6 @@ private struct CrawlerHandoffBox: @unchecked Sendable { return previousAccountId == newAccountId || previousAccountId == nil } - /// Reads crawl state for the given hash (used for dynamic TTL adjustment) - private func readCrawlState(hash: String) -> CrawlState? { - guard let appSupport = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) else { return nil } - let url = appSupport.appendingPathComponent("crawl_state_\(hash).json") - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(CrawlState.self, from: data) - } - // MARK: – Auth Document fetch with state-machine wiring /// Wraps `Account.loadAuthenticationDocument` with: @@ -2092,35 +1908,9 @@ private struct CrawlerHandoffBox: @unchecked Sendable { func clearCache() { // network cache networkExecutor.clearCache() - // file caches — delete all files matching known prefixes written to the - // Application Support root. (`authentication_document_` was removed: no - // code path writes a file with that prefix — auth docs live in `Account` - // state / are re-fetched, not persisted as files — so it cleared nothing.) - let prefixes = [ - "library_list_", - "accounts_catalog_", - "accounts_catalog_metadata_", - "crawl_state_", - ] - let fm = FileManager.default - guard let appSupport = try? fm.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) else { return } - - guard let files = try? fm.contentsOfDirectory( - at: appSupport, - includingPropertiesForKeys: nil - ) else { return } - - for file in files { - let name = file.lastPathComponent - if prefixes.contains(where: { name.hasPrefix($0) }) { - try? fm.removeItem(at: file) - } - } + // file caches — the on-disk catalog/metadata/list/crawl sweep now lives on + // the injected registry cache (Wave 3 / 3a-1). + registryCache.clearFileCaches() } } diff --git a/PalaceTests/AccountsManagerHelpersTests.swift b/PalaceTests/AccountsManagerHelpersTests.swift index 15d8db1c5..ce68d07e6 100644 --- a/PalaceTests/AccountsManagerHelpersTests.swift +++ b/PalaceTests/AccountsManagerHelpersTests.swift @@ -84,12 +84,12 @@ final class AccountsManagerHelpersTests: XCTestCase { func test_isCacheStale_nilMetadata_returnsTrue() { // Kills the `return true` → `return false` mutant on the // nil-metadata path. - XCTAssertTrue(AccountsManager.isCacheStale(metadata: nil, serverMaxAge: nil)) + XCTAssertTrue(CatalogCacheMetadata.isCacheStale(metadata: nil, serverMaxAge: nil)) } func test_isCacheStale_freshMetadata_returnsFalse() { let fresh = CatalogCacheMetadata(timestamp: Date(), hash: "h") - XCTAssertFalse(AccountsManager.isCacheStale(metadata: fresh, serverMaxAge: nil)) + XCTAssertFalse(CatalogCacheMetadata.isCacheStale(metadata: fresh, serverMaxAge: nil)) } func test_isCacheStale_staleMetadata_returnsTrue() { @@ -98,7 +98,7 @@ final class AccountsManagerHelpersTests: XCTestCase { timestamp: Date().addingTimeInterval(-7 * 3600), hash: "h" ) - XCTAssertTrue(AccountsManager.isCacheStale(metadata: stale, serverMaxAge: nil)) + XCTAssertTrue(CatalogCacheMetadata.isCacheStale(metadata: stale, serverMaxAge: nil)) } func test_isCacheStale_serverMaxAgeRespected() { @@ -108,13 +108,13 @@ final class AccountsManagerHelpersTests: XCTestCase { timestamp: Date().addingTimeInterval(-4 * 60), hash: "h" ) - XCTAssertFalse(AccountsManager.isCacheStale(metadata: recent, serverMaxAge: 600)) + XCTAssertFalse(CatalogCacheMetadata.isCacheStale(metadata: recent, serverMaxAge: 600)) // Same metadata 10 minutes old → stale (> 5min TTL). let older = CatalogCacheMetadata( timestamp: Date().addingTimeInterval(-10 * 60), hash: "h" ) - XCTAssertTrue(AccountsManager.isCacheStale(metadata: older, serverMaxAge: 600)) + XCTAssertTrue(CatalogCacheMetadata.isCacheStale(metadata: older, serverMaxAge: 600)) } } diff --git a/PalaceTests/Decomp/AccountRegistryCacheSeamTests.swift b/PalaceTests/Decomp/AccountRegistryCacheSeamTests.swift new file mode 100644 index 000000000..afc782ae2 --- /dev/null +++ b/PalaceTests/Decomp/AccountRegistryCacheSeamTests.swift @@ -0,0 +1,115 @@ +// +// AccountRegistryCacheSeamTests.swift +// PalaceTests +// +// Pins the Wave 3 / 3a-1 `AccountRegistryCache` seam: `AccountsManager` reaches +// the on-disk catalog cache ONLY through the injected `any AccountRegistryCaching` +// collaborator, never inline FileManager bodies. This is the extraction that lets +// the hub carry no disk-I/O and move into `PalaceAccounts` cleanly. +// +// Two lenses: +// 1. Routing (spy) — `clearCache()` clears the file caches through the injected +// seam (a mutant that drops the call, or clears inline, flips it red). +// 2. Behaviour (real impl) — `DiskAccountRegistryCache` round-trips a write→read +// and honours the `isBundled` always-stale rule; covers the moved disk bodies +// end-to-end (the concrete path the spy tests intentionally bypass). +// +// Copyright © 2026 The Palace Project. All rights reserved. +// + +import XCTest +import PalaceCatalog +import PalaceBookModel +@testable import Palace + +@MainActor +final class AccountRegistryCacheSeamTests: PalaceWiringTestCase { + + // MARK: - Routing through the injected seam + + /// Contract: `AccountsManager.clearCache()` clears the on-disk caches through + /// the injected `AccountRegistryCaching`, not an inline FileManager sweep. + /// + /// Kill case: dropping `registryCache.clearFileCaches()` (or reverting to the + /// inline loop) → the spy never records `clearFileCaches` → fails. + func testClearCache_routesThroughInjectedRegistryCache() { + let spy = RecordingRegistryCache() + let manager = makeFreshAccountsManager( + defaults: Self.testUserDefaults(), + registryCache: spy + ) + + manager.clearCache() + + XCTAssertTrue( + spy.clearFileCachesCalled, + "clearCache() must clear the disk caches through the injected AccountRegistryCaching seam" + ) + } + + // MARK: - Real disk implementation (moved bodies) + + /// Contract: `DiskAccountRegistryCache` persists and reads back catalog bytes + /// for a hash, reports freshness after a write, and treats a bundled-origin + /// write as always-stale (the refresh-keeps-firing rule). + /// + /// Kill cases: breaking the write/read URL derivation → read returns nil; + /// dropping the `isBundled` short-circuit in `isStale` → bundled write reports + /// not-stale; negating `isExpired` → fresh write reports not-fresh. + func testDiskCache_roundTripsAndHonorsBundledStaleness() { + let cache = DiskAccountRegistryCache() + let hash = "acctcache-\(UUID().uuidString)" + defer { cache.clearFileCaches() } + + let payload = Data("catalog-bytes-\(hash)".utf8) + + // Network-origin write → readable, fresh, and NOT bundled-stale. + cache.writeCatalogData(payload, hash: hash, isBundled: false) + XCTAssertEqual(cache.readCatalogData(hash: hash), payload, + "a written catalog blob must read back byte-identical") + XCTAssertTrue(cache.hasFreshCatalogData(hash: hash), + "a just-written catalog must be fresh (not expired)") + XCTAssertFalse(cache.isCatalogStale(hash: hash), + "a just-written network-origin catalog must not be stale") + + // Bundled-origin overwrite → still fresh, but ALWAYS stale for refresh. + cache.writeCatalogData(payload, hash: hash, isBundled: true) + XCTAssertTrue(cache.hasFreshCatalogData(hash: hash), + "a bundled write is still usable (not expired)") + XCTAssertTrue(cache.isCatalogStale(hash: hash), + "a bundled-origin catalog must report stale so refresh keeps firing") + } + + /// Contract: the convenience `writeCatalogData(_:hash:)` (protocol-extension + /// default) writes a non-bundled entry — the ergonomics the hub's network + /// write sites rely on through the `any AccountRegistryCaching` existential. + func testDiskCache_convenienceWrite_isNotBundled() { + let cache = DiskAccountRegistryCache() + let hash = "acctcache-conv-\(UUID().uuidString)" + defer { cache.clearFileCaches() } + + cache.writeCatalogData(Data("x".utf8), hash: hash) + + XCTAssertFalse(cache.isCatalogStale(hash: hash), + "the isBundled-defaulting convenience must write a non-bundled (fresh, not-stale) entry") + } +} + +// MARK: - Test double + +/// Records the routing calls the hub makes; returns inert reads. `@unchecked +/// Sendable`: only holds trivially-guarded flags/counters, mutated on the main +/// actor by these serial tests. +fileprivate final class RecordingRegistryCache: AccountRegistryCaching, @unchecked Sendable { + private(set) var clearFileCachesCalled = false + private(set) var writes: [(hash: String, isBundled: Bool)] = [] + + func writeCatalogData(_ data: Data, hash: String, isBundled: Bool) { + writes.append((hash, isBundled)) + } + func readCatalogData(hash: String) -> Data? { nil } + func hasFreshCatalogData(hash: String) -> Bool { false } + func isCatalogStale(hash: String) -> Bool { true } + func slimSnapshotURL(hash: String) -> URL? { nil } + func clearFileCaches() { clearFileCachesCalled = true } +} diff --git a/PalaceTests/Support/PalaceWiringTestCase.swift b/PalaceTests/Support/PalaceWiringTestCase.swift index bfc9e96c4..adbcb45aa 100644 --- a/PalaceTests/Support/PalaceWiringTestCase.swift +++ b/PalaceTests/Support/PalaceWiringTestCase.swift @@ -288,6 +288,26 @@ class PalaceWiringTestCase: PalaceTestCase { return manager } + /// DI-aware overload that injects the disk-cache collaborator + /// (`AccountRegistryCaching`, Wave 3 / 3a-1). Lets a test install a recording + /// cache to pin the catalog read/write/clear routing while keeping the same + /// opt-out flag pin + tearDown drain as the other helpers (so it stays off the + /// `AccountsManagerIsolationLint` bare-construction ban). + @discardableResult + nonisolated func makeFreshAccountsManager( + defaults: UserDefaults, + registryCache: any AccountRegistryCaching, + _ configure: (AccountsManager) -> Void = { _ in } + ) -> AccountsManager { + #if DEBUG + AccountsManager.deferInitialLoadCatalogsForTesting = true + #endif + let manager = AccountsManager(defaults: defaults, registryCache: registryCache) + configure(manager) + managersToCancelOnTearDown.append(manager) + return manager + } + // MARK: - Disk-cache cleanup /// Remove every on-disk catalog/auth/crawl cache file in the test diff --git a/scripts/godclass-loc-baseline.txt b/scripts/godclass-loc-baseline.txt index 75706a758..ff5d5a170 100644 --- a/scripts/godclass-loc-baseline.txt +++ b/scripts/godclass-loc-baseline.txt @@ -60,6 +60,13 @@ # seams were inverted behind the injected `AccountSwitchDependencies` bundle; the # net is slightly NEGATIVE (the extracted nav-pop closure + deleted inline reaches # outweigh the stored dependency + its docs). Re-baselined by the exact delta. +# Wave 3 / 3a-1 (-210 on AccountsManager, 2383 -> 2173): the on-disk catalog cache +# concern (the `CatalogCacheMetadata` value type + the FileManager read/write/ +# staleness/clear bodies) was EXTRACTED to the injected `AccountRegistryCaching` +# collaborator in the new, unfrozen `AccountRegistryCache.swift`. The hub keeps only +# the retyped `registryCache.*` call sites + a one-line pointer. This is the first of +# the 3a in-target collaborator splits; strongly NEGATIVE, exactly as god-class +# decomposition intends. Re-baselined DOWN to the new `wc -l`. # Wave 3 S2b (+4 on MyBooksDownloadCenter, 2180 -> 2184): the account-SCOPE reads # (current-account-id + auth-surface hosts) were INVERTED onto the injected # `DownloadAccountScopeProviding` seam. The residual growth is EXACTLY 4 irreducible @@ -73,7 +80,7 @@ # PalaceDownloads and the account-scope wiring goes with it. Re-baselined by the exact # delta; the freeze still catches any further unrelated growth. 3093 Palace/Audiobooks/AudiobookSessionManager.swift -2383 Palace/Accounts/Library/AccountsManager.swift +2173 Palace/Accounts/Library/AccountsManager.swift 2184 Palace/MyBooks/MyBooksDownloadCenter.swift 1378 Palace/Book/UI/BookDetail/BookDetailViewModel.swift 1261 Palace/SignInLogic/TPPSignInBusinessLogic.swift