JR-010: record how truth data was obtained
Three routes now deliver truth data -- a sidecar, a worker push, and a fetched
manifest -- and once stored they were indistinguishable. The truth file records
nothing about how it arrived, so a locally computed sidecar and a loose-tier
manifest from a third-party server looked identical to every reader, despite
making claims of very different strength about the same item.
TruthProvenance records source, server, match tier, applied offset and caveat.
GET /Items/{itemId}/Provenance serves it, and JR-036's loose-tier caveat now
has somewhere to come from.
Two decisions carried in the code rather than assumed:
Provenance is stored BESIDE the truth file, never inside it. Injecting fields
would mean the bytes served back are not the bytes the producer wrote, which is
the property JR-004 turns on. UT-026 pins it by asserting the stored truth JSON
contains no provenance keys.
The applied offset is recorded because it is otherwise unrecoverable. Once
JR-030 shifts every window the timings look native, and nothing else would say
they had been shifted -- which matters when diagnosing an overlay that is
consistently a few seconds out.
A sidecar's provenance is derived rather than stored: it is local, and its
timestamp is the file's own. Precedence resolves through the same rule as
GetTruthAsync, because resolving it twice by different rules is how the two
would drift.
Fourth mutation check: stopping Delete from removing provenance fails UT-027
alone -- a stale record would otherwise outlive its claim and describe data the
next fetch had already replaced.
TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and
|
||||
/// they are not interchangeable. A locally computed sidecar and a
|
||||
/// <c>loose</c>-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
|
||||
/// </summary>
|
||||
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<ManagedTruthStore>.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 = 1, Movie = "/m.mkv" };
|
||||
var actor = new TruthActor { Name = "A", TmdbId = "884" };
|
||||
actor.Scenes.Add([1.0, 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user