mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 05:02:53 +00:00
337 lines
12 KiB
Swift
337 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 }
|
|
|
|
var cachedSubscriptions: [Channel]? {
|
|
subscribed.map { Channel(id: .global($0), name: $0) }
|
|
}
|
|
}
|
|
|
|
/// 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 = makeTestInstancesManager()
|
|
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)
|
|
}
|
|
}
|