using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Plugin.RecentlyAddedGuard.Core; using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests; public sealed class LedgerStoreTests : IDisposable { private readonly GuardFixture _fx = new(); public void Dispose() => _fx.Dispose(); // 13 [Fact] public void SaveAndLoad_RestoresIdenticalState() { var series = Guid.NewGuid(); var movieItem = Guid.NewGuid(); var episodeItems = new[] { Guid.NewGuid(), Guid.NewGuid() }; var removedItem = Guid.NewGuid(); _fx.Reconciler.Register(Media.Movie(movieItem, _fx.Now.AddDays(-30), tmdb: "603", imdb: "tt0133093")); _fx.Reconciler.Register(Media.Episode(episodeItems[0], _fx.Now.AddDays(-7), series, 2, 3, seriesTvdb: "393189")); _fx.Reconciler.Register(Media.Episode(episodeItems[1], _fx.Now.AddDays(-1), series, 2, 3, seriesTvdb: "393189")); var removed = Media.Movie(removedItem, _fx.Now.AddDays(-90), tmdb: "604"); _fx.Reconciler.Register(removed); _fx.Reconciler.OnItemRemoved(removedItem, removed.Keys); _fx.Store.MarkSynced(_fx.Now); _fx.Store.Flush(); using var reloaded = _fx.CreateStore(); reloaded.Load(); Assert.True(reloaded.SeedCompleted); Assert.Equal(_fx.Now, reloaded.LastSyncUtc); Assert.Equal(_fx.Store.GetStatistics(), reloaded.GetStatistics()); var expected = _fx.Store.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList(); var actual = reloaded.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList(); Assert.Equal(expected.Count, actual.Count); for (var i = 0; i < expected.Count; i++) { Assert.Equal(expected[i].Keys, actual[i].Keys); Assert.Equal(expected[i].FirstAdded, actual[i].FirstAdded); Assert.Equal(DateTimeKind.Utc, actual[i].FirstAdded.Kind); Assert.Equal(expected[i].LastSeen, actual[i].LastSeen); Assert.Equal(expected[i].RemovedAt, actual[i].RemovedAt); Assert.Equal(expected[i].LiveItemIds.Order(), actual[i].LiveItemIds.Order()); Assert.Equal(expected[i].DisplayName, actual[i].DisplayName); } // Indexes are rebuilt as well. Assert.Equal(2, Assert.Single(reloaded.FindByKeys(["ep:tvdb:393189:s2e3"])).LiveItemIds.Count); Assert.NotNull(reloaded.FindByItemId(movieItem)); Assert.Null(reloaded.FindByItemId(removedItem)); Assert.NotNull(Assert.Single(reloaded.FindByKeys(["movie:tmdb:604"])).RemovedAt); } [Fact] public void Flush_ReplacesFileAtomically_WithoutLeavingTempFile() { _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603")); _fx.Store.Flush(); _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "604")); _fx.Store.Flush(); Assert.False(File.Exists(_fx.LedgerPath + ".tmp")); using var document = JsonDocument.Parse(File.ReadAllBytes(_fx.LedgerPath)); Assert.Equal(2, document.RootElement.GetProperty("Entries").GetArrayLength()); } [Fact] public void Load_IgnoresLeftoverTempFileOfInterruptedWrite() { _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603")); _fx.Store.Flush(); File.WriteAllText(_fx.LedgerPath + ".tmp", "{ \"Entries\": [ { \"Keys\": [\"half-writ"); using var reloaded = _fx.CreateStore(); reloaded.Load(); Assert.Single(reloaded.GetEntries()); Assert.False(File.Exists(_fx.LedgerPath + ".tmp")); } [Fact] public void Load_MovesUnreadableFileAside_AndStartsEmpty() { Directory.CreateDirectory(_fx.DirectoryPath); File.WriteAllText(_fx.LedgerPath, "{ not json"); using var store = _fx.CreateStore(); store.Load(); Assert.Empty(store.GetEntries()); Assert.False(store.SeedCompleted); Assert.False(File.Exists(_fx.LedgerPath)); Assert.Single(Directory.GetFiles(_fx.DirectoryPath, LedgerStore.DefaultFileName + ".corrupt-*")); } [Fact] public void Load_WithoutFile_YieldsEmptyLedger() { using var store = _fx.CreateStore(); store.Load(); Assert.Empty(store.GetEntries()); Assert.False(store.SeedCompleted); } [Fact] public void Keys_AreMatchedCaseInsensitively() { _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, imdb: "tt0133093")); Assert.Single(_fx.Store.FindByKeys(["movie:imdb:TT0133093"])); } [Fact] public async Task MarkDirty_IsDebounced_AndWritesAfterTheDelay() { var path = Path.Combine(_fx.DirectoryPath, "debounced.json"); using var store = new LedgerStore(path, NullLogger.Instance, TimeProvider.System, TimeSpan.FromMilliseconds(100)); var reconciler = new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger.Instance); for (var i = 0; i < 50; i++) { reconciler.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: i.ToString(System.Globalization.CultureInfo.InvariantCulture))); } Assert.False(File.Exists(path)); var stopwatch = Stopwatch.StartNew(); while (!File.Exists(path) && stopwatch.Elapsed < TimeSpan.FromSeconds(10)) { await Task.Delay(25, TestContext.Current.CancellationToken); } Assert.True(File.Exists(path)); using var document = JsonDocument.Parse(File.ReadAllBytes(path)); Assert.Equal(50, document.RootElement.GetProperty("Entries").GetArrayLength()); } [Fact] public void ConcurrentEvents_KeepLedgerAndIndexesConsistent() { var itemIds = Enumerable.Range(0, 4000).Select(_ => Guid.NewGuid()).ToArray(); Parallel.For(0, itemIds.Length, i => { var tmdb = (i % 500).ToString(System.Globalization.CultureInfo.InvariantCulture); var movie = Media.Movie(itemIds[i], _fx.Now.AddMinutes(-i), tmdb: tmdb); _fx.Reconciler.OnItemSeen(movie); if (i % 3 == 0) { _fx.Reconciler.OnItemRemoved(itemIds[i], movie.Keys); } }); var entries = _fx.Store.GetEntries(); Assert.Equal(500, entries.Count); var live = itemIds.Where((_, i) => i % 3 != 0).ToArray(); Assert.Equal(live.Length, entries.Sum(e => e.LiveItemIds.Count)); Assert.All(live, id => Assert.NotNull(_fx.Store.FindByItemId(id))); Assert.All(entries, e => Assert.Equal(e.Keys.Length, e.Keys.Distinct(StringComparer.OrdinalIgnoreCase).Count())); } [Fact] public void Dispose_FlushesPendingChanges() { var path = Path.Combine(_fx.DirectoryPath, "dispose.json"); var store = new LedgerStore(path, NullLogger.Instance, TimeProvider.System, TimeSpan.FromHours(1)); new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger.Instance) .Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: "603")); store.Dispose(); Assert.True(File.Exists(path)); } [Fact] public void Load_MergesOverlappingEntriesOfADamagedFile() { var item = Guid.NewGuid(); Directory.CreateDirectory(_fx.DirectoryPath); File.WriteAllText( _fx.LedgerPath, $$""" {"SchemaVersion":1,"SeedCompleted":true,"Entries":[ {"Keys":["movie:tmdb:603"],"FirstAdded":"2025-05-01T00:00:00Z","LastSeen":"2026-01-01T00:00:00Z","LiveItemIds":["{{item}}"],"DisplayName":"A"}, {"Keys":["movie:tmdb:603","movie:imdb:tt0133093"],"FirstAdded":"2024-05-01T00:00:00Z","LastSeen":"2025-01-01T00:00:00Z","RemovedAt":"2025-02-01T00:00:00Z","LiveItemIds":[],"DisplayName":"B"} ]} """); using var store = _fx.CreateStore(); store.Load(); var entry = Assert.Single(store.GetEntries()); Assert.Equal(new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc), entry.FirstAdded); Assert.Equal(2, entry.Keys.Length); Assert.Null(entry.RemovedAt); Assert.Same(entry, store.FindByItemId(item)); } }