diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 68c6607df..8b3880105 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -368,6 +368,7 @@ 31648629CEAC60ED194656BA /* TPPBasicAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E5C9EA3B9B15FEF2555CE3 /* TPPBasicAuthTests.swift */; }; 316543077F31595C2C9C1AFF /* AudiobookSessionManagerPresenterMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CBF74C265D4FA1483F1974 /* AudiobookSessionManagerPresenterMigrationTests.swift */; }; 317AEF4CF6E747286B2CA086 /* TriageBotKeyAdmin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CF4EB59391A34A6315985E /* TriageBotKeyAdmin.swift */; }; + 31E755A021BE24D982712F6B /* TPPNetworkExecutor+AccountNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A6F1763B71F0D796343FBBF /* TPPNetworkExecutor+AccountNetworking.swift */; }; 32093E834629466F8280138F /* TPPPDFLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BFD0F3410046E6AD2F25B0 /* TPPPDFLocationTests.swift */; }; 323FEA088C637AA3AAFC0F41 /* LCPAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5248CEFDF1FEDEBE50897394 /* LCPAdapterTests.swift */; }; 32910EA3300D7728B6FF2268 /* ScopedResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65988BBA901E65AA0905EF43 /* ScopedResetTests.swift */; }; @@ -447,6 +448,7 @@ 4ABC89EACE1CF4EB53281A51 /* StreamingReaderViewControllerScrollRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2A4750286AC64A2D15FD94 /* StreamingReaderViewControllerScrollRestoreTests.swift */; }; 4B69321BBCF4158F4AC546EA /* RightsManagementDispatchContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB69BEC26CB16962E3EE14C /* RightsManagementDispatchContractTests.swift */; }; 4BA66F6AA4A7DC046B995BC6 /* OfflineQueueServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FEEC427223FFDB026D6137 /* OfflineQueueServiceProtocol.swift */; }; + 4BD9BDFB996BF9E0576420CB /* AccountNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */; }; 4BEE7B81D9C945779A72310E /* DefaultRecentlyReadingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8B9BFBB791BB1A85E46685 /* DefaultRecentlyReadingServiceTests.swift */; }; 4C0017B99F494FE3A7A69887 /* TPPSignInBusinessLogic+ForceReset.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16A8F928810410482669BF6 /* TPPSignInBusinessLogic+ForceReset.swift */; }; 4C29B2B3058563294C60C0FE /* AudiobookBookmarkBusinessLogicPositionWriteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D1CD4CDD735A3008ACCD5F /* AudiobookBookmarkBusinessLogicPositionWriteTests.swift */; }; @@ -1011,8 +1013,10 @@ A701AF3AE365FEB190800CAA /* SignInFormPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30A128848513D0DC4CA0410 /* SignInFormPresentationTests.swift */; }; A725B03CF6619B2EE7F8B26C /* RatingFeedbackPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3640FC284C2E096253CAB7E4 /* RatingFeedbackPresenterTests.swift */; }; A7D3A61E77E6B8C0FD9AEA5D /* TypographySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C1C917A6BF4317D7FA6567 /* TypographySettings.swift */; }; + A7D4A0D17DACC00FB34F5289 /* TPPNetworkExecutor+AccountNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A6F1763B71F0D796343FBBF /* TPPNetworkExecutor+AccountNetworking.swift */; }; A7E1CE3367F3C787133E928B /* PalaceCatalog in Frameworks */ = {isa = PBXBuildFile; productRef = 4BDC371B99FCA4E42EEFA3CB /* PalaceCatalog */; }; A8131DE3255ED9F6651D1AF6 /* AudioSessionActivator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDE63F6732471A2B00D88DC /* AudioSessionActivator.swift */; }; + A81B474445D23D25DD33F00F /* AccountNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */; }; A823D811192BABA400B55DE2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A823D810192BABA400B55DE2 /* Foundation.framework */; }; A823D813192BABA400B55DE2 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A823D812192BABA400B55DE2 /* CoreGraphics.framework */; }; A823D815192BABA400B55DE2 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A823D814192BABA400B55DE2 /* UIKit.framework */; }; @@ -1342,6 +1346,7 @@ DD7931BFD5CA3EABC96BC215 /* OpenAccessAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DCE659985E97E91217CB739 /* OpenAccessAdapterTests.swift */; }; DDBA66D65E63F74D6D2668D5 /* OPDS2IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E38281988A9921BB4B8297 /* OPDS2IntegrationTests.swift */; }; DE0A30E3FA1B8A7EA46BCCBE /* ReaderAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72DFEC346C543E50A57BE49 /* ReaderAccessibilityTests.swift */; }; + DE253F8C8C4CC3BA89F45090 /* AccountNetworkingSeamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9963A35F959660D52B46E3EC /* AccountNetworkingSeamTests.swift */; }; DE4396B97927CA9E6978B33B /* AppAdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B75E0B61DBA872395691E65 /* AppAdvancedSettingsView.swift */; }; DE67C809E2D5FEC127EF7845 /* TPPBookRegistryDependencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2EF561051B25A261AE67BC /* TPPBookRegistryDependencyTests.swift */; }; DE8F7A1FFA894C20AC3C1101 /* TPPSettingsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8F946D19048E4AE73B6C7 /* TPPSettingsMock.swift */; }; @@ -2182,6 +2187,7 @@ 091A2B3C4D5E6F708192A3B4 /* TPPCredentialSnapshotCoherenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPCredentialSnapshotCoherenceTests.swift; sourceTree = ""; }; 09CAC3762A717783DDE755DD /* AudiobookSessionManagerShutdownTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookSessionManagerShutdownTests.swift; sourceTree = ""; }; 09CF38F572AE4A88961FCA46 /* AudiobookIssueFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookIssueFixTests.swift; sourceTree = ""; }; + 0A6F1763B71F0D796343FBBF /* TPPNetworkExecutor+AccountNetworking.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TPPNetworkExecutor+AccountNetworking.swift"; sourceTree = ""; }; 0A731B2809097BED1D69F274 /* DownloadAccountContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadAccountContext.swift; sourceTree = ""; }; 0A8D27C47B505B4D77B12221 /* RatingCardMotionGateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RatingCardMotionGateTests.swift; sourceTree = ""; }; 0ABB253AB7CB4183943EBA6A /* AccountStateStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountStateStore.swift; sourceTree = ""; }; @@ -2552,6 +2558,7 @@ 5A8468ADD34076BF5935E56B /* TPPBookAccessibilityLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPBookAccessibilityLabelTests.swift; sourceTree = ""; }; 5BC9F70101B8AD9FAFF88F0E /* AppLaunchTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchTracker.swift; sourceTree = ""; }; 5C133FD45251FA7A771EC43A /* SideloadedBookRegistryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SideloadedBookRegistryTests.swift; sourceTree = ""; }; + 5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountNetworking.swift; sourceTree = ""; }; 5CEA3369458249AC6239C383 /* AppContainerIsolationLintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppContainerIsolationLintTests.swift; sourceTree = ""; }; 5D1B142922CC179F0006C964 /* TPPAlertUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPAlertUtils.swift; sourceTree = ""; }; 5D3A28CB22D3DA850042B3BD /* UserProfileDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileDocument.swift; sourceTree = ""; }; @@ -2852,6 +2859,7 @@ 989EAF4764014D23B7BE358F /* TPPProblemDocumentCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPProblemDocumentCacheManagerTests.swift; sourceTree = ""; }; 9905AB408890B78E7B723F22 /* RatingConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RatingConfig.swift; sourceTree = ""; }; 994596C5FC8FAE5BC41AEB7D /* ReturnReducer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReturnReducer.swift; sourceTree = ""; }; + 9963A35F959660D52B46E3EC /* AccountNetworkingSeamTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountNetworkingSeamTests.swift; sourceTree = ""; }; 99895C63131766252D87F554 /* BackupExclusionMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupExclusionMigration.swift; sourceTree = ""; }; 99C7C68049D8AF012626234B /* BookServiceAudiobookOpenTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookServiceAudiobookOpenTests.swift; sourceTree = ""; }; 9A35DE944F78A26A081622BA /* TPPReaderPageListBusinessLogicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPReaderPageListBusinessLogicTests.swift; sourceTree = ""; }; @@ -4534,6 +4542,7 @@ 7360D0D424BFCB9700C8AD16 /* TPPUserFriendlyError.swift */, 2D62568A1D412BCB0080A81F /* BundledHTMLViewController.swift */, E6BA02B71DE4B6F600F76404 /* RemoteHTMLViewController.swift */, + 0A6F1763B71F0D796343FBBF /* TPPNetworkExecutor+AccountNetworking.swift */, ); path = Network; sourceTree = ""; @@ -4960,6 +4969,7 @@ B7171C65212AB50506BA53D3 /* AccountsManagerDownloadContextAdapter.swift */, 692DF9B78A5B64F59033D35C /* CatalogCrawlScheduler.swift */, 2C353AC0344431D385FC89E6 /* AccountSwitchDependencies.swift */, + 5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */, ); path = Library; sourceTree = ""; @@ -6049,6 +6059,7 @@ E5F23FD6246E4D153F32CB91 /* TPPCirculationAnalyticsRequestShapeContractTests.swift */, 333733730937345770AF9416 /* TPPSignInBusinessLogicCharacterizationTests.swift */, 41F50CFA2EBD6E2F19F568E0 /* TPPSignInCapabilitiesCharacterizationTests.swift */, + 9963A35F959660D52B46E3EC /* AccountNetworkingSeamTests.swift */, ); name = Decomp; path = Decomp; @@ -8048,6 +8059,7 @@ 49B679EE821164E16AF43BCF /* CatalogCrawlSchedulerTests.swift in Sources */, 4E3744149885D84DD020BB3B /* AccountsManagerCatalogLoadJoinTests.swift in Sources */, 2FEE2557210153D5C94C4D28 /* MyBooksDownloadCenterAccountScopeSeamTests.swift in Sources */, + DE253F8C8C4CC3BA89F45090 /* AccountNetworkingSeamTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -8614,6 +8626,8 @@ 146E571D95091CB8EDE2B624 /* CatalogCrawlScheduler.swift in Sources */, 82030623B66F1E97D4ABBE03 /* AccountSwitchDependencies.swift in Sources */, 674A6E70BD51E2989885ECAC /* MyBooksDownloadCenter+AccountScope.swift in Sources */, + 4BD9BDFB996BF9E0576420CB /* AccountNetworking.swift in Sources */, + A7D4A0D17DACC00FB34F5289 /* TPPNetworkExecutor+AccountNetworking.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9183,6 +9197,8 @@ BF6754FDD3EBF7514C3CD5B5 /* CatalogCrawlScheduler.swift in Sources */, CB5A9D76695146A98175489C /* AccountSwitchDependencies.swift in Sources */, AEAF14B65600C055E3C65996 /* MyBooksDownloadCenter+AccountScope.swift in Sources */, + A81B474445D23D25DD33F00F /* AccountNetworking.swift in Sources */, + 31E755A021BE24D982712F6B /* TPPNetworkExecutor+AccountNetworking.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Palace/Accounts/Library/AccountNetworking.swift b/Palace/Accounts/Library/AccountNetworking.swift new file mode 100644 index 000000000..1c4c6f81d --- /dev/null +++ b/Palace/Accounts/Library/AccountNetworking.swift @@ -0,0 +1,51 @@ +// +// AccountNetworking.swift +// Palace +// +// god-class decomposition — Wave 3 seam (3a precondition). +// +// The narrow slice of the shared network executor that `AccountsManager` actually +// uses on the account-switch cleanup + catalog/auth-doc fetch paths. Declaring it +// as a protocol beside `AccountsManager` removes the manager's last concrete +// reference to the app-target `TPPNetworkExecutor` type: S3 already inverted the +// ambient REACH (the executor is resolved through `AccountSwitchDependencies`'s +// injected provider closure rather than reaching the composition root directly), +// but the property and provider were still concretely typed. A SwiftPM `PalaceAccounts` +// target cannot name a `Palace/Network` app-target type, so this seam is the +// documented precondition for the 3a package move. +// +// Declared in the consuming (lower) module and implemented from above — the +// `BorrowReauthResetting` (S1) / `DownloadUserAccount` (S2) precedent. At the 3a +// move this protocol travels INTO `PalaceAccounts` with `AccountsManager`; the +// app-side `extension TPPNetworkExecutor: AccountNetworking` (in +// `TPPNetworkExecutor+AccountNetworking.swift`) stays app-target and gains an +// `import PalaceAccounts` + `@retroactive` then. +// +// `AnyObject, Sendable`: the concrete witness `TPPNetworkExecutor` is already +// `@objc class … NSObject, @unchecked Sendable`, so this adds ZERO new Sendable +// obligation, matches the existing `@Sendable () -> TPPNetworkExecutor` provider, +// and is REQUIRED — the async `GET` is awaited inside an owned-crawl `@Sendable` +// Task, so the existential must be `Sendable` to survive that capture. +// +// Copyright © 2026 The Palace Project. All rights reserved. +// + +import Foundation + +/// The account-facing surface of the shared network executor: the three calls +/// `AccountsManager` makes on the account-switch cleanup path (`cancelNonEssentialTasks`), +/// the cache-clear path (`clearCache`), and the catalog/auth-doc fetch path (`GET`). +/// `TPPNetworkExecutor` conforms app-side; tests inject a plain recording double. +protocol AccountNetworking: AnyObject, Sendable { + /// Cancel in-flight, non-essential requests before an account switch so a + /// request started under the prior library's credentials cannot land against + /// the new one. Synchronous on purpose (mirrors the executor's contract). + func cancelNonEssentialTasks() + + /// Clear the network response cache as part of `AccountsManager.clearCache()`. + func clearCache() + + /// Fetch catalog / auth-document bytes. `useTokenIfAvailable` matches the + /// executor's parameter; the account paths always pass it explicitly. + func GET(_ reqURL: URL, useTokenIfAvailable: Bool) async throws -> (Data, URLResponse?) +} diff --git a/Palace/Accounts/Library/AccountSwitchDependencies.swift b/Palace/Accounts/Library/AccountSwitchDependencies.swift index 0f30452fb..bb1a2dace 100644 --- a/Palace/Accounts/Library/AccountSwitchDependencies.swift +++ b/Palace/Accounts/Library/AccountSwitchDependencies.swift @@ -54,11 +54,13 @@ struct AccountSwitchDependencies: Sendable { /// (was the shared cover registry's `resetHostFailures()`). let resetCoverCircuitBreaker: @Sendable () -> Void - /// Lazily resolves the shared network executor. DEFERRED because `AccountsManager` + /// Lazily resolves the shared network executor, typed to the account-facing + /// `AccountNetworking` seam so a packaged `AccountsManager` names no concrete + /// `Palace/Network` type (3a precondition). DEFERRED because `AccountsManager` /// is constructed inline inside `AppContainer`'s dispatch_once — resolving the /// executor eagerly at init would re-enter that lock and trap. Resolved once, /// cached behind the manager's `lazy var networkExecutor`. - let networkExecutorProvider: @Sendable () -> TPPNetworkExecutor + let networkExecutorProvider: @Sendable () -> any AccountNetworking /// Main-actor navigation cleanup before an account switch: pop the active /// navigation stack to root (when non-empty) then wait the documented settle @@ -71,7 +73,7 @@ struct AccountSwitchDependencies: Sendable { imageCache: ImageCacheType, accountStateStore: AccountStateStore, resetCoverCircuitBreaker: @escaping @Sendable () -> Void, - networkExecutorProvider: @escaping @Sendable () -> TPPNetworkExecutor, + networkExecutorProvider: @escaping @Sendable () -> any AccountNetworking, popToRootForAccountSwitch: @escaping @MainActor @Sendable () async -> Void ) { self.imageCache = imageCache diff --git a/Palace/Accounts/Library/AccountsManager.swift b/Palace/Accounts/Library/AccountsManager.swift index 7c66fac4e..ddbf1183d 100644 --- a/Palace/Accounts/Library/AccountsManager.swift +++ b/Palace/Accounts/Library/AccountsManager.swift @@ -273,14 +273,14 @@ private struct CrawlerHandoffBox: @unchecked Sendable { /// Replaces the static `MyBooksDownloadCenter.clearAllBorrowReauthState()` /// call so the money-path clear is spy-testable. See `BorrowReauthResetting`. private let borrowReauthResetter: any BorrowReauthResetting - /// Wave 3 S3 — account-switch cleanup collaborators injected as a frozen bundle so the setter / - /// cleanup invert the ambient singleton REACHES (spy-observable); the concrete TPPNetworkExecutor TYPE edge survives, so 3a must still introduce a NetworkExecuting protocol to fully unblock the PalaceAccounts move. + /// Wave 3 S3 — account-switch cleanup collaborators injected as a frozen bundle (spy-observable); + /// the concrete executor TYPE edge is now inverted behind `AccountNetworking` too (3a precondition). private let switchDeps: AccountSwitchDependencies /// Lazy-resolved through the injected provider to break the singleton init cycle: /// AccountsManager is constructed inline by AppContainer._cached's initializer, so /// we cannot resolve the executor during init. First accessed *after* AppContainer /// finishes constructing; resolved exactly once, then cached here. - private lazy var networkExecutor: TPPNetworkExecutor = switchDeps.networkExecutorProvider() + private lazy var networkExecutor: any AccountNetworking = switchDeps.networkExecutorProvider() /// Injectable background-crawl spawn seam (see `CatalogCrawlScheduler`). /// Immutable `Sendable` `let`; `.production` by default, recording under test. private let crawlScheduler: CrawlTaskScheduler diff --git a/Palace/Network/TPPNetworkExecutor+AccountNetworking.swift b/Palace/Network/TPPNetworkExecutor+AccountNetworking.swift new file mode 100644 index 000000000..9f3a56e40 --- /dev/null +++ b/Palace/Network/TPPNetworkExecutor+AccountNetworking.swift @@ -0,0 +1,26 @@ +// +// TPPNetworkExecutor+AccountNetworking.swift +// Palace +// +// App-side conformance of the concrete network executor to the Accounts-owned +// `AccountNetworking` seam (god-class decomposition, Wave 3 / 3a precondition). +// +// Empty body: `TPPNetworkExecutor` already declares all three requirements with +// the exact signatures `AccountNetworking` needs — +// • `cancelNonEssentialTasks()` (TPPNetworkExecutor.swift, `@objc func`) +// • `clearCache()` (`@objc func`) +// • `GET(_:useTokenIfAvailable:) async throws -> (Data, URLResponse?)` +// so this is a pure adapter that binds the app type to the package-bound protocol. +// +// Lives next to the concrete type and STAYS app-target across the 3a move (when +// `AccountNetworking` travels into `PalaceAccounts`, this file gains +// `import PalaceAccounts` + `@retroactive`). Keeping the conformance here — not on +// the protocol side — is what lets `PalaceAccounts` avoid ever naming +// `TPPNetworkExecutor`. +// +// Copyright © 2026 The Palace Project. All rights reserved. +// + +import Foundation + +extension TPPNetworkExecutor: AccountNetworking {} diff --git a/PalaceTests/Decomp/AccountNetworkingSeamTests.swift b/PalaceTests/Decomp/AccountNetworkingSeamTests.swift new file mode 100644 index 000000000..479b3940f --- /dev/null +++ b/PalaceTests/Decomp/AccountNetworkingSeamTests.swift @@ -0,0 +1,128 @@ +// +// AccountNetworkingSeamTests.swift +// PalaceTests +// +// Pins the Wave 3 / 3a `AccountNetworking` seam: `AccountsManager` reaches the +// shared network executor ONLY through the injected `any AccountNetworking` +// provider, never a concrete `TPPNetworkExecutor`. This is the type-inversion that +// lets `AccountsManager` move into `PalaceAccounts` without naming a `Palace/Network` +// app-target type (see `AccountNetworking.swift`). +// +// The account-switch cancel path is already pinned by +// `AccountsManagerCurrentAccountSwitchContractTests` (whose spy is now a plain +// `AccountNetworking` conformer — itself proof the seam removes the concrete +// dependency). This file adds the `clearCache()` routing assertion so a regression +// that clears caches WITHOUT the injected executor flips red. +// +// Copyright © 2026 The Palace Project. All rights reserved. +// + +import XCTest +import PalaceCatalog +import PalaceBookModel +@testable import Palace + +@MainActor +final class AccountNetworkingSeamTests: PalaceWiringTestCase { + + /// Contract: `AccountsManager.clearCache()` clears the network cache through the + /// injected `AccountNetworking` seam — not by reaching a concrete executor. + /// + /// Kill case: a mutant that drops the `networkExecutor.clearCache()` call (or + /// resolves a real executor instead of the injected provider) → the spy never + /// records `clearCache` → this fails. + func testClearCache_routesThroughInjectedAccountNetworking() { + let log = CallLog() + let spy = RecordingAccountNetworking(log: log) + + let deps = AccountSwitchDependencies( + imageCache: MockImageCache(), + accountStateStore: AccountStateStore(), + resetCoverCircuitBreaker: {}, + networkExecutorProvider: { spy }, + popToRootForAccountSwitch: {} + ) + let manager = makeFreshAccountsManager( + defaults: Self.testUserDefaults(), + borrowReauthResetter: NoopBorrowReauthResetter(), + switchDependencies: deps + ) + + manager.clearCache() + + XCTAssertTrue( + log.snapshot().contains { $0.method == "clearCache" }, + "clearCache() must clear the network cache through the injected AccountNetworking seam" + ) + } + + /// Guards the seam's Sendability/type contract structurally: the provider accepts + /// a plain (non-`TPPNetworkExecutor`) conformer and `AccountsManager` drives its + /// cancel path through it. If the property ever reverts to the concrete type, a + /// plain conformer would no longer satisfy the provider and this file would fail + /// to compile. + /// + /// Kill case: dropping the `networkExecutor.cancelNonEssentialTasks()` call on the + /// switch-cleanup path → the spy never records `cancelNonEssentialTasks`. + func testAccountSwitchCancel_routesThroughInjectedAccountNetworking() { + let aUUID = "urn:uuid:acctnet-A-\(UUID().uuidString)" + let bUUID = "urn:uuid:acctnet-B-\(UUID().uuidString)" + let publication = OPDS2Publication( + links: [], + metadata: OPDS2Publication.Metadata( + updated: Date(), + description: "AccountNetworking seam", + id: bUUID, + title: "AccountNetworking B" + ), + images: nil + ) + let accountB = Account(publication: publication, imageCache: MockImageCache()) + + let log = CallLog() + let spy = RecordingAccountNetworking(log: log) + let deps = AccountSwitchDependencies( + imageCache: MockImageCache(), + accountStateStore: AccountStateStore(), + resetCoverCircuitBreaker: {}, + networkExecutorProvider: { spy }, + popToRootForAccountSwitch: {} + ) + + let defaults = Self.testUserDefaults() + defaults.set(aUUID, forKey: currentAccountIdentifierKey) + let manager = makeFreshAccountsManager( + defaults: defaults, + borrowReauthResetter: NoopBorrowReauthResetter(), + switchDependencies: deps + ) + + manager.currentAccount = accountB + + XCTAssertTrue( + log.snapshot().contains { $0.method == "cancelNonEssentialTasks" }, + "an account switch must cancel non-essential tasks through the injected AccountNetworking seam" + ) + } +} + +// MARK: - Test doubles + +/// Plain `AccountNetworking` recorder — no `TPPNetworkExecutor` inheritance, which is +/// exactly the property the seam guarantees. `@unchecked Sendable`: holds only the +/// thread-safe `CallLog`. +fileprivate final class RecordingAccountNetworking: AccountNetworking, @unchecked Sendable { + let log: CallLog + init(log: CallLog) { self.log = log } + func cancelNonEssentialTasks() { log.record("cancelNonEssentialTasks") } + func clearCache() { log.record("clearCache") } + func GET(_ reqURL: URL, useTokenIfAvailable: Bool) async throws -> (Data, URLResponse?) { + log.record("GET", args: ["url": reqURL.absoluteString]) + return (Data(), nil) + } +} + +/// Inert `BorrowReauthResetting` for tests that don't assert the borrow-reauth clear. +fileprivate final class NoopBorrowReauthResetter: BorrowReauthResetting, @unchecked Sendable { + func clearAllBorrowReauthState() {} +} diff --git a/PalaceTests/Decomp/AccountsManagerCurrentAccountSwitchContractTests.swift b/PalaceTests/Decomp/AccountsManagerCurrentAccountSwitchContractTests.swift index 0150e25fd..c289791fc 100644 --- a/PalaceTests/Decomp/AccountsManagerCurrentAccountSwitchContractTests.swift +++ b/PalaceTests/Decomp/AccountsManagerCurrentAccountSwitchContractTests.swift @@ -373,17 +373,19 @@ final class AccountsManagerCurrentAccountSwitchContractTests: PalaceWiringTestCa // MARK: - Spies for the Wave 3 S3 switch-cleanup contract -/// Records `cancelNonEssentialTasks()` into a shared `CallLog`. Subclasses the real -/// `TPPNetworkExecutor` (the only way to observe the concrete cancel seam); every -/// other executor behavior is inherited but never exercised on the switch path. -fileprivate final class SpyAccountSwitchNetworkExecutor: TPPNetworkExecutor, @unchecked Sendable { +/// Records `cancelNonEssentialTasks()` into a shared `CallLog`. A plain +/// `AccountNetworking` conformer — the Wave 3 / 3a seam means observing the cancel +/// call no longer requires subclassing the concrete `TPPNetworkExecutor` (which +/// pulled a real caching stack into the test). The other two seam methods are +/// implemented inertly; the switch path only exercises `cancelNonEssentialTasks`. +fileprivate final class SpyAccountSwitchNetworkExecutor: AccountNetworking, @unchecked Sendable { let log: CallLog - init(log: CallLog) { - self.log = log - super.init(cachingStrategy: .ephemeral) - } - override func cancelNonEssentialTasks() { - log.record("cancelNonEssentialTasks") + init(log: CallLog) { self.log = log } + func cancelNonEssentialTasks() { log.record("cancelNonEssentialTasks") } + func clearCache() { log.record("clearCache") } + func GET(_ reqURL: URL, useTokenIfAvailable: Bool) async throws -> (Data, URLResponse?) { + log.record("GET", args: ["url": reqURL.absoluteString]) + return (Data(), nil) } } diff --git a/docs/architecture/areas/accounts/verification-checklist.md b/docs/architecture/areas/accounts/verification-checklist.md index 32462b0d4..e8b3fd644 100644 --- a/docs/architecture/areas/accounts/verification-checklist.md +++ b/docs/architecture/areas/accounts/verification-checklist.md @@ -66,7 +66,7 @@ description: Per-area verification reference; refresh before next swarm/rigorous | Module | Owner | Public surface (what changes here is a contract break) | |--------|-------|---------------------------------------------------------| -| `Palace/Accounts/Library/` | Main target | `Account`, `AccountDetails`, `AccountsManager` (`TPPLibraryAccountsProvider` + `TPPUserAccountResolving` conformance), `Account.LoadState`, `AccountLoadError`, `AccountStateStore.shared`, `awaitReady()`, `LibraryRegistryCrawler`, `CatalogPreloader`, `LibraryCatalogMerger`, `BundledRegistrySnapshot`, `CrawlState` / `CrawlableFeedAnalysis` | +| `Palace/Accounts/Library/` | Main target | `Account`, `AccountDetails`, `AccountsManager` (`TPPLibraryAccountsProvider` + `TPPUserAccountResolving` conformance), `Account.LoadState`, `AccountLoadError`, `AccountStateStore.shared`, `awaitReady()`, `LibraryRegistryCrawler`, `CatalogPreloader`, `LibraryCatalogMerger`, `BundledRegistrySnapshot`, `CrawlState` / `CrawlableFeedAnalysis`, `AccountSwitchDependencies` (S3), `AccountNetworking` (3a executor seam) | | `Palace/Accounts/User/` | Main target | `TPPUserAccount` (per-library instance + `sharedAccount()` legacy delegates), `TPPUserAccountProvider`, `TPPCredentials`, `TPPAccountAuthState`, `UserAccountAuthHelper`, `UserAccountPublisher`, `CredentialSnapshot`, `UserProfileDocument` | | `Palace/Accounts/AgeCheck/` | Main target | `TPPAgeCheck` (`TPPAgeCheckVerifying` conformance), `TPPAgeCheckViewController` | | `AppContainer` cross-cutting | `Palace/AppInfrastructure/AppContainer.swift` | Owns the single live `AccountsManager` instance. New consumers MUST read `appContainer.accountsManager` — do NOT call `AccountsManager()` directly outside tests. | @@ -203,6 +203,7 @@ Before any new swarm or /rigorous-fix in this area, the architect should: | Date | Refreshed by | Notes | |------|-------------|-------| +| 2026-07-30 | feat/pp-decomp-3a-account-networking | Wave 3 / 3a seam: `AccountsManager.networkExecutor` retyped to `any AccountNetworking` (new `AccountNetworking.swift`; app-side `TPPNetworkExecutor+AccountNetworking.swift` conformance) — the hub no longer names `Palace/Network` app-target types, unblocking the `PalaceAccounts` package move. Behavior-identical. Sections 1–7 content NOT re-audited this pass (seam-scoped touch); the `.detailsEvicted` enum rename vs the doc's `.accountNotFound` prose remains a pending content refresh. | | 2026-05-28 | chore/swarm-rigor-meta-improvement (initial baseline) | Derived from Phase 1 state-machine PoC (PR #961 / `222137d3a`), Phase 2 migration (PR #967 / `2f5b4339f`), warm-path driver gap (PR #975 / `1fdd59c73`), currentAccount setter driver (PR #985 / `dce81974e`), library swap-back redrive (PR #996 / `14100c62a`), wiring-suite isolation flag (PR #995 / `49c581b24`). Memories consulted: `enum_conflation_account_not_found`, `phase1_account_state_machine_2026_05_19`, `reference_tpp_user_account_migration_retro`, `feedback_wiring_suite_test_isolation`, `singleton_audit_2026_04_24`, `saml_two_surface_auth_model`, `debug_protocol_auth_regressions`, `investigation_icarus_oidc_auth_loss_2026_05_14`. | ---