feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
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>
This commit is contained in:
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user