Review of 491f5099 found the fix inert in the only process that runs it, plus a MySQL collation defect in the lookup. B1 — TvContext.IsUniqueConstraintViolation was assigned only in ErsatzTV/ Startup.cs, but ErsatzTV.Scanner is a separate executable and every production caller of GetOrAddFolder/SetEtag lives there. The classifier kept its '_ => false' default, so the catch never ran and the DbUpdateException failed the whole scan - worse than the duplicate row it replaced. Wire both provider branches in ErsatzTV.Scanner/Program.cs, and add ProviderStaticsWiringTests (architecture) asserting the scanner assigns every TvContext static the host assigns, with IsSqlite documented as the one exemption. H1 — GetFolder's 'Path == folder' is case-insensitive on MySQL while PathHash is case-sensitive, and FirstOrDefault was unordered: a scan of '/x/foo' could resolve the '/x/Foo' row and stamp the wrong hash onto it (verified on MySQL 8.4: the WHERE matches both, LIMIT 1 returns the wrong one). Treat the SQL equality as a narrowing filter, order by Id, and settle identity ordinally. Route the heal through EF and drop a classified violation, so an opportunistic maintenance write can never abort a scan. Also: run the dedupe migration test with foreign keys ON (matching prod), clear the keeper's etag, null out a self-parent, and document the cleanup's limits (NULL paths excluded, Down does not restore deleted rows, CI's fresh-DB apply covers none of the data mutation). Refs #488 #308 fix #491
105 lines
5.2 KiB
C#
105 lines
5.2 KiB
C#
using System.Text.RegularExpressions;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Architecture.Tests;
|
|
|
|
/// <summary>
|
|
/// ersatztv#491: <c>TvContext</c> carries settable provider statics (<c>LastInsertedRowId</c>,
|
|
/// <c>CaseInsensitiveCollation</c>, <c>IsUniqueConstraintViolation</c>, …) that Infrastructure code
|
|
/// reads at runtime. There are TWO composition roots that execute that Infrastructure code —
|
|
/// <c>ErsatzTV/Startup.cs</c> (the host) and <c>ErsatzTV.Scanner/Program.cs</c> (a separate
|
|
/// executable launched per scan by <c>CallLibraryScannerHandler</c>) — and each wires the statics in
|
|
/// its own copy of the provider branch.
|
|
/// <para>
|
|
/// The failure mode this guards is "a static nobody assigned": #491 added
|
|
/// <c>IsUniqueConstraintViolation</c> to <c>Startup</c> only, so every production caller of
|
|
/// <c>GetOrAddFolder</c> (all of which live in the scanner) silently kept the conservative
|
|
/// <c>_ => false</c> default and the new catch was inert. Nothing about that is visible in a
|
|
/// unit test, because every test harness wires the classifier itself.
|
|
/// </para>
|
|
/// <para>
|
|
/// Source-level rather than reflective on purpose: the wiring lives inside a host-builder
|
|
/// lambda that cannot be invoked without standing up a real application, and the thing being
|
|
/// asserted is precisely that a line of code exists in both files.
|
|
/// </para>
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ProviderStaticsWiringTests
|
|
{
|
|
/// <summary>
|
|
/// Statics the host wires that the scanner deliberately does not. Add to this only with a reason:
|
|
/// the default must be provably harmless in the scanner process.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, string> ScannerExemptions = new()
|
|
{
|
|
// Only read by DbInitializer / DatabaseMigratorService, which run in the host exclusively; no
|
|
// Infrastructure code on a scan path reads it. Pre-dates #491.
|
|
["IsSqlite"] = "read only by DbInitializer + DatabaseMigratorService, both host-only"
|
|
};
|
|
|
|
private static string HostSource => ReadRepoFile(Path.Combine("ErsatzTV", "Startup.cs"));
|
|
|
|
private static string ScannerSource => ReadRepoFile(Path.Combine("ErsatzTV.Scanner", "Program.cs"));
|
|
|
|
[Test]
|
|
public void Scanner_should_wire_every_TvContext_provider_static_the_host_wires()
|
|
{
|
|
HashSet<string> host = AssignedStatics(HostSource);
|
|
HashSet<string> scanner = AssignedStatics(ScannerSource);
|
|
|
|
// sanity: the parser found the wiring at all, so a rename can't turn this test into a no-op
|
|
host.ShouldContain("LastInsertedRowId");
|
|
host.ShouldContain("IsUniqueConstraintViolation");
|
|
scanner.ShouldContain("LastInsertedRowId");
|
|
|
|
List<string> missing = host
|
|
.Except(scanner)
|
|
.Except(ScannerExemptions.Keys)
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
missing.ShouldBeEmpty(
|
|
"ErsatzTV.Scanner/Program.cs does not assign TvContext static(s) that ErsatzTV/Startup.cs "
|
|
+ $"assigns: {string.Join(", ", missing)}. The scanner is a separate process, so an unassigned "
|
|
+ "static keeps its default in every library scan. Wire it in BOTH provider branches, or add "
|
|
+ "it to ScannerExemptions with a reason if the default is provably harmless there.");
|
|
}
|
|
|
|
[Test]
|
|
public void Both_hosts_should_wire_the_unique_constraint_classifier_for_both_providers()
|
|
{
|
|
// The specific #491 regression, asserted directly rather than via set arithmetic: the classifier
|
|
// must be pointed at a real provider implementation on BOTH branches of BOTH composition roots.
|
|
foreach ((string name, string source) in new[] { ("host", HostSource), ("scanner", ScannerSource) })
|
|
{
|
|
source.ShouldContain(
|
|
"TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation",
|
|
customMessage: $"{name} does not wire the Sqlite unique-constraint classifier");
|
|
source.ShouldContain(
|
|
"TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation",
|
|
customMessage: $"{name} does not wire the MySql unique-constraint classifier");
|
|
}
|
|
}
|
|
|
|
private static HashSet<string> AssignedStatics(string source) =>
|
|
Regex.Matches(source, @"\bTvContext\.(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=[^=]")
|
|
.Select(m => m.Groups["name"].Value)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
private static string ReadRepoFile(string relativePath)
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
|
|
{
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");
|
|
|
|
string path = Path.Combine(directory!.FullName, relativePath);
|
|
File.Exists(path).ShouldBeTrue($"expected source file not found: {path}");
|
|
return File.ReadAllText(path);
|
|
}
|
|
}
|