Files
ersatztv/ErsatzTV.Scanner.Tests/Core/Metadata/MediaServerReconciliationGuardTests.cs
T
timothy 6f4497e1ce fix(484): guard the nested TV season and episode sweeps against projection failures
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
2026-07-25 16:36:35 +02:00

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>>());
}
}