// // AccountServiceTests.swift // YatteeTests // // What an account is allowed to do, whose credential it did it with, and what // the cache does with an answer that arrived too late to matter. // import Testing import Foundation @testable import Yattee // MARK: - Scripted transport /// A ``RemotePlaylistTransport`` that answers from a script instead of a server. /// /// Every call is recorded, so a test can assert what the service decided to /// send — the privacy it dropped, the token it removed by — and any call can be /// parked mid-flight, which is where the credential-change and generation rules /// actually live. /// Shared with `WatchHistorySyncTests`, whose service needs a playlist half it /// never reaches. actor ScriptedPlaylistTransport: RemotePlaylistTransport { enum Call: Equatable, Sendable { case userPlaylists(credential: String) case snapshot(id: String, credential: String) case create(title: String, privacy: PlaylistPrivacy?, credential: String) case rename(id: String, title: String, privacy: PlaylistPrivacy?, credential: String) case setDescription(id: String, description: String, title: String, credential: String) case delete(id: String, credential: String) case addVideo(playlistID: String, videoID: String, credential: String) case remove(entryID: RemotePlaylistEntryID, credential: String) } private(set) var calls: [Call] = [] /// Thrown by the next call of any kind, once set. var nextError: Error? /// The 1-based index of the first call that throws ``nextError``. Everything /// before it answers normally, which is how a write that lands and a refetch /// that then fails are scripted in one run. private var firstFailingCall = 1 /// What ``userPlaylistSnapshot(id:instance:credential:)`` answers. var snapshot = RemotePlaylistSnapshot(playlist: Playlist(id: .global("pl"), title: "Playlist"), entries: []) /// What ``userPlaylists(instance:credential:)`` answers. var playlists: [Playlist] = [] /// While true, every call suspends until `release()`. private var holdsCallsOpen = false private var parked: [CheckedContinuation] = [] func setError(_ error: Error?) { nextError = error } /// Fails every call from `index` on, leaving the earlier ones alone. func setError(_ error: Error, fromCall index: Int) { nextError = error firstFailingCall = index } func setSnapshot(_ snapshot: RemotePlaylistSnapshot) { self.snapshot = snapshot } func setPlaylists(_ playlists: [Playlist]) { self.playlists = playlists } func holdCalls() { holdsCallsOpen = true } /// Stops parking *new* calls without releasing the ones already parked, so /// a request can be left in flight while other work runs to completion. func stopHoldingNewCalls() { holdsCallsOpen = false } func release() { holdsCallsOpen = false let waiting = parked parked = [] waiting.forEach { $0.resume() } } /// Waits until at least `count` calls have been entered. func waitForCalls(_ count: Int) async throws { for _ in 0 ..< 400 { if calls.count >= count { return } try await Task.sleep(for: .milliseconds(5)) } Issue.record("The transport was never entered \(count) time(s)") } var callCount: Int { calls.count } private func record(_ call: Call) async throws { calls.append(call) if holdsCallsOpen { await withCheckedContinuation { parked.append($0) } } if let error = nextError, calls.count >= firstFailingCall { throw error } } func userPlaylists(instance _: Instance, credential: String) async throws -> [Playlist] { try await record(.userPlaylists(credential: credential)) return playlists } func userPlaylistSnapshot( id: String, instance _: Instance, credential: String ) async throws -> RemotePlaylistSnapshot { try await record(.snapshot(id: id, credential: credential)) return snapshot } func createPlaylist( title: String, privacy: PlaylistPrivacy?, instance _: Instance, credential: String ) async throws -> Playlist { try await record(.create(title: title, privacy: privacy, credential: credential)) return Playlist(id: .global("new-playlist"), title: title) } func renamePlaylist( id: String, title: String, privacy: PlaylistPrivacy?, instance _: Instance, credential: String ) async throws { try await record(.rename(id: id, title: title, privacy: privacy, credential: credential)) } func setPlaylistDescription( id: String, description: String, title: String, instance _: Instance, credential: String ) async throws { try await record( .setDescription(id: id, description: description, title: title, credential: credential) ) } func deletePlaylist(id: String, instance _: Instance, credential: String) async throws { try await record(.delete(id: id, credential: credential)) } func addVideoToPlaylist( playlistID: String, videoID: String, instance _: Instance, credential: String ) async throws { try await record(.addVideo(playlistID: playlistID, videoID: videoID, credential: credential)) } func removeEntry(_ entryID: RemotePlaylistEntryID, instance _: Instance, credential: String) async throws { try await record(.remove(entryID: entryID, credential: credential)) } } // MARK: - Scripted history transport /// A ``WatchHistoryTransport`` that answers from a script instead of a server. /// /// Same shape as ``ScriptedPlaylistTransport`` — every call recorded, any call /// parkable — because the same questions are asked of it: which credential went /// out, and what happened while one was still in flight. Shared with /// `WatchHistorySyncTests`. actor ScriptedHistoryTransport: WatchHistoryTransport { enum Call: Equatable, Sendable { case history(page: Int, maxResults: Int, credential: String) case markWatched(videoID: String, credential: String) case removeWatched(videoID: String, credential: String) } private(set) var calls: [Call] = [] /// Thrown by every call, once set. var nextError: Error? /// What ``watchHistory(instance:credential:page:maxResults:)`` answers. var history: [String] = [] /// While true, every call suspends until `release()`. private var holdsCallsOpen = false private var parked: [CheckedContinuation] = [] func setError(_ error: Error?) { nextError = error } func setHistory(_ history: [String]) { self.history = history } func holdCalls() { holdsCallsOpen = true } func release() { holdsCallsOpen = false let waiting = parked parked = [] waiting.forEach { $0.resume() } } /// Waits until at least `count` calls have been entered. func waitForCalls(_ count: Int) async throws { for _ in 0 ..< 400 { if calls.count >= count { return } try await Task.sleep(for: .milliseconds(5)) } Issue.record("The history transport was never entered \(count) time(s)") } /// Waits for the calls that should *not* happen to have had their chance. func settle() async throws { try await Task.sleep(for: .milliseconds(50)) } var callCount: Int { calls.count } var markedVideoIDs: [String] { calls.compactMap { if case .markWatched(let videoID, _) = $0 { return videoID } return nil } } private func record(_ call: Call) async throws { calls.append(call) if holdsCallsOpen { await withCheckedContinuation { parked.append($0) } } if let nextError { throw nextError } } func watchHistory( instance _: Instance, credential: String, page: Int, maxResults: Int ) async throws -> [String] { try await record(.history(page: page, maxResults: maxResults, credential: credential)) return history } func markWatched(videoID: String, instance _: Instance, credential: String) async throws { try await record(.markWatched(videoID: videoID, credential: credential)) } func removeWatched(videoID: String, instance _: Instance, credential: String) async throws { try await record(.removeWatched(videoID: videoID, credential: credential)) } } // MARK: - Harness /// An account on an instance, a scripted transport, and the service between /// them. The credential lives in a mutable box so a test can replace it while a /// call is still out. @MainActor private final class AccountServiceHarness { let instances: InstancesManager private(set) var instance: Instance let accounts: AccountsManager let account: Account let transport = ScriptedPlaylistTransport() let historyTransport = ScriptedHistoryTransport() private(set) var service: AccountService! /// What the credential reader reports right now. var credential: String? = "secret-original" /// Installed as ``credential`` the moment it is next read, so a test can /// place a sign-out exactly between resolving a credential and using it — /// the window the pre-transport recheck exists for, which is otherwise not /// reachable from outside on the paths that do no awaiting first. var credentialSwapOnNextRead: String? init(type: InstanceType = .invidious, isEnabled: Bool = true) { instances = makeTestInstancesManager() instance = Instance( type: type, url: URL(string: "https://accounts-\(UUID().uuidString).example.com")!, isEnabled: isEnabled ) instances.add(instance) accounts = AccountsManager( identityScope: CredentialIdentityScope(fixedScope: "identity-A"), instancesManager: instances, defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!, unreachableRetryInterval: 3600 ) account = accounts.addAccount(instanceID: instance.id) accounts.setStatus(.signedIn, for: account.id) service = AccountService( accountsManager: accounts, credentialReader: { [weak self] _ in self?.readCredential() }, transport: transport, historyTransport: historyTransport ) } private func readCredential() -> String? { let value = credential if let swap = credentialSwapOnNextRead { credential = swap credentialSwapOnNextRead = nil } return value } /// Instances live in the shared defaults, so each harness takes its own /// back out again. func tearDown() { instances.remove(instance) } var status: AccountStatus { accounts.status(for: account.id) } func makeCache() -> RemotePlaylistsCache { RemotePlaylistsCache(accountService: service) } } private func makeVideo(_ id: String) -> Video { Video( id: .global(id), title: "Video \(id)", description: nil, author: Author(id: "UCtest", name: "Test Channel"), duration: 60, publishedAt: nil, publishedText: nil, viewCount: nil, likeCount: nil, thumbnails: [], isLive: false, isUpcoming: false, scheduledStartTime: nil ) } private func makeSnapshot( playlistID: String, videoIDs: [String], kind: InstanceType ) -> RemotePlaylistSnapshot { let videos = videoIDs.map(makeVideo) let entries = videos.enumerated().map { offset, video in RemotePlaylistEntry( id: kind == .invidious ? .invidious(playlistID: playlistID, indexID: "TOKEN-\(video.id.videoID)") : .piped(playlistID: playlistID, position: offset), video: video, position: offset, displayRow: offset ) } return RemotePlaylistSnapshot( playlist: Playlist(id: .global(playlistID), title: "Playlist", videos: videos), entries: entries ) } // MARK: - Tests @Suite("Account service", .serialized) @MainActor struct AccountServiceTests { // MARK: - Capability gating @Test("A backend without playlist support is refused before anything is sent") func unsupportedBackendIsRefused() async throws { let harness = AccountServiceHarness(type: .peertube) defer { harness.tearDown() } await #expect(throws: AccountServiceError.notSupported) { _ = try await harness.service.userPlaylists(for: harness.account) } #expect(await harness.transport.callCount == 0) } @Test("A backend without playlist privacy never receives one") func privacyIsDroppedWhereUnsupported() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } _ = try await harness.service.createPlaylist( title: "Watch later", description: nil, privacy: .private, for: harness.account ) #expect(await harness.transport.calls == [ .create(title: "Watch later", privacy: nil, credential: "secret-original") ]) } @Test("A backend with playlist privacy receives the one that was asked for") func privacyIsPassedWhereSupported() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } _ = try await harness.service.createPlaylist( title: "Watch later", description: "Later", privacy: .unlisted, for: harness.account ) // Two requests, because no backend takes a description at creation. #expect(await harness.transport.calls == [ .create(title: "Watch later", privacy: .unlisted, credential: "secret-original"), .setDescription( id: "new-playlist", description: "Later", title: "Watch later", credential: "secret-original" ) ]) } // MARK: - Composite writes @Test("A credential replaced between a creation and its description stops the second call") func creationStopsDescribingWhenTheCredentialChanges() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } await harness.transport.holdCalls() let creation = Task { [service = harness.service!, account = harness.account] in try await service.createPlaylist( title: "Watch later", description: "Later", privacy: .private, for: account ) } // The creation is out. Somebody signs out and back in as another user // before it answers — the description must not go to the new login. try await harness.transport.waitForCalls(1) harness.credential = "secret-somebody-else" await harness.transport.release() await #expect(throws: AccountServiceError.notAuthenticated) { _ = try await creation.value } #expect(await harness.transport.calls == [ .create(title: "Watch later", privacy: .private, credential: "secret-original") ]) } @Test("An edit sends the description only after rechecking the credential") func editRechecksBetweenRenameAndDescription() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } await harness.transport.holdCalls() let edit = Task { [service = harness.service!, account = harness.account] in try await service.updatePlaylist( id: "pl", title: "Renamed", description: "New description", privacy: nil, for: account ) } try await harness.transport.waitForCalls(1) harness.credential = "secret-somebody-else" await harness.transport.release() await #expect(throws: AccountServiceError.notAuthenticated) { try await edit.value } // The rename landed under the old login; the description never went. #expect(await harness.transport.calls == [ .rename(id: "pl", title: "Renamed", privacy: nil, credential: "secret-original") ]) } @Test("An edit that keeps its credential renames and then describes") func editSendsBothHalvesWithTheSameCredential() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } try await harness.service.updatePlaylist( id: "pl", title: "Renamed", description: "New description", privacy: .public, for: harness.account ) #expect(await harness.transport.calls == [ .rename(id: "pl", title: "Renamed", privacy: .public, credential: "secret-original"), .setDescription( id: "pl", description: "New description", title: "Renamed", credential: "secret-original" ) ]) } @Test("An edit that names no visibility sends none, rather than inventing one") func editWithoutPrivacySendsNone() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } try await harness.service.updatePlaylist( id: "pl", title: "Renamed", description: nil, privacy: nil, for: harness.account ) #expect(await harness.transport.calls == [ .rename(id: "pl", title: "Renamed", privacy: nil, credential: "secret-original") ]) } // MARK: - Resolution @Test("An account with no readable credential is not sent anywhere") func missingCredentialIsRefused() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.credential = nil await #expect(throws: AccountServiceError.notAuthenticated) { _ = try await harness.service.userPlaylists(for: harness.account) } #expect(await harness.transport.callCount == 0) } @Test("An account on a disabled instance is not sent anywhere") func disabledInstanceIsRefused() async throws { let harness = AccountServiceHarness(isEnabled: false) defer { harness.tearDown() } await #expect(throws: AccountServiceError.instanceNotConfigured) { _ = try await harness.service.userPlaylists(for: harness.account) } #expect(await harness.transport.callCount == 0) } // MARK: - Outcome tracking @Test("A successful call reports the account as signed in") func successRecordsSignedIn() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.accounts.setStatus(.credentialMissing, for: harness.account.id) _ = try await harness.service.userPlaylists(for: harness.account) #expect(harness.status == .signedIn) } @Test("A server that cannot be reached is recorded as such") func failureRecordsTheError() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } await harness.transport.setError(APIError.timeout) await #expect(throws: APIError.timeout) { _ = try await harness.service.userPlaylists(for: harness.account) } #expect(harness.status == .unreachable) } @Test("An answer about a credential that has since been replaced records nothing") func staleCredentialRecordsNothing() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.accounts.setStatus(.credentialRejected, for: harness.account.id) await harness.transport.holdCalls() let call = Task { [service = harness.service!, account = harness.account] in try await service.userPlaylists(for: account) } try await harness.transport.waitForCalls(1) // The user signs in again while the request is still out. Its answer // describes a secret nobody holds any more. harness.credential = "secret-replacement" await harness.transport.release() _ = try await call.value #expect(harness.status == .credentialRejected) } // MARK: - Removal @Test("Invidious removes by the server's own token") func invidiousRemovesByIndexID() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .invidious) try await harness.service.removeEntry( snapshot.entries[1], fromPlaylist: "pl", for: harness.account ) #expect(await harness.transport.calls == [ .remove(entryID: .invidious(playlistID: "pl", indexID: "TOKEN-bbb"), credential: "secret-original") ]) } @Test("Piped removes by position once the position still holds the same video") func pipedRemovesAfterVerifying() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped) await harness.transport.setSnapshot(snapshot) try await harness.service.removeEntry( snapshot.entries[1], fromPlaylist: "pl", for: harness.account ) #expect(await harness.transport.calls == [ .snapshot(id: "pl", credential: "secret-original"), .remove(entryID: .piped(playlistID: "pl", position: 1), credential: "secret-original") ]) } @Test("Piped removes nothing when the position now holds a different video") func pipedRefusesToRemoveAMovedRow() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } let stale = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped) // The row above was removed elsewhere, so position 1 is now a different // video entirely — removing by that position would delete the wrong one. await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["bbb", "ccc"], kind: .piped)) await #expect(throws: AccountServiceError.entryMismatch) { try await harness.service.removeEntry(stale.entries[1], fromPlaylist: "pl", for: harness.account) } #expect(await harness.transport.calls == [.snapshot(id: "pl", credential: "secret-original")]) } // MARK: - Credential recheck before a write @Test("A credential replaced during the Piped verify step sends no removal") func pipedRemovalStopsWhenTheCredentialChangesMidVerify() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped) await harness.transport.setSnapshot(snapshot) await harness.transport.holdCalls() let removal = Task { [service = harness.service!, account = harness.account] in try await service.removeEntry(snapshot.entries[1], fromPlaylist: "pl", for: account) } // The verify fetch is out. Somebody signs out and back in as another // user before it answers. try await harness.transport.waitForCalls(1) harness.credential = "secret-somebody-else" await harness.transport.release() await #expect(throws: AccountServiceError.notAuthenticated) { try await removal.value } // The verify happened; the removal never did. #expect(await harness.transport.calls == [.snapshot(id: "pl", credential: "secret-original")]) } @Test("A mutation whose credential changed since it was resolved is not sent") func mutationStopsWhenTheCredentialChangesBeforeTransport() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } // Resolution reads "secret-original"; the recheck immediately before the // write reads the replacement. harness.credentialSwapOnNextRead = "secret-replacement" await #expect(throws: AccountServiceError.notAuthenticated) { _ = try await harness.service.createPlaylist( title: "Watch later", description: nil, privacy: nil, for: harness.account ) } #expect(await harness.transport.callCount == 0) } @Test("A read is not stopped by a credential change, only a write is") func readsAreNotRechecked() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.credentialSwapOnNextRead = "secret-replacement" _ = try await harness.service.userPlaylists(for: harness.account) #expect(await harness.transport.calls == [.userPlaylists(credential: "secret-original")]) } @Test("An entry minted by another playlist is refused") func entryFromAnotherPlaylistIsRefused() async throws { let harness = AccountServiceHarness(type: .invidious) defer { harness.tearDown() } let other = makeSnapshot(playlistID: "other", videoIDs: ["aaa"], kind: .invidious) await #expect(throws: AccountServiceError.entryMismatch) { try await harness.service.removeEntry(other.entries[0], fromPlaylist: "pl", for: harness.account) } #expect(await harness.transport.callCount == 0) } // MARK: - Watch history @Test("A backend without a history endpoint is refused before anything is sent") func historyIsRefusedWhereUnsupported() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } await #expect(throws: AccountServiceError.notSupported) { _ = try await harness.service.watchHistory(page: 1, maxResults: 60, for: harness.account) } await #expect(throws: AccountServiceError.notSupported) { try await harness.service.markWatched(videoID: "aaa", for: harness.account) } await #expect(throws: AccountServiceError.notSupported) { try await harness.service.removeFromWatchHistory(videoID: "aaa", for: harness.account) } #expect(await harness.historyTransport.callCount == 0) } @Test("History without a credential is refused before anything is sent") func historyIsRefusedWithoutACredential() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.credential = nil await #expect(throws: AccountServiceError.notAuthenticated) { _ = try await harness.service.watchHistory(page: 1, maxResults: 60, for: harness.account) } await #expect(throws: AccountServiceError.notAuthenticated) { try await harness.service.markWatched(videoID: "aaa", for: harness.account) } #expect(await harness.historyTransport.callCount == 0) } @Test("A history page is asked for with the paging the caller chose") func historyPageCarriesItsPaging() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } await harness.historyTransport.setHistory(["aaa", "bbb"]) let ids = try await harness.service.watchHistory(page: 2, maxResults: 60, for: harness.account) #expect(ids == ["aaa", "bbb"]) #expect(await harness.historyTransport.calls == [ .history(page: 2, maxResults: 60, credential: "secret-original") ]) } @Test("A credential replaced between resolving and marking stops the write") func markingStopsWhenTheCredentialChanges() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.credentialSwapOnNextRead = "secret-replacement" await #expect(throws: AccountServiceError.notAuthenticated) { try await harness.service.markWatched(videoID: "aaa", for: harness.account) } #expect(await harness.historyTransport.callCount == 0) } @Test("A removal from the server's history goes out on its own") func removalFromHistoryIsOneRequest() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } try await harness.service.removeFromWatchHistory(videoID: "aaa", for: harness.account) #expect(await harness.historyTransport.calls == [ .removeWatched(videoID: "aaa", credential: "secret-original") ]) } @Test("A history call reports what it learned about the login") func historyRecordsItsOutcome() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } harness.accounts.setStatus(.credentialMissing, for: harness.account.id) try await harness.service.markWatched(videoID: "aaa", for: harness.account) #expect(harness.status == .signedIn) await harness.historyTransport.setError(APIError.timeout) await #expect(throws: APIError.timeout) { try await harness.service.markWatched(videoID: "bbb", for: harness.account) } #expect(harness.status == .unreachable) } } // MARK: - Cache @Suite("Remote playlists cache", .serialized) @MainActor struct RemotePlaylistsCacheTests { @Test("A result that arrives after the account was cleared is dropped") func generationFenceDropsStaleResults() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) await harness.transport.holdCalls() let refresh = Task { [account = harness.account] in try await cache.refreshList(for: account) } try await harness.transport.waitForCalls(1) // Signed out while the fetch is still out: the request is not cancelled // by clearing, so its answer has to be refused on arrival. cache.clear(accountID: harness.account.id) await harness.transport.release() _ = try? await refresh.value #expect(cache.playlists(for: harness.account.id) == nil) } @Test("Creating a playlist republishes the list from the server") func createRefreshesTheList() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setPlaylists([Playlist(id: .global("new-playlist"), title: "Watch later")]) let created = try await cache.createPlaylist( title: "Watch later", description: nil, privacy: nil, for: harness.account ) #expect(created.title == "Watch later") #expect(cache.playlists(for: harness.account.id)?.map(\.title) == ["Watch later"]) } @Test("A failed mutation still refetches what it may have changed") func failedMutationStillReconciles() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious)) // The write reports failure, which is not the same as not having landed. await harness.transport.setError(APIError.httpError(statusCode: 500, message: nil)) await #expect(throws: APIError.self) { try await cache.addVideo(videoID: "bbb", toPlaylist: "pl", for: harness.account) } let calls = await harness.transport.calls #expect(calls.contains(.addVideo(playlistID: "pl", videoID: "bbb", credential: "secret-original"))) #expect(calls.contains(.snapshot(id: "pl", credential: "secret-original"))) } @Test("A delete that reported failure forgets a playlist the server no longer has") func failedDeleteForgetsAVanishedPlaylist() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious)) await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) try await cache.refreshSnapshot(id: "pl", for: harness.account) try await cache.refreshList(for: harness.account) #expect(cache.snapshot(id: "pl", for: harness.account.id) != nil) // The delete answers with an error, and every refetch after it 404s: // the playlist is gone, whatever the delete said. await harness.transport.setError(APIError.notFound(nil)) await #expect(throws: APIError.self) { try await cache.deletePlaylist(id: "pl", for: harness.account) } #expect(cache.snapshot(id: "pl", for: harness.account.id) == nil) #expect(cache.snapshotFetchDate(id: "pl", for: harness.account.id) == nil) #expect(cache.playlists(for: harness.account.id)?.contains { $0.id.playlistID == "pl" } != true) } @Test("A refresh in flight across a delete cannot bring the playlist back") func inFlightRefreshCannotResurrectADeletedPlaylist() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious)) await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) await harness.transport.holdCalls() let refresh = Task { [account = harness.account] in try await cache.refreshSnapshot(id: "pl", for: account) } try await harness.transport.waitForCalls(1) // That fetch is parked mid-flight. The delete runs to completion around // it, and the server stops listing the playlist. await harness.transport.stopHoldingNewCalls() await harness.transport.setPlaylists([]) try await cache.deletePlaylist(id: "pl", for: harness.account) // Only now does the older fetch answer. It describes a playlist that // existed when it was asked for, and arriving late does not bring it // back. await harness.transport.release() _ = try? await refresh.value #expect(cache.snapshot(id: "pl", for: harness.account.id) == nil) #expect(cache.playlists(for: harness.account.id)?.isEmpty == true) } @Test("A delete that landed drops the list entry even when the refetch fails") func successfulDeleteForgetsTheListEntryWhenTheRefetchFails() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious)) await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) try await cache.refreshSnapshot(id: "pl", for: harness.account) try await cache.refreshList(for: harness.account) // The delete itself lands; the reconciling list refetch right after it // cannot reach the server. await harness.transport.setError(APIError.noConnection, fromCall: 4) try await cache.deletePlaylist(id: "pl", for: harness.account) #expect(cache.snapshot(id: "pl", for: harness.account.id) == nil) #expect(cache.playlists(for: harness.account.id)?.isEmpty == true) } @Test("A delete that could not reach the server keeps the playlist it knows") func failedDeleteKeepsThePlaylistWhenOffline() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious)) await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) try await cache.refreshSnapshot(id: "pl", for: harness.account) try await cache.refreshList(for: harness.account) await harness.transport.setError(APIError.noConnection) await #expect(throws: APIError.self) { try await cache.deletePlaylist(id: "pl", for: harness.account) } // Not reaching the server says nothing about whether the playlist still // exists — the last fetch stays, as it does for every other read. #expect(cache.snapshot(id: "pl", for: harness.account.id) != nil) #expect(cache.playlists(for: harness.account.id)?.map(\.id.playlistID) == ["pl"]) } @Test("Concurrent refreshes of the same list cost one round trip") func concurrentRefreshesAreCoalesced() async throws { let harness = AccountServiceHarness() defer { harness.tearDown() } let cache = harness.makeCache() await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")]) await harness.transport.holdCalls() let first = Task { [account = harness.account] in try await cache.refreshList(for: account) } try await harness.transport.waitForCalls(1) let second = Task { [account = harness.account] in try await cache.refreshList(for: account) } // Long enough for a second request to have been made, had one been. try await Task.sleep(for: .milliseconds(50)) await harness.transport.release() try await first.value try await second.value #expect(await harness.transport.callCount == 1) #expect(cache.playlists(for: harness.account.id)?.count == 1) } @Test("A second removal waits for the first one's refetch before it preflights") func removalsOnOnePlaylistAreSerialized() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } let cache = harness.makeCache() let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped) await harness.transport.setSnapshot(snapshot) await harness.transport.holdCalls() let first = Task { [account = harness.account] in try await cache.removeEntry(snapshot.entries[0], fromPlaylist: "pl", for: account) } try await harness.transport.waitForCalls(1) // Tapped while the first removal's preflight is still out. Its own // preflight must not be sent yet: it would describe the playlist as it // was *before* the first removal, and a Piped position taken from that // names whatever shifts into the slot. let second = Task { [account = harness.account] in try await cache.removeEntry(snapshot.entries[1], fromPlaylist: "pl", for: account) } // Long enough for a second preflight to have been sent, had one been. try await Task.sleep(for: .milliseconds(50)) #expect(await harness.transport.callCount == 1) await harness.transport.release() try await first.value try await second.value // Three calls each, in order, and never interleaved: preflight, remove, // and the refetch that reconciles it. #expect(await harness.transport.calls == [ .snapshot(id: "pl", credential: "secret-original"), .remove(entryID: .piped(playlistID: "pl", position: 0), credential: "secret-original"), .snapshot(id: "pl", credential: "secret-original"), .snapshot(id: "pl", credential: "secret-original"), .remove(entryID: .piped(playlistID: "pl", position: 1), credential: "secret-original"), .snapshot(id: "pl", credential: "secret-original") ]) } @Test("Removals from different playlists do not wait for each other") func removalsOnDifferentPlaylistsInterleave() async throws { let harness = AccountServiceHarness(type: .piped) defer { harness.tearDown() } let cache = harness.makeCache() let one = makeSnapshot(playlistID: "one", videoIDs: ["aaa"], kind: .piped) let two = makeSnapshot(playlistID: "two", videoIDs: ["aaa"], kind: .piped) await harness.transport.setSnapshot(one) await harness.transport.holdCalls() let first = Task { [account = harness.account] in try await cache.removeEntry(one.entries[0], fromPlaylist: "one", for: account) } try await harness.transport.waitForCalls(1) let second = Task { [account = harness.account] in try await cache.removeEntry(two.entries[0], fromPlaylist: "two", for: account) } // Both preflights are out at once: the queue is per playlist, not one // for the whole account. try await harness.transport.waitForCalls(2) #expect(await harness.transport.callCount == 2) await harness.transport.release() try await first.value try await second.value } }