using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.Search; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Sqlite.Data; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using NSubstitute; using NUnit.Framework; using Shouldly; using Unit = LanguageExt.Unit; namespace ErsatzTV.Tests.Application.MediaCollections; /// /// #308: two concurrent adds of the same item both membership-check it absent, both insert the same /// CollectionItem composite key, and the loser used to 500 on the unhandled /// (SQLite 19 / MySQL 1062) that SaveChangesForcingVersion /// does not catch. The fix (TrySaveChangesForcingVersion) makes the loser an idempotent no-op /// for the single-item handlers, and for the bulk handler retries against fresh membership so the /// non-colliding items are not dropped. /// [TestFixture] public class AddToCollectionIdempotencyConcurrencyTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private static async Task SeedCollectionWithMovies(Func createContext, int collectionId, params int[] movieIds) { await using TvContext context = createContext(); var library = new LocalLibrary { Id = 1, Name = "Library", MediaKind = LibraryMediaKind.Movies, Paths = [] }; var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] }; library.Paths.Add(path); foreach (int movieId in movieIds) { context.Movies.Add(new Movie { Id = movieId, LibraryPath = path, Collections = [], CollectionItems = [], TraktListItems = [], MovieMetadata = [ new MovieMetadata { Title = $"Movie {movieId}", SortTitle = $"Movie {movieId}", Artwork = [], Genres = [], Tags = [], Studios = [], Actors = [], Guids = [], Subtitles = [], Directors = [], Writers = [] } ], MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }] }); } context.Collections.Add(new Collection { Id = collectionId, Name = "Manual", Version = 1, MediaItems = [], CollectionItems = [] }); await context.SaveChangesAsync(); } private static async Task CollectionItemCount(Func createContext, int collectionId, int mediaItemId) { await using TvContext context = createContext(); return await context.CollectionItems .CountAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == mediaItemId); } private static (IMediaCollectionRepository repo, Channel worker, ChannelWriter search) Deps() { IMediaCollectionRepository repo = Substitute.For(); repo.PlayoutIdsUsingCollection(Arg.Any()).Returns([1]); Channel worker = System.Threading.Channels.Channel.CreateUnbounded(); ChannelWriter search = System.Threading.Channels.Channel.CreateUnbounded().Writer; return (repo, worker, search); } // Simulates the concurrent "winner": exactly once, on a SEPARATE connection, insert the same composite // key and commit — AFTER the intercepted context has read (stale) membership but BEFORE its own INSERT // runs. This is what interposes the race deterministically. private sealed class InsertConflictingItemOnce(SharedCacheTvContext db, int collectionId, int mediaItemId) : SaveChangesInterceptor { private int _fired; public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _fired, 1) == 0) { await using SqliteConnection connection = db.OpenConnection(); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "INSERT INTO \"CollectionItem\" (\"CollectionId\", \"MediaItemId\", \"CustomIndex\") " + $"VALUES ({collectionId}, {mediaItemId}, NULL)"; await command.ExecuteNonQueryAsync(cancellationToken); } return result; } } // ----- Non-vacuous anchor + extension-level unit tests (single shared connection) ----- // Proves the race really does throw a DbUpdateException the classifier recognizes — i.e. the pre-fix // 500 was real and the fix's catch filter is not a no-op. This is the negative control anchoring the // extension tests below. [Test] public async Task Duplicate_CollectionItem_Insert_Throws_A_Classified_UniqueViolation() { await SeedCollectionWithMovies(_db.CreateContext, 1, 10); await using TvContext stale = _db.CreateContext(); Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1); Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10); // A racer inserts the (1, 10) CollectionItem and commits, then the stale context tries the same key. await InsertRow(_db.CreateContext, 1, 10); tracked.MediaItems.Add(movie); DbUpdateException ex = await Should.ThrowAsync(() => stale.SaveChangesAsync()); SqliteErrorClassifier.IsUniqueConstraintViolation(ex).ShouldBeTrue(); // And the classifier does not blanket-return true for an unrelated failure. SqliteErrorClassifier.IsUniqueConstraintViolation( new DbUpdateException("nope", new InvalidOperationException())).ShouldBeFalse(); } [Test] public async Task TrySaveChangesForcingVersion_Swallows_The_Duplicate_And_Returns_False() { await SeedCollectionWithMovies(_db.CreateContext, 1, 10); await using TvContext stale = _db.CreateContext(); Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1); Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10); await InsertRow(_db.CreateContext, 1, 10); tracked.MediaItems.Add(movie); tracked.Version++; bool committed = await stale.TrySaveChangesForcingVersion(CancellationToken.None); committed.ShouldBeFalse(); // the loser's save was swallowed, not thrown (await CollectionItemCount(_db.CreateContext, 1, 10)).ShouldBe(1); // exactly one row, no corruption } [Test] public async Task TrySaveChangesForcingVersion_Rethrows_When_The_Provider_Does_Not_Classify_It() { // Guard against an over-broad catch: with the classifier saying "not a unique violation", the // duplicate must still propagate rather than be silently swallowed. Func original = TvContext.IsUniqueConstraintViolation; try { await SeedCollectionWithMovies(_db.CreateContext, 1, 10); await using TvContext stale = _db.CreateContext(); Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1); Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10); await InsertRow(_db.CreateContext, 1, 10); tracked.MediaItems.Add(movie); tracked.Version++; TvContext.IsUniqueConstraintViolation = _ => false; await Should.ThrowAsync(() => stale.TrySaveChangesForcingVersion(CancellationToken.None)); } finally { TvContext.IsUniqueConstraintViolation = original; } } // ----- End-to-end handler tests over a real cross-connection race (shared cache) ----- [Test] public async Task SingleItem_Handler_Losing_The_Cross_Connection_Race_Is_A_NoOp_Not_A_500() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-single"); await SeedCollectionWithMovies(db.CreateContext, 1, 10); (IMediaCollectionRepository repo, Channel worker, ChannelWriter search) = Deps(); // The interceptor inserts (1, 10) on another connection during this handler's save. var racer = new InsertConflictingItemOnce(db, 1, 10); var handler = new AddMovieToCollectionHandler(db.Factory(racer), repo, worker.Writer, search); Either result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None); result.IsRight.ShouldBeTrue(); // no 500 (await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // one row, no duplicate worker.Reader.Count.ShouldBe(0); // the loser does not fan out a rebuild (the winner already did) } [Test] public async Task Bulk_Handler_Losing_The_Cross_Connection_Race_Retries_And_Keeps_NonColliding_Items() { await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-bulk"); await SeedCollectionWithMovies(db.CreateContext, 1, 10, 11); (IMediaCollectionRepository repo, Channel worker, ChannelWriter search) = Deps(); var movieRepo = Substitute.For(); movieRepo.AllMoviesExist(Arg.Any>()).Returns(true); var tvRepo = Substitute.For(); tvRepo.AllShowsExist(Arg.Any>()).Returns(true); tvRepo.AllSeasonsExist(Arg.Any>()).Returns(true); tvRepo.AllEpisodesExist(Arg.Any>()).Returns(true); // Item 10 is inserted by the racer during the first attempt's save, colliding and rolling back the // whole [10, 11] batch; the retry must recompute membership and still add 11. var racer = new InsertConflictingItemOnce(db, 1, 10); var handler = new AddItemsToCollectionHandler(db.Factory(racer), repo, movieRepo, tvRepo, worker.Writer, search); var request = new AddItemsToCollection(1, [10, 11], [], [], [], [], [], [], [], [], []); Either result = await handler.Handle(request, CancellationToken.None); result.IsRight.ShouldBeTrue(); (await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // the racer's row — not dropped/duped (await CollectionItemCount(db.CreateContext, 1, 11)).ShouldBe(1); // NOT dropped by the rolled-back attempt } private static async Task InsertRow(Func createContext, int collectionId, int mediaItemId) { await using TvContext context = createContext(); context.Add(new CollectionItem { CollectionId = collectionId, MediaItemId = mediaItemId, CustomIndex = null }); await context.SaveChangesAsync(); } }