GetOrAddFolder was a check-then-insert with no unique constraint behind it, so two callers racing the same folder could both miss the lookup and both insert. Enforce identity in the schema and make the loser adopt the winner. - LibraryFolder gains a SHA-256 PathHash (the MediaFile.Path/PathHash precedent): Path is MySQL longtext, which cannot be indexed without a prefix length and collates case-insensitively, so the unique index is on (LibraryPathId, PathHash) instead. - GetOrAddFolder and SetEtag catch a classified unique violation via the existing TvContext.IsUniqueConstraintViolation seam (#308) and re-read. - Dual-provider migration audits and collapses pre-existing duplicates (repointing MediaFile, ParentId and ImageFolderDuration) before creating the index; legacy rows keep a null hash and heal on the next scan. - Tests: deterministic cross-connection race, 8x10 barrier stress with an insert-attempt vacuity guard, classifier-inversion negative control, and a real-migration dedupe test. Refs #488 #308 fix #491
166 lines
7.4 KiB
C#
166 lines
7.4 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|