Files
allstarr/allstarr.Tests/Matching/TrackIdentityServiceTests.cs
T
joshpatra 881ad1647b
CI / build-and-test (push) Canceled after 0s
CI / release-critical-tests (push) Canceled after 0s
CI / csharp-format (push) Canceled after 0s
CI / webui (push) Canceled after 0s
CI / apple-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s
CI / release-manifest (push) Canceled after 0s
fix(search): collapse verified provider results safely
2026-09-23 12:59:40 -04:00

615 lines
25 KiB
C#

using System.Security.Cryptography;
using System.Text;
using allstarr.Core.Capabilities;
using allstarr.Core.Jobs;
using allstarr.Core.Matching;
using allstarr.Core.Operations;
using allstarr.Core.Storage;
using Microsoft.EntityFrameworkCore;
using Moq;
namespace allstarr.Tests;
public sealed class TrackIdentityServiceTests : IAsyncLifetime
{
private PostgresTestDatabase _database = null!;
private TestDbContextFactory _factory = null!;
private DurableStorageState _storageState = null!;
private FakeClock _clock = null!;
private TrackIdentityService _service = null!;
private Guid _tenantA;
private Guid _tenantB;
private Guid _userA;
private Guid _userB;
private Guid _userOtherTenant;
public async Task InitializeAsync()
{
_database = await PostgresTestDatabase.CreateAsync();
var storage = new DurableStorageOptions
{
Provider = "Postgres",
ConnectionString = _database.ConnectionString
};
_factory = new TestDbContextFactory(_database.Options);
await using (var context = await _factory.CreateDbContextAsync())
{
_tenantA = Guid.CreateVersion7();
_tenantB = Guid.CreateVersion7();
_userA = Guid.CreateVersion7();
_userB = Guid.CreateVersion7();
_userOtherTenant = Guid.CreateVersion7();
var now = new DateTimeOffset(2026, 7, 11, 14, 0, 0, TimeSpan.Zero);
context.Tenants.AddRange(
new TenantRecord { Id = _tenantA, Slug = "tenant-a", Name = "Tenant A", CreatedAt = now },
new TenantRecord { Id = _tenantB, Slug = "tenant-b", Name = "Tenant B", CreatedAt = now });
context.Users.AddRange(
User(_userA, _tenantA, "User A", now),
User(_userB, _tenantA, "User B", now),
User(_userOtherTenant, _tenantB, "Other tenant", now));
await context.SaveChangesAsync();
}
_storageState = new DurableStorageState(storage);
_storageState.Set(DurableStorageReadiness.Ready, "fixture");
_clock = new FakeClock(new DateTimeOffset(2026, 7, 11, 14, 0, 0, TimeSpan.Zero));
_service = new TrackIdentityService(_factory, _storageState, _clock);
}
[Fact]
public async Task OneCanonicalRecording_LinksManyProvidersAndTranslatesExactly()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(
actor,
"multi-provider-create",
"US-RC1-76-07839");
var spotify = Context(actor, "spotify");
var deezer = Context(actor, "deezer");
var qobuz = Context(actor, "qobuz");
await Link(recording.Recording.Id, spotify, "spotify-track-1");
await Link(recording.Recording.Id, deezer, "3135556");
await Link(recording.Recording.Id, qobuz, "qobuz-track-9", catalog: "us");
var translated = await _service.TranslateAsync(
spotify,
Track("spotify", "spotify-track-1"),
deezer,
new ProviderTrackIdentityTarget("deezer"));
Assert.Equal(TrackIdentityTranslationStatus.Translated, translated.Status);
Assert.Equal(recording.Recording.Id, translated.CanonicalRecordingId);
Assert.Equal("3135556", translated.Target!.ExternalId.Value);
await using var context = await _factory.CreateDbContextAsync();
var links = await context.ProviderTrackIdentities.ToListAsync();
Assert.Equal(3, links.Count);
Assert.All(links, link => Assert.Matches("^[0-9a-f]{64}$", link.ExternalIdHash));
Assert.Single(await context.CanonicalRecordings.ToListAsync());
var aliases = await context.CanonicalCatalogAliases
.Where(item => item.EntityKind == CanonicalCatalogEntityKind.Recording)
.ToListAsync();
Assert.Equal(4, aliases.Count);
Assert.Equal(
["3135556", "qobuz-track-9", "spotify-track-1"],
aliases.Where(item => item.Namespace.StartsWith("provider:", StringComparison.Ordinal))
.Select(item => item.ExternalId)
.Order()
.ToArray());
Assert.All(aliases, alias => Assert.Equal(recording.Recording.Id, alias.CanonicalEntityId));
}
[Fact]
public async Task BatchResolution_PrefersAuthorizedAccountScopeWithoutLeakingOtherAccounts()
{
var actorA = Actor(_tenantA, _userA);
var actorB = Actor(_tenantA, _userB);
var accountA = await SeedUserAccount("deezer", _tenantA, _userA);
var accountB = await SeedUserAccount("deezer", _tenantA, _userB);
var catalogRecording = (await _service.CreateRecordingAsync(actorA, "catalog-recording")).Recording.Id;
var accountRecording = (await _service.CreateRecordingAsync(actorA, "account-recording")).Recording.Id;
var catalogContext = Context(actorA, "deezer");
var contextA = Context(actorA, "deezer", accountA);
var contextB = Context(actorB, "deezer", accountB);
await Link(catalogRecording, catalogContext, "shared-track");
await Link(accountRecording, contextA, "shared-track", ProviderIdentityScope.Account);
var forA = await _service.ResolveManyAsync([
new(contextA, Track("deezer", "shared-track")),
new(contextA, Track("deezer", "missing-track"))]);
var forB = await _service.ResolveManyAsync([
new(contextB, Track("deezer", "shared-track"))]);
Assert.Equal(accountRecording, forA[0]?.CanonicalRecordingId);
Assert.Null(forA[1]);
Assert.Equal(catalogRecording, forB[0]?.CanonicalRecordingId);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_service.ResolveManyAsync([
new(contextA, Track("deezer", "shared-track")),
new(contextB, Track("deezer", "shared-track"))]));
}
[Fact]
public async Task RecordingWithMusicBrainzIdentity_QueuesIdempotentCatalogDiscovery()
{
const string recordingMbid = "11111111-1111-4111-8111-111111111111";
var actor = Actor(_tenantA, _userA);
var queue = new Mock<IMusicBrainzCatalogRefreshQueue>(MockBehavior.Strict);
queue.Setup(item => item.EnqueueRecordingAsync(
actor,
recordingMbid,
"catalog-discovery",
It.IsAny<CancellationToken>()))
.ReturnsAsync(new DurableJobEnqueueResult(Guid.CreateVersion7(), true));
var service = new TrackIdentityService(_factory, _storageState, _clock, queue.Object);
var first = await service.CreateRecordingAsync(
actor, "catalog-discovery", musicBrainzRecordingId: recordingMbid);
var repeated = await service.CreateRecordingAsync(
actor, "catalog-discovery", musicBrainzRecordingId: recordingMbid);
Assert.True(first.Created);
Assert.False(repeated.Created);
Assert.Equal(first.Recording.Id, repeated.Recording.Id);
queue.Verify(item => item.EnqueueRecordingAsync(
actor,
recordingMbid,
"catalog-discovery",
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task MissingVerifiedLink_RemainsUnresolvedAndNeverGuesses()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(actor, "no-guess-create");
var spotify = Context(actor, "spotify");
var deezer = Context(actor, "deezer");
await Link(recording.Recording.Id, spotify, "same title by same artist");
var missingTarget = await _service.TranslateAsync(
spotify,
Track("spotify", "same title by same artist"),
deezer,
new ProviderTrackIdentityTarget("deezer"));
var missingSource = await _service.TranslateAsync(
spotify,
Track("spotify", "unlinked source"),
deezer,
new ProviderTrackIdentityTarget("deezer"));
Assert.Equal(TrackIdentityTranslationStatus.TargetNotLinked, missingTarget.Status);
Assert.Equal(TrackIdentityTranslationStatus.SourceNotLinked, missingSource.Status);
Assert.Null(missingTarget.Target);
Assert.Null(missingSource.CanonicalRecordingId);
}
[Fact]
public async Task ExactIdentityConflict_DoesNotRemapExistingRecording()
{
var actor = Actor(_tenantA, _userA);
var first = await _service.CreateRecordingAsync(actor, "conflict-first");
var second = await _service.CreateRecordingAsync(actor, "conflict-second");
var spotify = Context(actor, "spotify");
var externalId = "spotify-conflict-sensitive-id";
await Link(first.Recording.Id, spotify, externalId);
var conflict = await Link(second.Recording.Id, spotify, externalId);
Assert.Equal(TrackIdentityLinkStatus.Conflict, conflict.Status);
Assert.Equal(first.Recording.Id, conflict.ConflictingCanonicalRecordingId);
var resolution = await _service.ResolveAsync(spotify, Track("spotify", externalId));
Assert.Equal(first.Recording.Id, resolution!.CanonicalRecordingId);
await using var context = await _factory.CreateDbContextAsync();
Assert.Single(await context.ProviderTrackIdentities.ToListAsync());
var audit = await context.AuditEvents.SingleAsync(item => item.Outcome == "conflict");
Assert.DoesNotContain(externalId, audit.DetailsJson, StringComparison.Ordinal);
}
[Fact]
public async Task RelinkingSameExactIdentity_IsIdempotent()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(actor, "idempotent-create");
var context = Context(actor, "deezer");
var created = await Link(recording.Recording.Id, context, "idempotent-id");
var existing = await Link(recording.Recording.Id, context, "idempotent-id");
Assert.Equal(TrackIdentityLinkStatus.Created, created.Status);
Assert.Equal(TrackIdentityLinkStatus.AlreadyLinked, existing.Status);
Assert.Equal(created.LinkId, existing.LinkId);
await using var database = await _factory.CreateDbContextAsync();
Assert.Single(await database.ProviderTrackIdentities.ToListAsync());
}
[Fact]
public async Task AccountScopedIdentity_IsPreferredButCannotCrossUserScope()
{
var account = await SeedUserAccount("spotify", _tenantA, _userA);
var actorA = Actor(_tenantA, _userA);
var catalogContext = Context(actorA, "spotify");
var accountContext = Context(actorA, "spotify", account);
var catalogRecording = await _service.CreateRecordingAsync(actorA, "catalog-recording");
var accountRecording = await _service.CreateRecordingAsync(actorA, "account-recording");
await Link(catalogRecording.Recording.Id, catalogContext, "overlapping-id");
await Link(
accountRecording.Recording.Id,
accountContext,
"overlapping-id",
ProviderIdentityScope.Account);
var catalogResolution = await _service.ResolveAsync(
catalogContext,
Track("spotify", "overlapping-id"));
var accountResolution = await _service.ResolveAsync(
accountContext,
Track("spotify", "overlapping-id"));
Assert.Equal(catalogRecording.Recording.Id, catalogResolution!.CanonicalRecordingId);
Assert.Equal(accountRecording.Recording.Id, accountResolution!.CanonicalRecordingId);
await using (var database = await _factory.CreateDbContextAsync())
{
var aliases = await database.CanonicalCatalogAliases
.Where(item => item.ExternalId == "overlapping-id")
.OrderBy(item => item.Namespace)
.ToListAsync();
Assert.Equal(2, aliases.Count);
Assert.Equal(2, aliases.Select(item => item.Namespace).Distinct().Count());
Assert.Contains(aliases, item => item.CanonicalEntityId == catalogRecording.Recording.Id);
Assert.Contains(aliases, item => item.CanonicalEntityId == accountRecording.Recording.Id);
}
var forgedSnapshot = new ProviderAccountContext(
account.Id,
account.ProviderId,
ProviderAccountScope.User,
account.Revision,
tenantId: _tenantA,
ownerUserId: _userB);
var forged = Context(Actor(_tenantA, _userB), "spotify", forgedSnapshot);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_service.ResolveAsync(forged, Track("spotify", "overlapping-id")));
}
[Fact]
public async Task SameExternalId_IsIsolatedByTenant()
{
var actorA = Actor(_tenantA, _userA);
var actorB = Actor(_tenantB, _userOtherTenant);
var recordingA = await _service.CreateRecordingAsync(actorA, "tenant-a-recording");
var recordingB = await _service.CreateRecordingAsync(actorB, "tenant-b-recording");
var contextA = Context(actorA, "qobuz");
var contextB = Context(actorB, "qobuz");
await Link(recordingA.Recording.Id, contextA, "shared-provider-id");
await Link(recordingB.Recording.Id, contextB, "shared-provider-id");
var resolvedA = await _service.ResolveAsync(contextA, Track("qobuz", "shared-provider-id"));
var resolvedB = await _service.ResolveAsync(contextB, Track("qobuz", "shared-provider-id"));
Assert.Equal(recordingA.Recording.Id, resolvedA!.CanonicalRecordingId);
Assert.Equal(recordingB.Recording.Id, resolvedB!.CanonicalRecordingId);
Assert.NotEqual(resolvedA.CanonicalRecordingId, resolvedB.CanonicalRecordingId);
}
[Fact]
public async Task CrossTenantCanonicalForeignKey_IsRejectedByDatabase()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(actor, "foreign-key-recording");
await using var context = await _factory.CreateDbContextAsync();
context.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
{
Id = Guid.CreateVersion7(),
TenantId = _tenantB,
CanonicalRecordingId = recording.Recording.Id,
ProviderId = "spotify",
ResourceKind = ProviderResourceKind.Track,
CatalogNamespace = "default",
Scope = ProviderIdentityScope.Catalog,
ExternalId = "cross-tenant-id",
ExternalIdHash = Hash("cross-tenant-id"),
Verification = ProviderIdentityVerification.Verified,
VerificationMethod = "fixture",
DecisionVersion = 1,
VerifiedAt = _clock.UtcNow,
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
});
await Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync());
}
[Fact]
public async Task StoredHashCollision_IsRejectedInsteadOfAccepted()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(actor, "collision-recording");
var requested = "requested-external-id";
await using (var context = await _factory.CreateDbContextAsync())
{
context.ProviderTrackIdentities.Add(new ProviderTrackIdentityRecord
{
Id = Guid.CreateVersion7(),
TenantId = _tenantA,
CanonicalRecordingId = recording.Recording.Id,
ProviderId = "spotify",
ResourceKind = ProviderResourceKind.Track,
CatalogNamespace = "default",
Scope = ProviderIdentityScope.Catalog,
ExternalId = "different-value-with-forced-hash",
ExternalIdHash = Hash(requested),
Verification = ProviderIdentityVerification.Verified,
VerificationMethod = "fixture",
DecisionVersion = 1,
VerifiedAt = _clock.UtcNow,
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
});
await context.SaveChangesAsync();
}
var execution = Context(actor, "spotify");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.ResolveAsync(execution, Track("spotify", requested)));
}
[Fact]
public async Task MultipleTargetIdsInSameScope_AreReportedAsAmbiguous()
{
var actor = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(actor, "ambiguous-target");
var spotify = Context(actor, "spotify");
var deezer = Context(actor, "deezer");
await Link(recording.Recording.Id, spotify, "source-id");
await Link(recording.Recording.Id, deezer, "target-one");
await Link(recording.Recording.Id, deezer, "target-two");
var translated = await _service.TranslateAsync(
spotify,
Track("spotify", "source-id"),
deezer,
new ProviderTrackIdentityTarget("deezer"));
Assert.Equal(TrackIdentityTranslationStatus.TargetAmbiguous, translated.Status);
Assert.Null(translated.Target);
}
[Fact]
public async Task CanonicalSignals_AreNormalizedReusedAndNeverSilentlyMerged()
{
var actor = Actor(_tenantA, _userA);
var mbid = Guid.NewGuid();
var first = await _service.CreateRecordingAsync(
actor,
"signals-first",
"US-RC1-76-07839",
mbid.ToString("B").ToUpperInvariant());
var reused = await _service.CreateRecordingAsync(
actor,
"signals-reused",
"usrc17607839",
mbid.ToString("D"));
Assert.False(reused.Created);
Assert.Equal(first.Recording.Id, reused.Recording.Id);
Assert.Equal("USRC17607839", reused.Recording.Isrc);
Assert.Equal(mbid.ToString("D"), reused.Recording.MusicBrainzRecordingId);
await using (var database = await _factory.CreateDbContextAsync())
{
var aliases = await database.CanonicalCatalogAliases
.Where(item => item.CanonicalEntityId == first.Recording.Id)
.OrderBy(item => item.Namespace)
.ToListAsync();
Assert.Collection(
aliases,
item =>
{
Assert.Equal("isrc", item.Namespace);
Assert.Equal("USRC17607839", item.ExternalId);
},
item =>
{
Assert.Equal("musicbrainz", item.Namespace);
Assert.Equal(mbid.ToString("D"), item.ExternalId);
});
Assert.False(await database.CanonicalRecordings
.Where(item => item.Id == first.Recording.Id)
.Select(item => item.IsProvisional)
.SingleAsync());
}
var isrcOnly = await _service.CreateRecordingAsync(
actor,
"signals-isrc-only",
"GBAYE6800011");
var otherMbid = Guid.NewGuid();
var mbidOnly = await _service.CreateRecordingAsync(
actor,
"signals-mbid-only",
musicBrainzRecordingId: otherMbid.ToString());
Assert.NotEqual(isrcOnly.Recording.Id, mbidOnly.Recording.Id);
await using (var database = await _factory.CreateDbContextAsync())
{
Assert.True(await database.CanonicalRecordings
.Where(item => item.Id == isrcOnly.Recording.Id)
.Select(item => item.IsProvisional)
.SingleAsync());
}
await Assert.ThrowsAsync<InvalidOperationException>(() => _service.CreateRecordingAsync(
actor,
"signals-conflict",
"GBAYE6800011",
otherMbid.ToString()));
}
[Fact]
public async Task AutomatedJob_CannotCreatePinnedIdentity()
{
var creator = Actor(_tenantA, _userA);
var recording = await _service.CreateRecordingAsync(creator, "pin-recording");
var jobActor = new ProviderActorContext(
_tenantA,
ProviderActorKind.SystemJob,
userId: null,
durableJobId: Guid.CreateVersion7());
var jobContext = Context(jobActor, "spotify");
var request = new TrackIdentityLinkRequest(
recording.Recording.Id,
Track("spotify", "pin-id"),
ProviderIdentityScope.Catalog,
ProviderIdentityVerification.Pinned,
"manual",
1);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_service.LinkAsync(jobContext, request));
}
[Fact]
public async Task DurableStorageOutage_BlocksIdentityReadsAndWrites()
{
var actor = Actor(_tenantA, _userA);
_storageState.Set(DurableStorageReadiness.Unavailable, errorCode: "fixture");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateRecordingAsync(actor, "storage-down"));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.ResolveAsync(Context(actor, "spotify"), Track("spotify", "track")));
}
private async Task<TrackIdentityLinkResult> Link(
Guid canonicalRecordingId,
ProviderExecutionContext context,
string externalId,
ProviderIdentityScope scope = ProviderIdentityScope.Catalog,
string? catalog = null) => await _service.LinkAsync(
context,
new TrackIdentityLinkRequest(
canonicalRecordingId,
Track(context.ProviderId, externalId, catalog),
scope,
ProviderIdentityVerification.Verified,
"exact-provider-id",
1));
private async Task<ProviderAccountRecord> SeedUserAccount(
string providerId,
Guid tenantId,
Guid userId)
{
var account = new ProviderAccountRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenantId,
OwnerUserId = userId,
ProviderId = providerId,
DisplayName = $"{providerId} personal",
Scope = ProviderAccountScope.User,
Enabled = true,
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
};
await using var context = await _factory.CreateDbContextAsync();
context.ProviderAccounts.Add(account);
await context.SaveChangesAsync();
return account;
}
private ProviderExecutionContext Context(
ProviderActorContext actor,
string providerId,
ProviderAccountRecord? account = null) => Context(
actor,
providerId,
account == null
? null
: new ProviderAccountContext(
account.Id,
account.ProviderId,
account.Scope,
account.Revision,
enabled: account.Enabled,
tenantId: account.TenantId,
ownerUserId: account.OwnerUserId,
libraryScopeId: account.LibraryScopeId));
private ProviderExecutionContext Context(
ProviderActorContext actor,
string providerId,
ProviderAccountContext? account)
{
var library = account?.Scope == ProviderAccountScope.Library
? new ProviderLibraryContext(actor.TenantId, account.LibraryScopeId!)
: null;
return new ProviderExecutionContext(
actor,
providerId,
account,
library,
new ProviderExecutionPolicy(
new ProviderQualityPolicy(
ProviderAudioQuality.Any,
ProviderAudioQuality.HighResolution,
allowTranscode: true),
ProviderExplicitContentPolicy.Allow,
allowFallback: true,
allowSharedAccount: true,
allowManagedDownloads: false,
allowedProviderIds: [providerId]),
operationId: $"identity-{providerId}",
correlationId: $"correlation-{providerId}",
deadline: _clock.UtcNow.AddMinutes(5),
cancellationToken: CancellationToken.None);
}
private static ProviderActorContext Actor(Guid tenantId, Guid userId) => new(
tenantId,
ProviderActorKind.User,
userId,
new ProviderBackendPrincipal("jellyfin", "fixture", userId.ToString("N")));
private static ProviderExternalResourceId Track(
string providerId,
string externalId,
string? catalog = null) => new(
providerId,
ProviderResourceKind.Track,
externalId,
catalog);
private static PlatformUserRecord User(
Guid id,
Guid tenantId,
string name,
DateTimeOffset now) => new()
{
Id = id,
TenantId = tenantId,
DisplayName = name,
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
};
private static string Hash(string value) => Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
public async Task DisposeAsync() => await _database.DisposeAsync();
private sealed class FakeClock(DateTimeOffset now) : IPlatformClock
{
public DateTimeOffset UtcNow { get; } = now;
}
private sealed class TestDbContextFactory(DbContextOptions<AllstarrDbContext> options)
: IDbContextFactory<AllstarrDbContext>
{
public AllstarrDbContext CreateDbContext() => new(options);
public Task<AllstarrDbContext> CreateDbContextAsync(
CancellationToken cancellationToken = default) => Task.FromResult(new AllstarrDbContext(options));
}
}