mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Subscribing went wherever the selected account happened to be, and the button offering it read the local store first — so under a server account it claimed a channel was subscribed when only the device knew about it. The button now asks the active provider and nothing else, except under a local context, where the local store is that provider and can answer without a hop. The channel menu gains a destination list: local, plus every account whose backend does subscriptions, whose instance is switched on, and which has a credential on this device. A row names its own target, so it deliberately does not care whether the selected context can be written to — that is precisely the case where naming somewhere else is the only way to subscribe at all. The main button is disabled instead, with the reason beside it. tvOS reaches the same list through its own button and a full-screen picker, because a menu cannot be driven with a remote. Membership is asked for only once the rows are on screen: an unwarmed provider answers by fetching that account's entire subscription list, and asking every destination up front is a burst nobody requested. Answers carry the round they were asked under, and "not asked yet" is kept apart from "asked, not subscribed" — a checkmark the user acts on must not be a guess. SubscriptionService grows the destination-parameterized subscribe, unsubscribe and membership calls the menu writes through, with the context-implicit ones left as wrappers over the current context. A membership answer that outlived its login now says nothing rather than "no". Building a provider moves behind SubscriptionProviderFactory, which is what lets a test see which destination a write was routed to without a server behind it. The unsubscribe-only screens are untouched: they act on the account being browsed, which is the account whose list they are showing.
333 lines
12 KiB
Swift
333 lines
12 KiB
Swift
//
|
|
// SubscriptionDestinationTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Where a subscribe lands when the user names its destination, and what the
|
|
// menu is allowed to claim about the answers it gets back.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Yattee
|
|
|
|
// MARK: - Scripted provider
|
|
|
|
/// A ``SubscriptionProvider`` that answers from memory and records every call,
|
|
/// so a test can assert which destination a write was actually routed to.
|
|
@MainActor
|
|
private final class ScriptedSubscriptionProvider: SubscriptionProvider {
|
|
let context: DataContext
|
|
|
|
private(set) var subscribed: Set<String> = []
|
|
private(set) var subscribeCalls: [String] = []
|
|
private(set) var unsubscribeCalls: [String] = []
|
|
private(set) var membershipCalls: [String] = []
|
|
|
|
/// Thrown by the next write.
|
|
var nextError: Error?
|
|
|
|
/// While true, every call suspends until `release()`.
|
|
var holdsCallsOpen = false
|
|
private var parked: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(context: DataContext, subscribed: Set<String> = []) {
|
|
self.context = context
|
|
self.subscribed = subscribed
|
|
}
|
|
|
|
func release() {
|
|
holdsCallsOpen = false
|
|
let waiting = parked
|
|
parked = []
|
|
waiting.forEach { $0.resume() }
|
|
}
|
|
|
|
private func park() async {
|
|
if holdsCallsOpen {
|
|
await withCheckedContinuation { parked.append($0) }
|
|
}
|
|
}
|
|
|
|
func fetchSubscriptions() async throws -> [Channel] {
|
|
subscribed.map { Channel(id: .global($0), name: $0) }
|
|
}
|
|
|
|
func subscribe(to channel: Channel) async throws {
|
|
subscribeCalls.append(channel.id.channelID)
|
|
await park()
|
|
if let nextError { throw nextError }
|
|
subscribed.insert(channel.id.channelID)
|
|
}
|
|
|
|
func unsubscribe(from channelID: String) async throws {
|
|
unsubscribeCalls.append(channelID)
|
|
await park()
|
|
if let nextError { throw nextError }
|
|
subscribed.remove(channelID)
|
|
}
|
|
|
|
func isSubscribed(to channelID: String) async -> Bool {
|
|
membershipCalls.append(channelID)
|
|
await park()
|
|
return subscribed.contains(channelID)
|
|
}
|
|
|
|
var cachedSubscriptionCount: Int? { subscribed.count }
|
|
}
|
|
|
|
/// Hands out the scripted providers instead of building ones that would need a
|
|
/// server and a credential.
|
|
@MainActor
|
|
private final class ScriptedProviderFactory: SubscriptionProviderFactory {
|
|
var providers: [UUID: ScriptedSubscriptionProvider] = [:]
|
|
|
|
func provider(
|
|
for account: Account,
|
|
instance _: Instance,
|
|
accountsManager _: AccountsManager
|
|
) -> (any SubscriptionProvider)? {
|
|
providers[account.id]
|
|
}
|
|
}
|
|
|
|
// MARK: - Routing
|
|
|
|
@MainActor
|
|
@Suite("Subscription destinations", .serialized)
|
|
struct SubscriptionDestinationRoutingTests {
|
|
/// Everything one test needs: two signed-in accounts on their own
|
|
/// instances, each with a provider that records what it was asked to do.
|
|
@MainActor
|
|
private struct Fixture {
|
|
let service: SubscriptionService
|
|
let dataManager: DataManager
|
|
let accounts: AccountsManager
|
|
let instances: InstancesManager
|
|
let madeInstances: [Instance]
|
|
let factory: ScriptedProviderFactory
|
|
let first: Account
|
|
let second: Account
|
|
|
|
var firstProvider: ScriptedSubscriptionProvider { factory.providers[first.id]! }
|
|
var secondProvider: ScriptedSubscriptionProvider { factory.providers[second.id]! }
|
|
|
|
func tearDown() {
|
|
for instance in madeInstances { instances.remove(instance) }
|
|
}
|
|
}
|
|
|
|
private func makeFixture() throws -> Fixture {
|
|
let instances = InstancesManager()
|
|
let made = (0 ..< 2).map { index in
|
|
Instance(
|
|
type: .invidious,
|
|
url: URL(string: "https://inv-\(index)-\(UUID().uuidString).example.com")!
|
|
)
|
|
}
|
|
for instance in made { instances.add(instance) }
|
|
|
|
let accounts = AccountsManager(
|
|
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
|
|
instancesManager: instances,
|
|
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
|
)
|
|
let first = accounts.addAccount(instanceID: made[0].id)
|
|
let second = accounts.addAccount(instanceID: made[1].id)
|
|
|
|
let factory = ScriptedProviderFactory()
|
|
factory.providers[first.id] = ScriptedSubscriptionProvider(context: .account(first.id))
|
|
factory.providers[second.id] = ScriptedSubscriptionProvider(context: .account(second.id))
|
|
|
|
let dataManager = try DataManager(inMemory: true)
|
|
let service = SubscriptionService(dataManager: dataManager, providerFactory: factory)
|
|
service.setAccountsManager(accounts)
|
|
|
|
return Fixture(
|
|
service: service,
|
|
dataManager: dataManager,
|
|
accounts: accounts,
|
|
instances: instances,
|
|
madeInstances: made,
|
|
factory: factory,
|
|
first: first,
|
|
second: second
|
|
)
|
|
}
|
|
|
|
private func channel(_ id: String = "UC-test") -> Channel {
|
|
Channel(id: .global(id), name: "Channel \(id)")
|
|
}
|
|
|
|
@Test("A subscribe lands on the account it names, not the one selected")
|
|
func subscribeRoutesToNamedDestination() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
fixture.accounts.setContext(.account(fixture.first.id))
|
|
try await fixture.service.subscribe(to: channel(), in: .account(fixture.second.id))
|
|
|
|
#expect(fixture.secondProvider.subscribeCalls == ["UC-test"])
|
|
#expect(fixture.firstProvider.subscribeCalls.isEmpty)
|
|
}
|
|
|
|
@Test("An unsubscribe lands on the account it names")
|
|
func unsubscribeRoutesToNamedDestination() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try await fixture.service.subscribe(to: channel(), in: .account(fixture.second.id))
|
|
try await fixture.service.unsubscribe(from: "UC-test", in: .account(fixture.second.id))
|
|
|
|
#expect(fixture.secondProvider.unsubscribeCalls == ["UC-test"])
|
|
#expect(fixture.firstProvider.unsubscribeCalls.isEmpty)
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .account(fixture.second.id)) == false)
|
|
}
|
|
|
|
@Test("The local destination writes to the device's own store")
|
|
func localDestinationWritesLocally() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
fixture.accounts.setContext(.account(fixture.first.id))
|
|
try await fixture.service.subscribe(to: channel(), in: .local)
|
|
|
|
#expect(fixture.dataManager.isSubscribed(to: "UC-test"))
|
|
#expect(fixture.firstProvider.subscribeCalls.isEmpty)
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .local) == true)
|
|
}
|
|
|
|
@Test("Membership is answered per destination")
|
|
func membershipIsPerDestination() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
try await fixture.service.subscribe(to: channel(), in: .account(fixture.first.id))
|
|
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .account(fixture.first.id)) == true)
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .account(fixture.second.id)) == false)
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .local) == false)
|
|
}
|
|
|
|
@Test("Without a destination the current context is written")
|
|
func implicitCallsUseTheCurrentContext() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
fixture.accounts.setContext(.account(fixture.second.id))
|
|
try await fixture.service.subscribe(to: channel())
|
|
|
|
#expect(fixture.secondProvider.subscribeCalls == ["UC-test"])
|
|
#expect(fixture.firstProvider.subscribeCalls.isEmpty)
|
|
}
|
|
|
|
@Test("A named destination is written even while the selected account is unusable")
|
|
func namedDestinationIgnoresTheSelectedContext() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
// An account with no instance and no provider: the selected context
|
|
// cannot be written to at all, which is exactly when a destination row
|
|
// is the only way to subscribe.
|
|
let unusable = fixture.accounts.addAccount(instanceID: UUID())
|
|
fixture.accounts.setContext(.account(unusable.id))
|
|
|
|
await #expect(throws: Error.self) {
|
|
try await fixture.service.subscribe(to: channel())
|
|
}
|
|
|
|
try await fixture.service.subscribe(to: channel(), in: .account(fixture.first.id))
|
|
#expect(fixture.firstProvider.subscribeCalls == ["UC-test"])
|
|
}
|
|
|
|
@Test("A destination that cannot be served says nothing rather than 'not subscribed'")
|
|
func unservableDestinationAnswersUnknown() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
let unusable = fixture.accounts.addAccount(instanceID: UUID())
|
|
|
|
#expect(await fixture.service.isSubscribed(to: "UC-test", in: .account(unusable.id)) == nil)
|
|
}
|
|
|
|
@Test("An answer that outlived its login is discarded")
|
|
func staleMembershipAnswerIsDiscarded() async throws {
|
|
let fixture = try makeFixture()
|
|
defer { fixture.tearDown() }
|
|
|
|
fixture.firstProvider.holdsCallsOpen = true
|
|
|
|
let pending = Task { await fixture.service.isSubscribed(to: "UC-test", in: .account(fixture.first.id)) }
|
|
|
|
for _ in 0 ..< 200 where fixture.firstProvider.membershipCalls.isEmpty {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
#expect(!fixture.firstProvider.membershipCalls.isEmpty)
|
|
|
|
// Signing in as somebody else replaces every provider; the answer now
|
|
// in flight belongs to the identity that left.
|
|
fixture.service.discardAllProviders()
|
|
fixture.firstProvider.release()
|
|
|
|
#expect(await pending.value == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Membership state
|
|
|
|
@MainActor
|
|
@Suite("Subscription destination membership")
|
|
struct SubscriptionDestinationMembershipTests {
|
|
@Test("Nothing is claimed before an answer arrives")
|
|
func unknownUntilAnswered() {
|
|
let membership = SubscriptionDestinationMembership()
|
|
|
|
#expect(membership.isSubscribed(on: .local) == nil)
|
|
}
|
|
|
|
@Test("An answer is kept for the destination it was asked about")
|
|
func answersAreKeptPerDestination() {
|
|
let account = DataContext.account(UUID())
|
|
var membership = SubscriptionDestinationMembership()
|
|
|
|
membership.record(true, for: account, generation: membership.generation)
|
|
membership.record(false, for: .local, generation: membership.generation)
|
|
|
|
#expect(membership.isSubscribed(on: account) == true)
|
|
#expect(membership.isSubscribed(on: .local) == false)
|
|
}
|
|
|
|
@Test("An answer with nothing to say is not stored as 'not subscribed'")
|
|
func unknownAnswersAreNotStored() {
|
|
var membership = SubscriptionDestinationMembership()
|
|
|
|
membership.record(nil, for: .local, generation: membership.generation)
|
|
|
|
#expect(membership.isSubscribed(on: .local) == nil)
|
|
}
|
|
|
|
@Test("A new round forgets what the last one learned")
|
|
func invalidationForgetsAnswers() {
|
|
var membership = SubscriptionDestinationMembership()
|
|
membership.record(true, for: .local, generation: membership.generation)
|
|
|
|
membership.invalidate()
|
|
|
|
#expect(membership.isSubscribed(on: .local) == nil)
|
|
}
|
|
|
|
@Test("An answer from an abandoned round is dropped")
|
|
func lateAnswersAreDropped() {
|
|
var membership = SubscriptionDestinationMembership()
|
|
let stale = membership.generation
|
|
|
|
// The channel changed while the question was in flight.
|
|
membership.invalidate()
|
|
membership.record(true, for: .local, generation: stale)
|
|
|
|
#expect(membership.isSubscribed(on: .local) == nil)
|
|
|
|
membership.record(false, for: .local, generation: membership.generation)
|
|
#expect(membership.isSubscribed(on: .local) == false)
|
|
}
|
|
}
|