Files
yattee/YatteeTests/AccountServiceTests.swift
Arkadiusz Fal b2741bbb32 Let an account change the playlists it owns
Reading a server-side playlist was all the app could do: the write half of
both APIs was missing, and the only thing that knew which credential to use
was the subscription layer. So playlists arrive here as an account concern —
hand AccountService an account and it finds the instance, reads the secret,
refuses what the backend cannot do, and records what the answer implies
about the login.

Capabilities decide, never the instance type: a privacy setting sent to a
backend that has none is dropped rather than refused, because the playlist
is still perfectly creatable there. Every call captures the credential it
used and re-reads it before recording an outcome, so a late 401 cannot
reject the login the user just fixed, nor a late success clear a rejection
it never earned.

Removal is where the backends stop being alike. Invidious names the row
itself with an indexId that survives refetches, so it removes directly.
Piped removes by position, and a position is only true of the fetch that
minted it — so the playlist is refetched and the position checked to still
hold the same video before anything is deleted. That narrows the window; it
cannot close it, and only the refetch afterwards shows what really happened.

The cache exists so that refetch cannot be forgotten. Every mutation goes
through it and republishes what it touched — on failure as much as on
success, because a write that reported failure may still have landed — and
nothing is ever edited optimistically. It is in memory alone: the data
belongs to a login rather than to this device, and a stored Piped entry id
would be aimed at whatever occupies that slot next launch. A per-account
generation fences results that come back after the account was cleared,
since clearing has never cancelled the request that was filling it.

Along the way, clients minted per instance sent no User-Agent at all —
instances that whitelist the Yattee prefix answered them with a 401 the
shared client never earned. The factory now bakes the app's User-Agent in
at construction, where no request can outrun it.
2026-09-02 23:29:13 +02:00

519 lines
18 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"
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?.credential },
transport: transport
)
}
/// 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
)
}
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")])
}
@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("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)
}
}