Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes' FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason / ProjectToEpisode returning Failed() was computed and discarded. #477 scoped those out because "the blast radius is one show's seasons / one season's episodes", which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by construction. One bad code path fires on every parent, so every season enumerates zero episodes, existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently. Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged. Also from the review: - finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode, music video) by driving the real ScanLibrary entry point and recording the failure from inside the enumeration, so a refactor handing the api client a fresh counter goes red. - finding 4: the missing-library Failed() branch is documented as defensive and unreachable. - finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's pre-projection filter) is stated as a known limitation in the decision record. - finding 5: the log-contract change (only the #484 message when both refusals apply) is noted. fixes #484
210 lines
8.0 KiB
C#
210 lines
8.0 KiB
C#
using ErsatzTV.Scanner.Core.Metadata;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Scanner.Tests.Core.Metadata;
|
|
|
|
// #477/#484: the deterministic policy behind the media-server anti-nuke guard. Two cases skip the sweep
|
|
// (and log), both requiring existing items to be at risk: an EMPTY incoming set (#477) and a non-zero
|
|
// PROJECTION FAILURE count (#484). Every other combination reconciles normally — in particular a short
|
|
// incoming set caused by deliberate skips, and a genuine bulk deletion, both still sweep.
|
|
public class MediaServerReconciliationGuardTests
|
|
{
|
|
[Test]
|
|
public void Empty_Incoming_With_Existing_Items_Skips_And_Warns()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
bool shouldFlag = MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5);
|
|
|
|
shouldFlag.ShouldBeFalse();
|
|
logger.Received(1).Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
[Test]
|
|
public void Partial_Deletion_Still_Flags()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5).ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
[Test]
|
|
public void Empty_Incoming_With_No_Existing_Items_Is_A_Noop_Sweep()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
// nothing exists, so an empty incoming set flags nothing either way — allow the (empty) sweep
|
|
// rather than special-casing it, and do not emit the scary warning.
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 0).ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
[Test]
|
|
public void Full_Fetch_Into_Empty_Library_Still_Flags()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 0).ShouldBeTrue();
|
|
}
|
|
|
|
// #484: an item whose projection threw was silently dropped, so it is missing from the incoming set
|
|
// even though the server DID return it. Sweeping here would flag a healthy item FileNotFound.
|
|
[Test]
|
|
public void Projection_Failure_Skips_And_Warns()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 4, 5, 1).ShouldBeFalse();
|
|
|
|
logger.Received(1).Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
// #484 THE regression guard. Deliberate guard-clause skips (a STRM file, a virtual item, an
|
|
// unsupported type) are NOT projection failures: they never reach the counter, so a short incoming
|
|
// set caused only by them must still sweep. If skips were counted, one STRM file in a library would
|
|
// permanently disable that library's reconciliation and stale rows would accumulate forever.
|
|
[Test]
|
|
public void Deliberate_Skips_Do_Not_Suppress_The_Sweep()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
// 5 exist locally, the server returned 5, 2 were deliberately skipped => 3 incoming, 0 failures
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5, 0).ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
// a genuine bulk deletion produces zero projection failures, so it is unaffected by #484 — this is
|
|
// why the rejected ratio threshold was not needed to tell the two apart.
|
|
[Test]
|
|
public void Bulk_Deletion_With_No_Failures_Still_Flags()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 1, 500, 0).ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public void Projection_Failure_With_No_Existing_Items_Is_A_Noop_Sweep()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
// nothing exists locally, so the sweep flags nothing either way — don't emit the scary warning
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 2, 0, 3).ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
// #484: the nested per-show season / per-season episode sweeps. They get the SAME projection-failure
|
|
// predicate...
|
|
[Test]
|
|
public void Descendant_Sweep_Skips_And_Warns_On_A_Projection_Failure()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 4, 5, 1)
|
|
.ShouldBeFalse();
|
|
|
|
logger.Received(1).Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
// ...but deliberately NOT #477's empty-fetch branch. A per-parent empty is a plausible legitimate
|
|
// state (every episode of a season really was deleted) with a bounded blast radius, and #476's
|
|
// descendant cascade depends on it still sweeping. Importing that branch here would be a silent
|
|
// behaviour change, which is why this is a separate entry point rather than a shared signature.
|
|
[Test]
|
|
public void Descendant_Sweep_Still_Runs_On_An_Empty_Per_Parent_Fetch()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "season 1 of show Keeper episodes", 0, 5, 0)
|
|
.ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
[Test]
|
|
public void Descendant_Sweep_Runs_With_No_Failures_And_Is_A_Noop_When_Nothing_Exists()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
// deliberate skips only => still sweeps (the STRM regression guard at the descendant level)
|
|
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 3, 5, 0)
|
|
.ShouldBeTrue();
|
|
|
|
// failures but nothing exists locally => the sweep flags nothing anyway, so don't warn
|
|
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 2, 0, 3)
|
|
.ShouldBeTrue();
|
|
|
|
logger.DidNotReceive().Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Any<object>(),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
|
|
// both refusals can be true at once (a fetch that dropped everything it returned); either is
|
|
// sufficient, and the failure branch is reported because it names the actual cause.
|
|
[Test]
|
|
public void Projection_Failure_Wins_Over_The_Zero_Incoming_Message()
|
|
{
|
|
var logger = Substitute.For<ILogger>();
|
|
|
|
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5, 2).ShouldBeFalse();
|
|
|
|
logger.Received(1).Log(
|
|
LogLevel.Warning,
|
|
Arg.Any<EventId>(),
|
|
Arg.Is<object>(o => o.ToString()!.Contains("silently dropped")),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
}
|
|
}
|