using System; using System.IO; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.JRay.Configuration; using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Services; using MediaBrowser.Common.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Plugin.JRay.Tests; /// /// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and /// they are not interchangeable. A locally computed sidecar and a /// loose-tier manifest from a third-party server make claims of very /// different strength about the same item, and the truth file itself records /// nothing about how it arrived. /// /// TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010 /// public class ManagedTruthStoreTests : IDisposable { private readonly string _root; private readonly ManagedTruthStore _store; public ManagedTruthStoreTests() { _root = Path.Combine(Path.GetTempPath(), "jray-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); _store = new ManagedTruthStore(new FakePaths(_root), NullLogger.Instance); } public void Dispose() { GC.SuppressFinalize(this); if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } private static TruthFile Truth() { var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion, Movie = "/m.mkv" }; var actor = new TruthActor { Name = "A", TmdbId = "884" }; actor.Scenes.Add(new TruthScene { Start = 1.0, End = 2.0 }); truth.Actors.Add(actor); return truth; } // UT-024 [Fact] public async Task SaveAsync_ThenLoadProvenance_RoundTripsAFetchedClaim() { var id = Guid.NewGuid(); var recorded = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); await _store.SaveAsync( id, Truth(), new TruthProvenance { Source = TruthSource.Fetched, ServerUrl = "https://jray.example", MatchTier = MatchTier.Loose, OffsetSec = -12.5, Caveat = "loose match", RecordedAt = recorded, }, CancellationToken.None); var loaded = _store.LoadProvenance(id); Assert.NotNull(loaded); Assert.Equal(TruthSource.Fetched, loaded!.Source); Assert.Equal("https://jray.example", loaded.ServerUrl); Assert.Equal(MatchTier.Loose, loaded.MatchTier); // The offset is unrecoverable once applied: the stored windows look // native, and nothing else would say they had been shifted. Assert.Equal(-12.5, loaded.OffsetSec); Assert.Equal("loose match", loaded.Caveat); Assert.Equal(recorded, loaded.RecordedAt); } // UT-025 [Fact] public async Task SaveAsync_LocalPush_RecordsNoServerOrTier() { var id = Guid.NewGuid(); await _store.SaveAsync( id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None); var loaded = _store.LoadProvenance(id)!; Assert.Equal(TruthSource.Pushed, loaded.Source); Assert.Equal(string.Empty, loaded.ServerUrl); // A push is about *this* file, so there is no cut to match. A tier here // would be a fabricated claim. Assert.Null(loaded.MatchTier); } // UT-026 [Fact] public async Task SaveAsync_DoesNotWriteProvenanceIntoTheTruthFile() { var id = Guid.NewGuid(); await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None); var truthJson = await File.ReadAllTextAsync( Path.Combine(_root, "plugins", "configurations", "JRay", "truth", id.ToString("D") + ".json")); // JR-004: the bytes served back are the bytes the producer wrote. Assert.DoesNotContain("source", truthJson, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("match_tier", truthJson, StringComparison.OrdinalIgnoreCase); } // UT-027 [Fact] public async Task Delete_RemovesProvenanceToo() { var id = Guid.NewGuid(); await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None); Assert.True(_store.Delete(id)); // A stale provenance record outliving its truth file would describe data // the next fetch has already replaced. Assert.Null(_store.LoadProvenance(id)); Assert.False(_store.Exists(id)); } // UT-028 [Fact] public void LoadProvenance_ForUnknownItem_ReturnsNull() { Assert.Null(_store.LoadProvenance(Guid.NewGuid())); } private sealed class FakePaths : IApplicationPaths { public FakePaths(string root) { ProgramDataPath = root; PluginsPath = Path.Combine(root, "plugins"); PluginConfigurationsPath = Path.Combine(root, "plugins", "configurations"); } public string ProgramDataPath { get; } public string WebPath => Path.Combine(ProgramDataPath, "web"); public string ProgramSystemPath => ProgramDataPath; public string DataPath => ProgramDataPath; public string ImageCachePath => ProgramDataPath; public string PluginsPath { get; } public string PluginConfigurationsPath { get; } public string LogDirectoryPath => ProgramDataPath; public string ConfigurationDirectoryPath => ProgramDataPath; public string SystemConfigurationFilePath => Path.Combine(ProgramDataPath, "system.xml"); public string CachePath { get; set; } = string.Empty; public string TempDirectory => Path.Combine(ProgramDataPath, "temp"); public string TrickplayPath => Path.Combine(ProgramDataPath, "trickplay"); public string VirtualDataPath => ProgramDataPath; public string BackupPath => Path.Combine(ProgramDataPath, "backup"); public void MakeSanityCheckOrThrow() { } public void CreateAndCheckMarker(string path, string markerName, bool recursive = false) { } } }