feat: wire persistence into the web layer (check → archive → dossier)

This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.

extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.

internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.

internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:

- POST /pruefen persists submission + extraction + findings and marks
  the submission "checked". A needsClarification result persists the
  extraction (there's something worth keeping) but no findings and no
  status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
  the canonical submission+facts+findings metadata, gets an RFC-3161
  timestamp, generates the PDF dossier to disk, and persists the
  evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.

Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.

cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 14:39:38 +02:00
parent 72005fc326
commit f5ad08cebd
14 changed files with 724 additions and 105 deletions

View File

@@ -103,8 +103,15 @@ func TestExtractSuccess(t *testing.T) {
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
}
if got != want {
t.Fatalf("Extract() = %+v, want %+v", got, want)
if got.Facts != want {
t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want)
}
var rawArgs map[string]any
if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil {
t.Fatalf("RawJSON does not parse as JSON: %v", err)
}
if rawArgs["gegenleistung"] != "bezahlt" {
t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON)
}
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
@@ -131,8 +138,8 @@ func TestExtractPassesThroughUnclear(t *testing.T) {
if err != nil {
t.Fatalf("Extract: %v", err)
}
if got.Consideration != rules.ConsiderationUnclear {
t.Fatalf("Consideration = %q, want unklar", got.Consideration)
if got.Facts.Consideration != rules.ConsiderationUnclear {
t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration)
}
}
@@ -174,6 +181,34 @@ func TestExtractRejectsMalformedToolInput(t *testing.T) {
}
}
func TestParsePayloadRoundTrip(t *testing.T) {
payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`)
facts, err := extract.ParsePayload(payload, "tiktok", "DE")
if err != nil {
t.Fatalf("ParsePayload: %v", err)
}
want := rules.Facts{
Platform: "tiktok",
Jurisdiction: "DE",
Consideration: rules.ConsiderationInKind,
DisclosurePresent: true,
DisclosureWording: "Anzeige",
DisclosureBeforeCut: true,
}
if facts != want {
t.Fatalf("ParsePayload() = %+v, want %+v", facts, want)
}
}
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
t.Fatal("expected error for out-of-enum stored payload, got nil")
}
}
func TestExtractPropagatesAPIError(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)