Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GetOrAddFolder read libraryPath.LibraryFolders, a navigation collection only
eager-loaded on the local scan path (via GetLibrary). The Jellyfin remote sync
path takes its LibraryPath off the JellyfinLibrary entity, where LibraryFolders
is null, so .Filter(null) threw ArgumentNullException('source') on the first
item of every Jellyfin music-video scan — a feature that had therefore never
run in prod, CI, or locally.
Look the folder up from the DB by (LibraryPathId, Path) instead, removing the
implicit eager-load contract entirely (correct for all nine callers) and
documenting it on ILibraryRepository. null != empty is preserved so a re-scan
does not insert duplicate LibraryFolder rows. No new hot-path cost: local
scanners already query GetParentFolderId per folder just before this call.
Tests:
- LibraryRepositoryTests: GetOrAddFolder with a null LibraryFolders (the exact
remote-path shape) creates the folder, is idempotent on re-scan, and persists
a supplied ParentId.
- JellyfinMusicVideoLibraryScannerTests: an end-to-end scan of one synthetic
music video, wiring the REAL LibraryRepository/ArtistRepository/
MusicVideoRepository against in-memory SQLite (the existing MediaServer*
scanner tests mock every repo, which is why the bug escaped), asserts the scan
completes and creates Artist + MusicVideo rows with a real LibraryFolder.
Both proven non-vacuous against the reverted fix (each reproduces the issue's
ArgumentNullException). decisions.md entry added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
5.1 KiB
C#
117 lines
5.1 KiB
C#
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|