mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Remote playlist entries carried the display row number as their server position, so a row the app could not parse shifted every removal after it by one, and rows were paired back to entries by video id, which cannot tell two copies of the same video apart. - Piped entry positions are now the raw stream index; entries carry a separate displayRow for the row they are drawn as, filled in by both snapshot builders and used by the detail view's row map - an emptied description clears a server-side playlist instead of being read as "leave it alone" - a delete that reported failure forgets a playlist the refetch 404s on, and drops it from the account's cached list - the browse view discards account-scoped content when a status change moves which account the instance resolves to - every mutation re-reads the credential immediately before it is sent, which matters most after Piped's verify-before-remove fetch - Handoff carries the owning account id, so a Piped playlist keeps its ownership on the receiving device
642 lines
24 KiB
Swift
642 lines
24 KiB
Swift
//
|
|
// 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.
|
|
private actor ScriptedPlaylistTransport: RemotePlaylistTransport {
|
|
enum Call: Equatable, Sendable {
|
|
case userPlaylists(credential: String)
|
|
case snapshot(id: String, credential: String)
|
|
case create(title: String, description: String?, privacy: PlaylistPrivacy?, credential: String)
|
|
case update(id: String, title: String, description: String?, privacy: PlaylistPrivacy?, 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?
|
|
|
|
/// 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<Void, Never>] = []
|
|
|
|
func setError(_ error: Error?) { nextError = error }
|
|
func setSnapshot(_ snapshot: RemotePlaylistSnapshot) { self.snapshot = snapshot }
|
|
func setPlaylists(_ playlists: [Playlist]) { self.playlists = playlists }
|
|
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 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 {
|
|
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,
|
|
description: String?,
|
|
privacy: PlaylistPrivacy?,
|
|
instance _: Instance,
|
|
credential: String
|
|
) async throws -> Playlist {
|
|
try await record(
|
|
.create(title: title, description: description, privacy: privacy, credential: credential)
|
|
)
|
|
return Playlist(id: .global("new-playlist"), title: title, description: description)
|
|
}
|
|
|
|
func updatePlaylist(
|
|
id: String,
|
|
title: String,
|
|
description: String?,
|
|
privacy: PlaylistPrivacy?,
|
|
instance _: Instance,
|
|
credential: String
|
|
) async throws {
|
|
try await record(
|
|
.update(id: id, title: title, description: description, privacy: privacy, 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: - 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()
|
|
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 = InstancesManager()
|
|
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
|
|
)
|
|
}
|
|
|
|
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", description: nil, 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
|
|
)
|
|
|
|
#expect(await harness.transport.calls == [
|
|
.create(title: "Watch later", description: "Later", privacy: .unlisted, 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: - 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 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)
|
|
}
|
|
}
|