using System.Text.Json; using allstarr.Core.Capabilities; using allstarr.Core.Matching; using allstarr.Core.Routing; using allstarr.Core.Storage; namespace allstarr.Tests; public sealed class ProviderRouterTests { [Fact] public async Task Plan_OrdersEligibleProvidersAndAllowedTypedFailureAdvancesToVerifiedIdentity() { var identity = new FakeIdentityService(ProviderIdentityVerification.Verified); var router = Router( [Metadata("spotify"), Metadata("deezer")], identity: identity); var request = Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], source: Track("spotify", "source-secret-id")); var plan = await router.PlanAsync(request); var fallback = router.EvaluateFallback( plan, 0, new ProviderError(ProviderErrorKind.TransientFailure)); Assert.Equal(["spotify", "deezer"], plan.Candidates.Select(item => item.Provider.Id)); Assert.Equal("deezer-verified-target", plan.Candidates[1].TrackId!.Value); Assert.Equal(ProviderFallbackDisposition.Advance, fallback.Disposition); Assert.Equal("deezer", fallback.NextCandidate!.Provider.Id); Assert.Equal("fallback-transient-failure", fallback.ReasonCode); Assert.Single(identity.Translations); } [Theory] [InlineData(ProviderErrorKind.AccountNeedsConfiguration)] [InlineData(ProviderErrorKind.Unauthorized)] [InlineData(ProviderErrorKind.Forbidden)] [InlineData(ProviderErrorKind.PermanentFailure)] [InlineData(ProviderErrorKind.Canceled)] public async Task Fallback_DoesNotCrossProvidersForNonFallbackFailures(ProviderErrorKind kind) { var router = Router( [Metadata("spotify"), Metadata("deezer")], identity: new FakeIdentityService(ProviderIdentityVerification.Verified)); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], source: Track("spotify", "source"))); var fallback = router.EvaluateFallback(plan, 0, new ProviderError(kind)); Assert.Equal(ProviderFallbackDisposition.StopFailure, fallback.Disposition); Assert.Null(fallback.NextCandidate); } [Fact] public async Task Plan_RejectsCrossProviderFallbackWithoutVerifiedExactIdentity() { var router = Router( [Metadata("spotify"), Metadata("deezer")], identity: new FakeIdentityService(ProviderIdentityVerification.Unknown)); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], source: Track("spotify", "source"))); Assert.Single(plan.Candidates); Assert.Equal("spotify", plan.Candidates[0].Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ProviderId == "deezer" && item.ReasonCode == "verified-identity-required"); } [Fact] public async Task Plan_EnforcesAccountScopeEnabledAndRevisionBeforePriority() { var tenant = Guid.CreateVersion7(); var user = Guid.CreateVersion7(); var actor = Actor(tenant, user); var validAccount = Account("valid", tenant, user, revision: 3); var wrongScope = new ProviderAccountContext( Guid.CreateVersion7(), "wrong-scope", ProviderAccountScope.Global, 1, resolutionReason: "global-account"); var disabled = new ProviderAccountContext( Guid.CreateVersion7(), "disabled", ProviderAccountScope.User, 1, enabled: false, tenantId: tenant, ownerUserId: user); var accounts = new FakeAccountResolver(new Dictionary { ["valid"] = new(validAccount, 3), ["wrong-scope"] = new(wrongScope, 1), ["disabled"] = new(disabled, 1), ["stale"] = new(Account("stale", tenant, user, revision: 1), 2) }); var router = Router( [ Metadata("wrong-scope", ProviderAccountRequirement.Required, [ProviderAccountScope.User]), Metadata("disabled", ProviderAccountRequirement.Required, [ProviderAccountScope.User]), Metadata("stale", ProviderAccountRequirement.Required, [ProviderAccountScope.User]), Metadata("valid", ProviderAccountRequirement.Required, [ProviderAccountScope.User]) ], accounts: accounts); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["wrong-scope", "disabled", "stale", "valid"], actor: actor, states: [new ProviderRouteProviderState("valid", expectedAccountRevision: 3)])); Assert.Single(plan.Candidates); Assert.Equal("valid", plan.Candidates[0].Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "account-scope-denied"); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "account-disabled"); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "account-stale"); } [Fact] public async Task Plan_RemovesDisabledCapabilityWithoutRemovingAnotherProvider() { var router = Router([Metadata("spotify"), Metadata("deezer")]); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], states: [new ProviderRouteProviderState("spotify", capabilityEnabled: false)])); Assert.Equal("deezer", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ProviderId == "spotify" && item.ReasonCode == "capability-disabled"); } [Fact] public async Task Plan_UsesCapabilityPriorityOnlyAfterQualityPolicyFiltering() { var router = Router([Streaming("lossy"), Streaming("lossless")]); var policy = Policy(minimum: ProviderAudioQuality.Lossless); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Streaming, ["lossy", "lossless"], policy: policy, states: [ new ProviderRouteProviderState("lossy", availableQualities: [ProviderAudioQuality.Lossy]), new ProviderRouteProviderState("lossless", availableQualities: [ProviderAudioQuality.Lossless]) ])); Assert.Equal("lossless", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ProviderId == "lossy" && item.ReasonCode == "quality-policy-denied"); } [Fact] public async Task Plan_RejectsOpenCircuitAndUnreadyDeclaredSidecar() { var tenant = Guid.CreateVersion7(); var user = Guid.CreateVersion7(); var account = Account("circuit", tenant, user); var accounts = new FakeAccountResolver(new Dictionary { ["circuit"] = new(account, account.Revision) }); var health = new FakeHealthSource(new Dictionary { ["circuit"] = new(ProviderRouteHealthState.Healthy, CircuitOpen: true) }); var router = Router( [ Metadata("circuit", ProviderAccountRequirement.Required, [ProviderAccountScope.User]), Metadata("sidecar", sidecar: "sidecar-runtime"), Metadata("ready", sidecar: "ready-runtime") ], accounts, health, new FakeSidecarSource(["ready-runtime"])); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["circuit", "sidecar", "ready"], actor: Actor(tenant, user))); Assert.Equal("ready", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "circuit-open"); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "sidecar-not-ready"); } [Theory] [InlineData(ProviderRouteHealthState.Degraded, "health-degraded")] [InlineData(ProviderRouteHealthState.Unavailable, "health-unavailable")] [InlineData(ProviderRouteHealthState.Unauthorized, "health-unauthorized")] public async Task Plan_RejectsUnhealthyAccountFreeCapability( ProviderRouteHealthState state, string reason) { var health = new FakeHealthSource(new Dictionary { ["unhealthy"] = new(state, CircuitOpen: false) }); var router = Router([Metadata("unhealthy"), Metadata("ready")], health: health); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["unhealthy", "ready"])); Assert.Equal("ready", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ProviderId == "unhealthy" && item.ProviderAccountId == null && item.ReasonCode == reason); } [Fact] public async Task DecisionRecord_IsExplainableAndDoesNotContainOpaqueIdsOrAccountDetails() { const string rawTrack = "track-token=https://secret.invalid/audio?token=very-secret"; var router = Router( [Metadata("spotify"), Metadata("deezer")], identity: new FakeIdentityService(ProviderIdentityVerification.Verified)); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], source: Track("spotify", rawTrack))); var serialized = JsonSerializer.Serialize(plan.Decision); Assert.Contains("spotify", serialized, StringComparison.Ordinal); Assert.Contains("deezer", serialized, StringComparison.Ordinal); Assert.Contains("selected", serialized, StringComparison.Ordinal); Assert.DoesNotContain(rawTrack, serialized, StringComparison.Ordinal); Assert.DoesNotContain("very-secret", serialized, StringComparison.Ordinal); Assert.DoesNotContain("https://", serialized, StringComparison.Ordinal); } [Fact] public async Task Plan_RejectsUntypedPlanningAndProviderOutsideAllowlist() { var router = Router([Metadata("spotify"), Metadata("deezer")]); var request = Request( ProviderCapabilityKind.Metadata, ["spotify", "deezer"], policy: Policy(allowedProviders: ["deezer"])); await Assert.ThrowsAsync(() => router.PlanAsync(request)); var plan = await router.PlanAsync(request); Assert.Equal("deezer", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, item => item.ReasonCode == "provider-not-allowed"); } [Fact] public async Task Plan_RejectsDownloadWithoutDurableIdempotencyKey() { var router = Router([Download("download-provider")]); var plan = await router.PlanAsync(Request( ProviderCapabilityKind.Download, ["download-provider"], states: [ new ProviderRouteProviderState( "download-provider", availableQualities: [ProviderAudioQuality.Lossless]) ])); Assert.Empty(plan.Candidates); Assert.Equal("idempotency-key-required", Assert.Single(plan.Decision.Candidates).ReasonCode); } [Fact] public async Task Plan_EnforcesExplicitTermsRateLimitAndStoragePolicyInputs() { var metadataRouter = Router([ Metadata("clean"), Metadata("explicit"), Metadata("unknown"), Metadata("terms"), Metadata("rate") ]); var metadata = await metadataRouter.PlanAsync(Request( ProviderCapabilityKind.Metadata, ["explicit", "unknown", "clean", "terms", "rate"], policy: Policy(explicitContent: ProviderExplicitContentPolicy.CleanOnly), states: [ new ProviderRouteProviderState("clean", isExplicit: false), new ProviderRouteProviderState("explicit", isExplicit: true), new ProviderRouteProviderState("terms", isExplicit: false, providerTermsAllowed: false), new ProviderRouteProviderState("rate", isExplicit: false, rateLimitBudgetAvailable: false) ])); Assert.Equal("clean", Assert.Single(metadata.Candidates).Provider.Id); Assert.Contains(metadata.Decision.Candidates, item => item.ProviderId == "explicit" && item.ReasonCode == "explicit-content-denied"); Assert.Contains(metadata.Decision.Candidates, item => item.ProviderId == "unknown" && item.ReasonCode == "explicit-state-unknown"); Assert.Contains(metadata.Decision.Candidates, item => item.ProviderId == "terms" && item.ReasonCode == "provider-terms-denied"); Assert.Contains(metadata.Decision.Candidates, item => item.ProviderId == "rate" && item.ReasonCode == "rate-limit-budget-exhausted"); var download = await Router([Download("full")]).PlanAsync(Request( ProviderCapabilityKind.Download, ["full"], idempotencyKey: "download-1", states: [ new ProviderRouteProviderState("full", availableQualities: [ProviderAudioQuality.Lossless], storageCapacityAvailable: false) ])); Assert.Empty(download.Candidates); Assert.Equal("storage-capacity-unavailable", Assert.Single(download.Decision.Candidates).ReasonCode); } [Fact] public async Task PreferClean_ReordersEligibleCandidatesWithoutDiscardingExplicitFallback() { var plan = await Router([Metadata("explicit"), Metadata("clean")]) .PlanAsync(Request( ProviderCapabilityKind.Metadata, ["explicit", "clean"], policy: Policy(explicitContent: ProviderExplicitContentPolicy.PreferClean), states: [ new ProviderRouteProviderState("explicit", isExplicit: true), new ProviderRouteProviderState("clean", isExplicit: false) ])); Assert.Equal(["clean", "explicit"], plan.Candidates.Select(item => item.Provider.Id)); } [Fact] public async Task Plan_RejectsAnExpiredRequestBeforeAccountOrProviderRouting() { var accounts = new FakeAccountResolver(); var router = Router([Metadata("spotify")], accounts); var request = Request( ProviderCapabilityKind.Metadata, ["spotify"], deadline: DateTimeOffset.UtcNow.AddMinutes(-1)); await Assert.ThrowsAsync(() => router.PlanAsync(request)); Assert.Equal(0, accounts.CallCount); } [Fact] public async Task Plan_ManualSourcePinPreventsAutomaticProviderSwitching() { var router = Router([Streaming("deezer"), Streaming("qobuz")], identity: new FakeIdentityService(ProviderIdentityVerification.Verified, ProviderIdentityVerification.Pinned)); var plan = await router.PlanAsync(Request(ProviderCapabilityKind.Streaming, ["qobuz", "deezer"], source: Track("deezer", "pinned-track"), states: [new ProviderRouteProviderState("deezer", availableQualities: [ProviderAudioQuality.Lossless]), new ProviderRouteProviderState("qobuz", availableQualities: [ProviderAudioQuality.Lossless])])); Assert.Equal("deezer", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, candidate => candidate.ProviderId == "qobuz" && candidate.ReasonCode == "manual-source-pinned"); } [Theory] [InlineData("automatic-suggestion")] [InlineData("manual-released")] [InlineData("manual-replaced")] public async Task Plan_DoesNotReviveTentativeOrReleasedAlternatives(string method) { var router = Router([Metadata("deezer"), Metadata("qobuz")], identity: new FakeIdentityService(ProviderIdentityVerification.Verified, targetMethod: method)); var plan = await router.PlanAsync(Request(ProviderCapabilityKind.Metadata, ["deezer", "qobuz"], source: Track("deezer", "track"))); Assert.Equal("deezer", Assert.Single(plan.Candidates).Provider.Id); Assert.Contains(plan.Decision.Candidates, candidate => candidate.ProviderId == "qobuz" && candidate.ReasonCode == "verified-identity-required"); } private static ProviderRouter Router( IEnumerable registrations, IProviderRouteAccountResolver? accounts = null, IProviderRouteHealthSource? health = null, IProviderRouteSidecarSource? sidecars = null, ITrackIdentityService? identity = null) => new( new ProviderRegistry(registrations), accounts ?? new FakeAccountResolver(), health ?? new FakeHealthSource(), sidecars ?? new FakeSidecarSource(), identity ?? new FakeIdentityService(ProviderIdentityVerification.Verified)); private static ProviderRegistration Metadata( string providerId, ProviderAccountRequirement requirement = ProviderAccountRequirement.None, IEnumerable? scopes = null, string? sidecar = null) { var capability = new ProviderCapabilityDescriptor( ProviderCapabilityKind.Metadata, ProviderCapabilitySupportState.Supported, requirement, "1.0", ["searchTracks", "getTrack"], scopes, sidecar); return Registration(providerId, capability, new FakeMetadataCapability(providerId)); } private static ProviderRegistration Streaming(string providerId) { var capability = new ProviderCapabilityDescriptor( ProviderCapabilityKind.Streaming, ProviderCapabilitySupportState.Supported, ProviderAccountRequirement.None, "1.0", ["getStreamLease"]); return Registration(providerId, capability, new FakeStreamingCapability(providerId)); } private static ProviderRegistration Download(string providerId) { var capability = new ProviderCapabilityDescriptor( ProviderCapabilityKind.Download, ProviderCapabilitySupportState.Supported, ProviderAccountRequirement.None, "1.0", ["checkAvailability", "download"]); return Registration(providerId, capability, new FakeDownloadCapability(providerId)); } private static ProviderRegistration Registration( string providerId, ProviderCapabilityDescriptor capability, IProviderCapability implementation) => new( new ProviderDescriptor( providerId, providerId, $"{providerId} fake provider", ProviderOrigin.BuiltIn, "1", "1.0", [capability], new ProviderPermissionDescriptor()), [implementation]); private static ProviderRouteRequest Request( ProviderCapabilityKind capability, IEnumerable priority, ProviderActorContext? actor = null, ProviderExecutionPolicy? policy = null, IEnumerable? states = null, ProviderExternalResourceId? source = null, DateTimeOffset? deadline = null, string? idempotencyKey = null) => new( capability, actor ?? Actor(Guid.CreateVersion7(), Guid.CreateVersion7()), policy ?? Policy(), "router-test", "router-correlation", deadline ?? DateTimeOffset.UtcNow.AddMinutes(1), priority, states, sourceTrackId: source, idempotencyKey: idempotencyKey); private static ProviderExecutionPolicy Policy( ProviderAudioQuality minimum = ProviderAudioQuality.Any, IEnumerable? allowedProviders = null, ProviderExplicitContentPolicy explicitContent = ProviderExplicitContentPolicy.Allow) => new( new ProviderQualityPolicy(minimum, ProviderAudioQuality.HighResolution, allowTranscode: false), explicitContent, allowFallback: true, allowSharedAccount: true, allowManagedDownloads: true, allowedProviders); private static ProviderActorContext Actor(Guid tenantId, Guid userId) => new( tenantId, ProviderActorKind.User, userId, new ProviderBackendPrincipal("jellyfin", "main", userId.ToString("N"))); private static ProviderAccountContext Account( string providerId, Guid tenantId, Guid userId, long revision = 1) => new( Guid.CreateVersion7(), providerId, ProviderAccountScope.User, revision, tenantId: tenantId, ownerUserId: userId); private static ProviderExternalResourceId Track(string providerId, string value) => new(providerId, ProviderResourceKind.Track, value); private sealed class FakeAccountResolver( IReadOnlyDictionary? resolutions = null) : IProviderRouteAccountResolver { private readonly IReadOnlyDictionary _resolutions = resolutions ?? new Dictionary(); public int CallCount { get; private set; } public Task ResolveAsync( ProviderRouteAccountRequest request, CancellationToken cancellationToken = default) { CallCount++; return Task.FromResult(_resolutions.GetValueOrDefault(request.ProviderId)); } } private sealed class FakeHealthSource( IReadOnlyDictionary? snapshots = null) : IProviderRouteHealthSource { private readonly IReadOnlyDictionary _snapshots = snapshots ?? new Dictionary(); public ProviderRouteHealthSnapshot Get( string providerId, Guid? providerAccountId, ProviderCapabilityKind capability) => _snapshots.GetValueOrDefault(providerId) ?? new ProviderRouteHealthSnapshot(ProviderRouteHealthState.Unknown, CircuitOpen: false); } private sealed class FakeSidecarSource(IEnumerable? ready = null) : IProviderRouteSidecarSource { private readonly HashSet _ready = new(ready ?? [], StringComparer.Ordinal); public bool IsReady(string dependencyId) => _ready.Contains(dependencyId); } private sealed class FakeIdentityService(ProviderIdentityVerification verification, ProviderIdentityVerification? sourceVerification = null, string targetMethod = "test-fixture") : ITrackIdentityService { public List<(string Source, string Target)> Translations { get; } = []; public Task CreateRecordingAsync( ProviderActorContext actor, string correlationId, string? isrc = null, string? musicBrainzRecordingId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LinkAsync( ProviderExecutionContext executionContext, TrackIdentityLinkRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ResolveAsync( ProviderExecutionContext executionContext, ProviderExternalResourceId externalId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task TranslateAsync( ProviderExecutionContext sourceContext, ProviderExternalResourceId sourceId, ProviderExecutionContext targetContext, ProviderTrackIdentityTarget target, CancellationToken cancellationToken = default) { Translations.Add((sourceContext.ProviderId, targetContext.ProviderId)); var targetId = Track(target.ProviderId, $"{target.ProviderId}-verified-target"); var resolution = new TrackIdentityResolution( Guid.CreateVersion7(), Guid.CreateVersion7(), targetId, ProviderIdentityScope.Catalog, null, verification, targetMethod, 1); return Task.FromResult(new TrackIdentityTranslationResult( TrackIdentityTranslationStatus.Translated, resolution.CanonicalRecordingId, sourceVerification == null ? null : resolution with { ExternalId = sourceId, Verification = sourceVerification.Value }, resolution)); } } private sealed class FakeMetadataCapability(string providerId) : IProviderMetadataCapability { public string ProviderId { get; } = providerId; public ProviderCapabilityKind Capability => ProviderCapabilityKind.Metadata; public Task>> SearchTracksAsync( ProviderExecutionContext context, ProviderMetadataSearchRequest request) => throw new NotSupportedException(); public Task> GetTrackAsync( ProviderExecutionContext context, ProviderTrackLookupRequest request) => throw new NotSupportedException(); public Task> LookupByIsrcAsync( ProviderExecutionContext context, ProviderIsrcLookupRequest request) => throw new NotSupportedException(); public Task>> SearchAlbumsAsync( ProviderExecutionContext context, ProviderMetadataSearchRequest request) => throw new NotSupportedException(); public Task> GetAlbumAsync( ProviderExecutionContext context, ProviderAlbumLookupRequest request) => throw new NotSupportedException(); public Task>> SearchArtistsAsync( ProviderExecutionContext context, ProviderMetadataSearchRequest request) => throw new NotSupportedException(); public Task> GetArtistAsync( ProviderExecutionContext context, ProviderArtistLookupRequest request) => throw new NotSupportedException(); } private sealed class FakeStreamingCapability(string providerId) : IProviderStreamingCapability { public string ProviderId { get; } = providerId; public ProviderCapabilityKind Capability => ProviderCapabilityKind.Streaming; public Task> GetStreamLeaseAsync( ProviderExecutionContext context, ProviderStreamLeaseRequest request) => throw new NotSupportedException(); public Task> ProbeStreamAsync( ProviderExecutionContext context, ProviderStreamLeaseRequest request) => throw new NotSupportedException(); } private sealed class FakeDownloadCapability(string providerId) : IProviderDownloadCapability { public string ProviderId { get; } = providerId; public ProviderCapabilityKind Capability => ProviderCapabilityKind.Download; public Task> CheckAvailabilityAsync( ProviderExecutionContext context, ProviderDownloadAvailabilityRequest request) => throw new NotSupportedException(); public Task> DownloadAsync( ProviderExecutionContext context, ProviderDownloadRequest request, IProgress? progress = null) => throw new NotSupportedException(); } }