mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
599 lines
22 KiB
Swift
599 lines
22 KiB
Swift
//
|
|
// WatchHistorySyncTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Which account a watch is mirrored to, how many times it is sent, and what
|
|
// happens to a post nobody is waiting for.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Yattee
|
|
|
|
// MARK: - Harness
|
|
|
|
/// An account whose instance keeps history, the scripted transport behind it,
|
|
/// and the sync service on top.
|
|
///
|
|
/// The context validator is the real one: what suppresses mirroring is the
|
|
/// *effective* context, and that is computed from the account's status and its
|
|
/// instance — both of which a test can move directly.
|
|
@MainActor
|
|
private final class WatchHistorySyncHarness {
|
|
let instances: InstancesManager
|
|
private(set) var instance: Instance
|
|
let accounts: AccountsManager
|
|
let account: Account
|
|
let playlistTransport = ScriptedPlaylistTransport()
|
|
let transport = ScriptedHistoryTransport()
|
|
let validator: DataContextValidator
|
|
private(set) var service: WatchHistorySyncService!
|
|
|
|
/// What the credential reader reports for each account right now. Keyed by
|
|
/// account so a post can be shown to have gone out under the login that was
|
|
/// selected when playback started, and not the one selected since.
|
|
var credentials: [UUID: String] = [:]
|
|
|
|
init(type: InstanceType = .invidious) {
|
|
instances = InstancesManager()
|
|
instance = Instance(
|
|
type: type,
|
|
url: URL(string: "https://history-\(UUID().uuidString).example.com")!,
|
|
isEnabled: true
|
|
)
|
|
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)
|
|
accounts.setContext(.account(account.id))
|
|
credentials[account.id] = "secret-original"
|
|
|
|
validator = DataContextValidator(
|
|
accountsManager: accounts,
|
|
instancesManager: instances,
|
|
toastManager: ToastManager()
|
|
)
|
|
|
|
let accountService = AccountService(
|
|
accountsManager: accounts,
|
|
credentialReader: { [weak self] account in self?.credentials[account.id] },
|
|
transport: playlistTransport,
|
|
historyTransport: transport
|
|
)
|
|
|
|
service = WatchHistorySyncService(
|
|
accountsManager: accounts,
|
|
accountService: accountService,
|
|
contextValidator: validator
|
|
)
|
|
}
|
|
|
|
/// Instances live in the shared defaults, so each harness takes its own
|
|
/// back out again.
|
|
func tearDown() {
|
|
instances.remove(instance)
|
|
}
|
|
|
|
/// A second account on its own instance, for the context switches.
|
|
@discardableResult
|
|
func addSecondAccount(type: InstanceType = .invidious) -> (Instance, Account) {
|
|
let other = Instance(
|
|
type: type,
|
|
url: URL(string: "https://history-other-\(UUID().uuidString).example.com")!,
|
|
isEnabled: true
|
|
)
|
|
instances.add(other)
|
|
let otherAccount = accounts.addAccount(instanceID: other.id)
|
|
accounts.setStatus(.signedIn, for: otherAccount.id)
|
|
credentials[otherAccount.id] = "secret-other"
|
|
return (other, otherAccount)
|
|
}
|
|
}
|
|
|
|
/// A flag a test can look at from outside the task that sets it.
|
|
@MainActor
|
|
private final class Flag {
|
|
var isSet = false
|
|
}
|
|
|
|
/// Collects what ``Notification/Name/serverWatchHistoryDidUpdate`` announced.
|
|
@MainActor
|
|
private final class UpdateAnnouncements {
|
|
private(set) var received: [(accountID: UUID, videoID: String)] = []
|
|
private var observer: NSObjectProtocol?
|
|
|
|
init() {
|
|
// No queue: delivered synchronously, on the main actor the post is made
|
|
// from, so a test can look at it the moment the flush returns.
|
|
observer = NotificationCenter.default.addObserver(
|
|
forName: .serverWatchHistoryDidUpdate,
|
|
object: nil,
|
|
queue: nil
|
|
) { [weak self] notification in
|
|
guard let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID,
|
|
let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String
|
|
else { return }
|
|
|
|
MainActor.assumeIsolated {
|
|
self?.received.append((accountID: accountID, videoID: videoID))
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
if let observer { NotificationCenter.default.removeObserver(observer) }
|
|
observer = nil
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
/// A file from a media source: an ID no YouTube-backed history can take.
|
|
private func makeExtractedVideo(_ id: String) -> Video {
|
|
Video(
|
|
id: .extracted(id, extractor: "generic", originalURL: URL(string: "https://example.com/\(id).mp4")!),
|
|
title: "File \(id)",
|
|
description: nil,
|
|
author: Author(id: "local", name: "Files"),
|
|
duration: 60,
|
|
publishedAt: nil,
|
|
publishedText: nil,
|
|
viewCount: nil,
|
|
likeCount: nil,
|
|
thumbnails: [],
|
|
isLive: false,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@Suite("Watch history mirroring", .serialized)
|
|
@MainActor
|
|
struct WatchHistorySyncTests {
|
|
// MARK: - What is mirrored, and to whom
|
|
|
|
@Test("A watch is posted once its progress is first saved")
|
|
func progressPostsTheWatch() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.calls == [
|
|
.markWatched(videoID: "aaa", credential: "secret-original")
|
|
])
|
|
}
|
|
|
|
@Test("A context switched mid-video does not redirect the post")
|
|
func theAccountIsCapturedWhenPlaybackStarts() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
let (_, other) = harness.addSecondAccount()
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
|
|
// The user switches accounts while the video is playing. The video was
|
|
// still watched under the one that was selected when it started.
|
|
harness.accounts.setContext(.account(other.id))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.calls == [
|
|
.markWatched(videoID: "aaa", credential: "secret-original")
|
|
])
|
|
}
|
|
|
|
@Test("Repeated progress saves in one session cost one post")
|
|
func oneSessionPostsOnce() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
|
}
|
|
|
|
@Test("Playing the same video again is a new watch and posts again")
|
|
func aNewSessionForTheSameVideoPostsAgain() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
harness.service.endSession()
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
|
}
|
|
|
|
@Test("A finished video played again is a new watch and posts again")
|
|
func aFreshSessionForTheSameVideoPostsAgain() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
|
|
// The replay button, or repeat-one coming round: the same video, still
|
|
// loaded, watched a second time.
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
|
}
|
|
|
|
// MARK: - Reloads of the video already playing
|
|
|
|
@Test("Reloading the video already playing does not post it again")
|
|
func reloadingTheSameVideoKeepsTheSession() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
|
|
// An audio-mode toggle, a quality switch, a stream retry: the same
|
|
// video loaded again, and the same watch.
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
|
}
|
|
|
|
@Test("Moving to another video starts that video's session")
|
|
func anotherVideoReplacesTheSession() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("bbb"))
|
|
harness.service.recordProgress(videoID: "bbb")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa", "bbb"])
|
|
}
|
|
|
|
@Test("The first video of a playback starts a session with nothing to compare to")
|
|
func theFirstVideoStartsASession() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
|
}
|
|
|
|
@Test("A video opened again after the player stopped is watched again")
|
|
func theSameVideoAfterAnEndedSessionPostsAgain() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
harness.service.endSession()
|
|
|
|
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
|
}
|
|
|
|
@Test("Progress for a video the session is not about is ignored")
|
|
func progressForAnotherVideoIsIgnored() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "bbb")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("Progress after the session ended is ignored")
|
|
func progressAfterTheSessionEndedIsIgnored() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.endSession()
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
// MARK: - What is never mirrored
|
|
|
|
@Test("A video that is not YouTube content is never posted")
|
|
func nonYouTubeContentIsNeverPosted() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeExtractedVideo("file"))
|
|
harness.service.recordProgress(videoID: "file")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("Local data is nobody's history")
|
|
func localContextIsNeverPosted() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
harness.accounts.setContext(.local)
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("A backend without a history endpoint is never posted to")
|
|
func anAccountWithoutHistorySupportIsNeverPosted() async throws {
|
|
let harness = WatchHistorySyncHarness(type: .piped)
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("A context that is only falling back to local is not mirrored either")
|
|
func anUnavailableContextIsNeverPosted() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
// The selection is kept, but nothing may be sent under it — and the
|
|
// reads behind the watch came from local data, not the account.
|
|
harness.accounts.setStatus(.credentialMissing, for: harness.account.id)
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
// MARK: - Failure
|
|
|
|
@Test("A failed post is tried once more and then dropped")
|
|
func aFailedPostIsRetriedExactlyOnce() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
await harness.transport.setError(APIError.timeout)
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 2)
|
|
}
|
|
|
|
@Test("A post the server refused on its merits is not tried again")
|
|
func aNonRetryableFailureIsNotRetried() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
// An ID this server will not take is not going to take it the second
|
|
// time either.
|
|
await harness.transport.setError(APIError.httpError(statusCode: 400, message: "Invalid video id."))
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 1)
|
|
}
|
|
|
|
@Test("A post the transport itself refused is not tried again")
|
|
func aServiceFailureFromTheTransportIsNotRetried() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
await harness.transport.setError(AccountServiceError.notSupported)
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 1)
|
|
}
|
|
|
|
@Test("A post that could not be resolved at all is not retried")
|
|
func aResolutionFailureIsNotRetried() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
|
|
// Signed out between the session starting and the progress save: the
|
|
// account has no credential to send, and asking again would not change
|
|
// that.
|
|
harness.credentials[harness.account.id] = nil
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("An account deleted while the video played has nothing to mirror to")
|
|
func aDeletedAccountDropsThePost() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.accounts.removeAccount(harness.account.id)
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
// MARK: - Announcing
|
|
|
|
@Test("A post that landed says so, naming the account and the video")
|
|
func aSuccessfulPostIsAnnounced() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
let announcements = UpdateAnnouncements()
|
|
defer { announcements.stop() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
|
|
#expect(announcements.received.count == 1)
|
|
#expect(announcements.received.first?.accountID == harness.account.id)
|
|
#expect(announcements.received.first?.videoID == "aaa")
|
|
}
|
|
|
|
@Test("A post that never landed announces nothing")
|
|
func aFailedPostIsNotAnnounced() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
let announcements = UpdateAnnouncements()
|
|
defer { announcements.stop() }
|
|
await harness.transport.setError(APIError.timeout)
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
|
|
#expect(announcements.received.isEmpty)
|
|
}
|
|
|
|
// MARK: - Draining and disowning
|
|
|
|
@Test("A flush returns only once the post it is waiting for has landed")
|
|
func flushWaitsForAPostInFlight() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
await harness.transport.holdCalls()
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.waitForCalls(1)
|
|
|
|
let finished = Flag()
|
|
let flush = Task { [service = harness.service!] in
|
|
await service.flushPending()
|
|
finished.isSet = true
|
|
}
|
|
|
|
// Long enough for the flush to have returned, had it not been waiting.
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
#expect(finished.isSet == false)
|
|
|
|
await harness.transport.release()
|
|
await flush.value
|
|
#expect(finished.isSet)
|
|
}
|
|
|
|
@Test("A flush with nothing pending returns immediately")
|
|
func flushWithNothingPendingReturns() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("An account that goes away takes its unsent post with it")
|
|
func cancellingPreventsThePost() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.cancelPending(for: harness.account.id)
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.settle()
|
|
|
|
// The session went with it, so the progress save has nothing to post.
|
|
#expect(await harness.transport.callCount == 0)
|
|
}
|
|
|
|
@Test("An account that goes away disowns the post already in flight")
|
|
func cancellingKillsAPostInFlight() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
await harness.transport.holdCalls()
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
try await harness.transport.waitForCalls(1)
|
|
|
|
harness.service.cancelPending(for: harness.account.id)
|
|
await harness.transport.release()
|
|
|
|
// Nothing is left to wait for, and the cancelled attempt is never
|
|
// repeated.
|
|
await harness.service.flushPending()
|
|
try await harness.transport.settle()
|
|
#expect(await harness.transport.callCount == 1)
|
|
}
|
|
|
|
@Test("Another account's trouble leaves this one's post alone")
|
|
func cancellingAnotherAccountLeavesThePost() async throws {
|
|
let harness = WatchHistorySyncHarness()
|
|
defer { harness.tearDown() }
|
|
let (_, other) = harness.addSecondAccount()
|
|
|
|
harness.service.beginSession(video: makeVideo("aaa"))
|
|
harness.service.recordProgress(videoID: "aaa")
|
|
harness.service.cancelPending(for: other.id)
|
|
await harness.service.flushPending()
|
|
|
|
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
|
}
|
|
}
|