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(); bool shouldFlag = MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5); shouldFlag.ShouldBeFalse(); logger.Received(1).Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } [Test] public void Partial_Deletion_Still_Flags() { var logger = Substitute.For(); MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5).ShouldBeTrue(); logger.DidNotReceive().Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } [Test] public void Empty_Incoming_With_No_Existing_Items_Is_A_Noop_Sweep() { var logger = Substitute.For(); // 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(), Arg.Any(), Arg.Any(), Arg.Any>()); } [Test] public void Full_Fetch_Into_Empty_Library_Still_Flags() { var logger = Substitute.For(); 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(); MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 4, 5, 1).ShouldBeFalse(); logger.Received(1).Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } // #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(); // 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(), Arg.Any(), Arg.Any(), Arg.Any>()); } // 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(); 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(); // 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(), Arg.Any(), Arg.Any(), Arg.Any>()); } // #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(); MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 4, 5, 1) .ShouldBeFalse(); logger.Received(1).Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } // ...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(); MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "season 1 of show Keeper episodes", 0, 5, 0) .ShouldBeTrue(); logger.DidNotReceive().Log( LogLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()); } [Test] public void Descendant_Sweep_Runs_With_No_Failures_And_Is_A_Noop_When_Nothing_Exists() { var logger = Substitute.For(); // 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(), Arg.Any(), Arg.Any(), Arg.Any>()); } // 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(); MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5, 2).ShouldBeFalse(); logger.Received(1).Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString()!.Contains("silently dropped")), Arg.Any(), Arg.Any>()); } }