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(), _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.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 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.None, "/data/music/artist1"); LibraryFolder second = await _repository.GetOrAddFolder(libraryPath, Option.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.None, "/data/music"); // first pass creates the child with no parent LibraryFolder child = await _repository.GetOrAddFolder(libraryPath, Option.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.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.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.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.None, "/data/music/artist1"); LibraryFolder second = await _repository.GetOrAddFolder(libraryPath, Option.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 { 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 { 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 { 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.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.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 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; } }