Files
ersatztv/ErsatzTV.Tests/Integration/LibraryRepositoryTests.cs
T
timothy 83cd36e0de test(491): run the dedupe fixture against MySql in CI; correct the collation claim
The dedupe DML had zero automated coverage on MySql: the migrations job only
applies migrations to a fresh EMPTY database, so no dedupe row ever executed
there. Two MySql-only collation defects escaped that gate in this session and
were caught only by hand-run containers.

Parameterize LibraryFolderDedupeMigrationTests over both providers from ONE
fixture body - same seeded rows, same expected survivors - rather than adding a
MySql-only copy that would drift and recreate the gap. Assertions no longer use
WHERE Path = '...', which is itself collation-dependent and would quietly mean
something different per provider; rows are read once and compared ordinally in
memory. A new step in the existing migrations job runs it against that job's
mysql:8.4 service, on a per-test database of its own.

Proven red when the collation is wrong: restoring COLLATE utf8mb4_bin fails the
MySql half with survivors [1,4,5,6,7,9] - the trailing-space sibling deleted -
while SQLite stays green. Proven non-skippable: without
ETV_TEST_MYSQL_CONNECTION the fixture ignores visibly, and with
ETV_REQUIRE_MYSQL_TESTS=1 (which CI sets) that skip becomes a hard failure, so
it cannot pass having connected to nothing. Local runs need no MySql.

Also correct an overstated comment. The schema pins only the utf8mb4 charset,
never a collation, so the effective comparison is the server default: always
case-insensitive, but PAD SPACE only on utf8mb4_general_ci - 8.4's default
utf8mb4_0900_ai_ci is NO PAD, verified on the real column. The migration bug was
independent of that because the old code applied an EXPLICIT utf8mb4_bin, which
is PAD SPACE everywhere; the runtime simply tolerates both.

Refs #488 #308
fix #491
2026-07-25 21:13:31 +02:00

337 lines
16 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using IFileSystem = System.IO.Abstractions.IFileSystem;
namespace ErsatzTV.Tests.Integration;
[TestFixture]
public class LibraryRepositoryTests
{
private InMemoryTvContext _db = null!;
private LibraryRepository _repository = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_repository = new LibraryRepository(Substitute.For<IFileSystem>(), _db.Factory);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
// Regression for ersatztv#488: the Jellyfin (remote) sync path takes its LibraryPath straight off the
// JellyfinLibrary entity, so LibraryPath.LibraryFolders is never eager-loaded (null). GetOrAddFolder
// used to read that navigation collection directly and threw ArgumentNullException on the very first
// item of every Jellyfin music-video scan. The repository must resolve the folder from the database
// instead, so an unloaded collection is not a precondition.
[Test]
public async Task GetOrAddFolder_Should_Create_Folder_When_LibraryFolders_Not_Loaded()
{
int libraryPathId = await SeedLibraryPath("/data/music");
// mimic the Jellyfin path: Paths is populated, but LibraryFolders was never included
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
LibraryFolder result =
await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.ShouldNotBeNull();
result.Id.ShouldBeGreaterThan(0);
result.Path.ShouldBe("/data/music/artist1");
result.LibraryPathId.ShouldBe(libraryPathId);
await using TvContext context = _db.CreateContext();
List<LibraryFolder> folders = await context.LibraryFolders
.Where(f => f.LibraryPathId == libraryPathId)
.ToListAsync();
folders.Count.ShouldBe(1);
folders[0].Path.ShouldBe("/data/music/artist1");
}
// Re-scanning must be idempotent: a second GetOrAddFolder for the same path returns the existing row
// rather than inserting a duplicate LibraryFolder (there is no unique constraint behind it).
[Test]
public async Task GetOrAddFolder_Should_Be_Idempotent_On_Rescan()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
LibraryFolder first =
await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
LibraryFolder second =
await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
second.Id.ShouldBe(first.Id);
await using TvContext context = _db.CreateContext();
int count = await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId);
count.ShouldBe(1);
}
// Covers the maybeParentFolder = Some(...) branch: on a folder already in the db, the parent id is
// persisted through the raw Dapper UPDATE against the no-tracking entity (not change tracking), and the
// returned object reflects it. Guards the AsNoTracking + raw-UPDATE interaction the DB-lookup fix relies on.
[Test]
public async Task GetOrAddFolder_Should_Persist_ParentId_On_Existing_Folder()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
LibraryFolder parent =
await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music");
// first pass creates the child with no parent
LibraryFolder child =
await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
child.ParentId.ShouldBeNull();
// second pass supplies the parent — the existing row must be updated, not duplicated
LibraryFolder updated =
await _repository.GetOrAddFolder(libraryPath, Option<int>.Some(parent.Id), "/data/music/artist1");
updated.Id.ShouldBe(child.Id);
updated.ParentId.ShouldBe(parent.Id);
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == child.Id);
persisted.ParentId.ShouldBe(parent.Id);
int count = await context.LibraryFolders.CountAsync(f => f.Path == "/data/music/artist1");
count.ShouldBe(1);
}
// ersatztv#491: the unique index is on (LibraryPathId, PathHash) because Path is unbounded, so every
// new row must carry the hash or the constraint is unenforceable for it.
[Test]
public async Task GetOrAddFolder_Should_Populate_PathHash_On_New_Folder()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == result.Id);
persisted.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
}
// ersatztv#491: rows that predate the PathHash column are left null by the migration (nulls are
// distinct in a unique index, so the index applies cleanly); the first scan that touches one heals it.
[Test]
public async Task GetOrAddFolder_Should_Heal_A_Legacy_Null_PathHash()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int legacyId;
await using (TvContext seed = _db.CreateContext())
{
var legacy = new LibraryFolder
{
LibraryPathId = libraryPathId, Path = "/data/music/artist1", PathHash = null
};
await seed.LibraryFolders.AddAsync(legacy);
await seed.SaveChangesAsync();
legacyId = legacy.Id;
}
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.Id.ShouldBe(legacyId);
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == legacyId);
persisted.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(1);
}
// ersatztv#491 / H1: when the lookup matches more than one row (legacy duplicates that predate the
// unique index, which is exactly the state the migration cleans up), it must resolve deterministically
// to the lowest Id. An unordered FirstOrDefault can return a different row as the plan changes — and
// adding the composite index alone can flip it — which would make the PathHash heal non-idempotent:
// each scan would heal a different row and the second would collide on (LibraryPathId, PathHash).
[Test]
public async Task GetOrAddFolder_Should_Resolve_Legacy_Duplicates_Deterministically()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int firstId;
await using (TvContext seed = _db.CreateContext())
{
var a = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
var b = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
await seed.LibraryFolders.AddRangeAsync(a, b);
await seed.SaveChangesAsync();
firstId = Math.Min(a.Id, b.Id);
}
// repeated calls must agree, and must agree with MIN(Id) — the same row the migration keeps
LibraryFolder first = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
LibraryFolder second = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
first.Id.ShouldBe(firstId);
second.Id.ShouldBe(firstId);
await using TvContext context = _db.CreateContext();
// exactly one of the two got the hash — the heal is idempotent, not alternating
(await context.LibraryFolders.CountAsync(
f => f.LibraryPathId == libraryPathId && f.PathHash != null)).ShouldBe(1);
LibraryFolder healed = await context.LibraryFolders.SingleAsync(f => f.Id == firstId);
healed.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
}
// ersatztv#491 / H1: on MySQL the lookup's SQL equality is case-INsensitive (longtext under the
// default collation), so it hands back BOTH "/x/Foo" and "/x/foo" for a "/x/foo" scan — verified on a
// real MySQL 8.4 server, where an unordered LIMIT 1 returns "/x/Foo". PathHash is a case-SENSITIVE
// hash, so identity has to be settled ordinally or the heal stamps the wrong path's hash onto a row.
//
// This pins that decision with no database at all, because no SQLite-backed test can: SQLite's `=` on
// TEXT is binary, so the case-differing candidate never reaches the in-memory step. Feeding the
// candidate list directly is the only way to exercise it in CI.
[Test]
public void ResolveExact_Should_Pick_The_Ordinal_Match_Not_A_Case_Variant()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 10, Path = "/x/Foo" },
new() { Id = 11, Path = "/x/foo" }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(11);
LibraryRepository.ResolveExact(candidates, "/x/Foo").Id.ShouldBe(10);
// a spelling that matches nothing ordinally is absent, not "close enough"
LibraryRepository.ResolveExact(candidates, "/x/FOO").ShouldBeNull();
LibraryRepository.ResolveExact([], "/x/foo").ShouldBeNull();
}
// MySQL's comparison is always case-insensitive and, on a PAD SPACE collation, also ignores trailing
// spaces ('/x/foo' = '/x/foo ' is TRUE under utf8mb4_general_ci and utf8mb4_bin; 8.4's default
// utf8mb4_0900_ai_ci is NO PAD, so this axis is server-dependent). Where it applies, the SQL narrowing
// hands back trailing-space siblings too. Those are distinct directories on Linux and hash
// differently, so the ordinal settle has to keep them apart — same defect class as the case variant,
// second axis. Ordinal compares length first, so this holds; pin it.
[Test]
public void ResolveExact_Should_Distinguish_Paths_Differing_Only_In_Trailing_Space()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 20, Path = "/x/foo" },
new() { Id = 21, Path = "/x/foo " }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(20);
LibraryRepository.ResolveExact(candidates, "/x/foo ").Id.ShouldBe(21);
LibraryRepository.ResolveExact(candidates, "/x/foo ").ShouldBeNull();
// the two spellings must also hash differently, or the unique index would reject one of them and
// the migration's decision to keep both would be wrong
PathUtils.GetPathHash("/x/foo").ShouldNotBe(PathUtils.GetPathHash("/x/foo "));
}
// Candidates arrive ordered by Id, and the first ordinal match wins — so true duplicates resolve to
// MIN(Id), the same row the #491 migration keeps.
[Test]
public void ResolveExact_Should_Prefer_The_First_Candidate_On_A_True_Duplicate()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 3, Path = "/x/Foo" },
new() { Id = 7, Path = "/x/foo" },
new() { Id = 9, Path = "/x/foo" }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(7);
}
// End-to-end companion to the ResolveExact tests above. On SQLite this passes on pre-fix code too (the
// SQL equality already excludes the case variant); its value is guarding the in-memory step against
// later being relaxed to OrdinalIgnoreCase, and covering the heal/no-duplicate behaviour around it.
[Test]
public async Task GetOrAddFolder_Should_Not_Resolve_A_Folder_Differing_Only_In_Case()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int upperId;
await using (TvContext seed = _db.CreateContext())
{
var upper = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/Foo" };
var lower = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/foo" };
await seed.LibraryFolders.AddRangeAsync(upper, lower);
await seed.SaveChangesAsync();
upperId = upper.Id;
}
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/foo");
result.Path.ShouldBe("/data/music/foo");
result.Id.ShouldNotBe(upperId);
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/foo"));
await using TvContext context = _db.CreateContext();
// the case-differing sibling must be untouched — no foreign hash stamped onto it
LibraryFolder upperPersisted = await context.LibraryFolders.SingleAsync(f => f.Id == upperId);
upperPersisted.Path.ShouldBe("/data/music/Foo");
upperPersisted.PathHash.ShouldBeNull();
// and no duplicate was inserted for either spelling
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(2);
}
// The heal is opportunistic maintenance on a hot scan path: if some other row already owns
// (LibraryPathId, hash) — a legacy duplicate the migration's grouping could not see — it must leave
// the row unhealed rather than abort the scan.
[Test]
public async Task GetOrAddFolder_Should_Not_Fail_The_Scan_When_The_PathHash_Heal_Collides()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
string hash = PathUtils.GetPathHash("/data/music/artist1");
int legacyId;
await using (TvContext seed = _db.CreateContext())
{
// a legacy row with a null hash, plus a squatter that already owns the hash it would heal to
var legacy = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
var squatter = new LibraryFolder
{
LibraryPathId = libraryPathId, Path = "/data/music/squatter", PathHash = hash
};
await seed.LibraryFolders.AddRangeAsync(legacy, squatter);
await seed.SaveChangesAsync();
legacyId = legacy.Id;
}
// must not throw — the scan continues and simply returns the folder
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.Id.ShouldBe(legacyId);
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == legacyId);
persisted.PathHash.ShouldBeNull(); // heal declined, not half-applied
}
private async Task<int> SeedLibraryPath(string path)
{
await using TvContext context = _db.CreateContext();
var libraryPath = new LibraryPath { Path = path };
await context.LibraryPaths.AddAsync(libraryPath);
await context.SaveChangesAsync();
return libraryPath.Id;
}
}