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); } 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; } }