Deployment: - custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App" (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090) - scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check - ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye) - compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090 - .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch) Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage): - GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests - EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO) - Frontend VerifizierteTierePage + verified-API + e2e-Spec - diverse Import-/Triage-Skripte und -Tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
6.0 KiB
C#
152 lines
6.0 KiB
C#
using System.Text.Json;
|
|
using GerbilManagerWebAPI.Import;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace GerbilManager.Tests
|
|
{
|
|
/// <summary>
|
|
/// Golden regression fixture: ingests the REAL <c>tools/import/output/resolved_import.json</c>
|
|
/// (gitignored, local only) and asserts that every verified key animal's Akte still matches its
|
|
/// frozen golden snapshot in <c>tools/import/verified-golden.json</c> (committed fixture).
|
|
///
|
|
/// Both files are optional: if EITHER is missing the test skips cleanly (no failure), so it is a
|
|
/// no-op on CI / fresh clones but a real guard on the breeder's machine once she has verified
|
|
/// animals and exported the fixture.
|
|
/// </summary>
|
|
public class VerifiedGoldenTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public VerifiedGoldenTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private sealed record GoldenEntry(Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, GerbilSnapshot? Snapshot);
|
|
|
|
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
|
|
|
|
private static string? FindRepoRoot()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null)
|
|
{
|
|
if (Directory.Exists(Path.Combine(dir.FullName, "tools", "import")))
|
|
return dir.FullName;
|
|
dir = dir.Parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Verified_animals_still_match_their_golden_snapshot()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
if (repoRoot is null)
|
|
{
|
|
_out.WriteLine("SKIP: repo root (tools/import) not found from the test host.");
|
|
return;
|
|
}
|
|
|
|
var goldenPath = Path.Combine(repoRoot, "tools", "import", "verified-golden.json");
|
|
var outputDir = Path.Combine(repoRoot, "tools", "import", "output");
|
|
var resolvedPath = Path.Combine(outputDir, "resolved_import.json");
|
|
|
|
if (!File.Exists(goldenPath))
|
|
{
|
|
_out.WriteLine($"SKIP: golden fixture not present ({goldenPath}).");
|
|
return;
|
|
}
|
|
if (!File.Exists(resolvedPath))
|
|
{
|
|
_out.WriteLine($"SKIP: resolved_import.json not present ({resolvedPath}) — run the import pipeline.");
|
|
return;
|
|
}
|
|
|
|
List<GoldenEntry>? golden;
|
|
try
|
|
{
|
|
golden = JsonSerializer.Deserialize<List<GoldenEntry>>(await File.ReadAllTextAsync(goldenPath), Web);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_out.WriteLine($"SKIP: could not parse golden fixture: {ex.Message}");
|
|
return;
|
|
}
|
|
if (golden is null || golden.Count == 0)
|
|
{
|
|
_out.WriteLine("SKIP: golden fixture is empty.");
|
|
return;
|
|
}
|
|
|
|
// Ingest the real resolved_import.json into an InMemory DB.
|
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
|
.UseInMemoryDatabase("verified-golden-" + Guid.NewGuid().ToString("N"))
|
|
.Options;
|
|
using var db = new ApplicationContext(opts);
|
|
db.Database.EnsureCreated();
|
|
|
|
var photoDir = Path.Combine(Path.GetTempPath(), "verified-golden-photos-" + Guid.NewGuid().ToString("N"));
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
{ "Import:SourcePath", outputDir },
|
|
{ "Photos:RootPath", photoDir },
|
|
})
|
|
.Build();
|
|
|
|
try
|
|
{
|
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
|
Assert.StartsWith("Ingestion successful!", result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_out.WriteLine($"SKIP: ingest of the real resolved_import.json failed: {ex.Message}");
|
|
return;
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(photoDir, recursive: true); } catch { }
|
|
}
|
|
|
|
var mismatches = new List<string>();
|
|
int checkedCount = 0;
|
|
foreach (var entry in golden)
|
|
{
|
|
if (entry.Snapshot is null) continue;
|
|
checkedCount++;
|
|
GerbilSnapshot? actual;
|
|
try
|
|
{
|
|
actual = await GerbilSnapshotService.BuildSnapshotAsync(db, entry.GerbilId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: Akte konnte nicht gebaut werden — {ex.Message}");
|
|
continue;
|
|
}
|
|
|
|
if (actual is null)
|
|
{
|
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: fehlt im Import (verifiziertes Tier verschwunden).");
|
|
continue;
|
|
}
|
|
|
|
var diffs = GerbilSnapshotService.Diff(entry.Snapshot, actual);
|
|
if (diffs.Count > 0)
|
|
{
|
|
var fields = string.Join("; ", diffs.Select(d => $"{d.Path}: golden='{d.GoldenValue}' ≠ import='{d.OtherValue}'"));
|
|
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: {diffs.Count} Abweichung(en) — {fields}");
|
|
}
|
|
}
|
|
|
|
_out.WriteLine($"Golden-Fixture geprüft: {checkedCount} verifizierte Tiere, {mismatches.Count} abweichend.");
|
|
if (mismatches.Count > 0)
|
|
{
|
|
var report = $"Golden-Regression fehlgeschlagen — {mismatches.Count} von {checkedCount} verifizierten Tieren weichen vom eingefrorenen Snapshot ab:\n"
|
|
+ string.Join("\n", mismatches);
|
|
Assert.Fail(report);
|
|
}
|
|
}
|
|
}
|
|
}
|