diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go new file mode 100644 index 0000000..b4afe6a --- /dev/null +++ b/internal/store/crud_test.go @@ -0,0 +1,193 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +func openTestStore(t *testing.T) *store.Store { + t.Helper() + url := testDatabaseURL(t) + if err := store.Migrate(url); err != nil { + t.Fatalf("Migrate: %v", err) + } + s, err := store.Open(context.Background(), url) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(s.Close) + return s +} + +func TestSubmissionCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + created, err := s.CreateSubmission(ctx, "instagram", "reel", "Werbung fuer ein Produkt") + if err != nil { + t.Fatalf("CreateSubmission: %v", err) + } + if created.Status != "draft" { + t.Fatalf("Status = %q, want draft", created.Status) + } + + got, err := s.GetSubmission(ctx, created.ID) + if err != nil { + t.Fatalf("GetSubmission: %v", err) + } + if got.Platform != "instagram" || got.Caption != "Werbung fuer ein Produkt" { + t.Fatalf("GetSubmission = %+v, unerwartete Werte", got) + } + + if err := s.SetSubmissionStatus(ctx, created.ID, "checked"); err != nil { + t.Fatalf("SetSubmissionStatus: %v", err) + } + got, err = s.GetSubmission(ctx, created.ID) + if err != nil { + t.Fatalf("GetSubmission nach Statuswechsel: %v", err) + } + if got.Status != "checked" { + t.Fatalf("Status nach SetSubmissionStatus = %q, want checked", got.Status) + } +} + +func TestGetSubmissionNotFound(t *testing.T) { + s := openTestStore(t) + if _, err := s.GetSubmission(context.Background(), "00000000-0000-0000-0000-000000000000"); err == nil { + t.Fatal("expected error for a nonexistent submission, got nil") + } +} + +func TestSetSubmissionStatusNotFound(t *testing.T) { + s := openTestStore(t) + err := s.SetSubmissionStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "checked") + if err == nil { + t.Fatal("expected error when updating a nonexistent submission, got nil") + } +} + +func TestExtractionCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + sub, err := s.CreateSubmission(ctx, "tiktok", "video", "...") + if err != nil { + t.Fatalf("CreateSubmission: %v", err) + } + + payload := []byte(`{"gegenleistung":"bezahlt"}`) + ext, err := s.CreateExtraction(ctx, sub.ID, payload, "claude-sonnet-5", "v1") + if err != nil { + t.Fatalf("CreateExtraction: %v", err) + } + // JSONB normalisiert die Textform (z. B. Leerzeichen nach ':'), der Inhalt + // muss aber semantisch identisch bleiben — kein Byte-Vergleich. + var gotPayload, wantPayload map[string]any + if err := json.Unmarshal(ext.Payload, &gotPayload); err != nil { + t.Fatalf("unmarshal stored payload: %v", err) + } + if err := json.Unmarshal(payload, &wantPayload); err != nil { + t.Fatalf("unmarshal input payload: %v", err) + } + if gotPayload["gegenleistung"] != wantPayload["gegenleistung"] { + t.Fatalf("stored payload = %v, want %v", gotPayload, wantPayload) + } + + got, err := s.GetLatestExtraction(ctx, sub.ID) + if err != nil { + t.Fatalf("GetLatestExtraction: %v", err) + } + if got.ID != ext.ID { + t.Fatalf("GetLatestExtraction returned a different row than CreateExtraction") + } + + // Eine zweite Extraktion (erneute Pruefung) muss die "latest" sein. + payload2 := []byte(`{"gegenleistung":"keine"}`) + ext2, err := s.CreateExtraction(ctx, sub.ID, payload2, "claude-sonnet-5", "v1") + if err != nil { + t.Fatalf("CreateExtraction (2): %v", err) + } + got, err = s.GetLatestExtraction(ctx, sub.ID) + if err != nil { + t.Fatalf("GetLatestExtraction (2): %v", err) + } + if got.ID != ext2.ID { + t.Fatalf("GetLatestExtraction did not return the most recent extraction") + } +} + +func TestFindingCRUDAndSupersedes(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + sub, err := s.CreateSubmission(ctx, "instagram", "reel", "...") + if err != nil { + t.Fatalf("CreateSubmission: %v", err) + } + + old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung") + if err != nil { + t.Fatalf("CreateFinding (old): %v", err) + } + + current, err := s.ListCurrentFindings(ctx, sub.ID) + if err != nil { + t.Fatalf("ListCurrentFindings: %v", err) + } + if len(current) != 1 || current[0].ID != old.ID { + t.Fatalf("expected exactly the old finding before any correction, got %+v", current) + } + + // Korrektur: neue Zeile, die die alte per supersedes ersetzt. + _, err = s.Pool.Exec(ctx, ` + INSERT INTO finding (submission_id, rule_id, rule_version, severity, message, supersedes) + VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', $2) + `, sub.ID, old.ID) + if err != nil { + t.Fatalf("insert superseding finding: %v", err) + } + + current, err = s.ListCurrentFindings(ctx, sub.ID) + if err != nil { + t.Fatalf("ListCurrentFindings nach Korrektur: %v", err) + } + if len(current) != 1 { + t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current) + } + if current[0].Message != "korrigierte Fassung" { + t.Fatalf("expected the corrected finding to be current, got %+v", current[0]) + } +} + +func TestEvidencePackageCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + sub, err := s.CreateSubmission(ctx, "instagram", "reel", "...") + if err != nil { + t.Fatalf("CreateSubmission: %v", err) + } + + token := []byte("fake-rfc3161-token-bytes") + pkg, err := s.CreateEvidencePackage(ctx, sub.ID, "/var/lib/deklarix/dossiers/x.pdf", "deadbeef", token) + if err != nil { + t.Fatalf("CreateEvidencePackage: %v", err) + } + + got, err := s.GetLatestEvidencePackage(ctx, sub.ID) + if err != nil { + t.Fatalf("GetLatestEvidencePackage: %v", err) + } + if got.ID != pkg.ID || string(got.TimestampToken) != string(token) { + t.Fatalf("GetLatestEvidencePackage = %+v, unerwartete Werte", got) + } + + // Append-only: ein UPDATE muss vom Trigger abgelehnt werden. + _, err = s.Pool.Exec(ctx, `UPDATE evidence_package SET sha256 = 'geaendert' WHERE id = $1`, pkg.ID) + if err == nil { + t.Fatal("expected UPDATE on evidence_package to be rejected, but it succeeded") + } +} diff --git a/internal/store/evidence.go b/internal/store/evidence.go new file mode 100644 index 0000000..5c6fa78 --- /dev/null +++ b/internal/store/evidence.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// EvidencePackage ist das Ergebnis der Archivierung eines Beitrags: +// Dossier-Pfad, Hash der kanonisierten Metadaten und RFC-3161-Token. +// Append-only: siehe Migration. +type EvidencePackage struct { + ID string + SubmissionID string + DossierPath string + SHA256 string + TimestampToken []byte + CreatedAt time.Time +} + +// CreateEvidencePackage speichert ein EvidencePackage. +func (s *Store) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (EvidencePackage, error) { + var e EvidencePackage + err := s.Pool.QueryRow(ctx, ` + INSERT INTO evidence_package (submission_id, dossier_path, sha256, timestamp_token) + VALUES ($1, $2, $3, $4) + RETURNING id, submission_id, dossier_path, sha256, timestamp_token, created_at + `, submissionID, dossierPath, sha256Hex, timestampToken).Scan( + &e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt, + ) + if err != nil { + return EvidencePackage{}, fmt.Errorf("store: create evidence package: %w", err) + } + return e, nil +} + +// GetLatestEvidencePackage liefert das zuletzt erzeugte EvidencePackage +// für einen Beitrag. +func (s *Store) GetLatestEvidencePackage(ctx context.Context, submissionID string) (EvidencePackage, error) { + var e EvidencePackage + err := s.Pool.QueryRow(ctx, ` + SELECT id, submission_id, dossier_path, sha256, timestamp_token, created_at + FROM evidence_package + WHERE submission_id = $1 + ORDER BY created_at DESC + LIMIT 1 + `, submissionID).Scan( + &e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt, + ) + if err != nil { + return EvidencePackage{}, fmt.Errorf("store: get latest evidence package: %w", err) + } + return e, nil +} diff --git a/internal/store/extraction.go b/internal/store/extraction.go new file mode 100644 index 0000000..1f539e9 --- /dev/null +++ b/internal/store/extraction.go @@ -0,0 +1,56 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Extraction ist das Ergebnis der Stufe-1-Extraktion für einen Beitrag. +// Payload ist das rohe, vom Modell gelieferte JSON — nicht eine +// abgeleitete Repräsentation. Append-only: siehe Migration. +type Extraction struct { + ID string + SubmissionID string + Payload []byte + ModelVersion string + PromptVersion string + CreatedAt time.Time +} + +// CreateExtraction speichert eine Extraktion. payload ist das rohe +// JSON, wie es das Modell zurückgegeben hat (siehe extract.Result.RawJSON). +func (s *Store) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (Extraction, error) { + var e Extraction + err := s.Pool.QueryRow(ctx, ` + INSERT INTO extraction (submission_id, payload, model_version, prompt_version) + VALUES ($1, $2, $3, $4) + RETURNING id, submission_id, payload, model_version, prompt_version, created_at + `, submissionID, payload, modelVersion, promptVersion).Scan( + &e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt, + ) + if err != nil { + return Extraction{}, fmt.Errorf("store: create extraction: %w", err) + } + return e, nil +} + +// GetLatestExtraction liest die zuletzt erzeugte Extraktion für einen +// Beitrag (append-only: es kann mehrere geben, z. B. bei einer erneuten +// Prüfung — die aktuellste zählt). +func (s *Store) GetLatestExtraction(ctx context.Context, submissionID string) (Extraction, error) { + var e Extraction + err := s.Pool.QueryRow(ctx, ` + SELECT id, submission_id, payload, model_version, prompt_version, created_at + FROM extraction + WHERE submission_id = $1 + ORDER BY created_at DESC + LIMIT 1 + `, submissionID).Scan( + &e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt, + ) + if err != nil { + return Extraction{}, fmt.Errorf("store: get latest extraction: %w", err) + } + return e, nil +} diff --git a/internal/store/finding.go b/internal/store/finding.go new file mode 100644 index 0000000..2e0cbc7 --- /dev/null +++ b/internal/store/finding.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only: +// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht +// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde). +type Finding struct { + ID string + SubmissionID string + ExtractionID *string + RuleID string + RuleVersion int + Severity string + Message string + CreatedAt time.Time +} + +// CreateFinding speichert ein Finding. +func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, message string) (Finding, error) { + var f Finding + err := s.Pool.QueryRow(ctx, ` + INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, message) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, message, created_at + `, submissionID, extractionID, ruleID, ruleVersion, severity, message).Scan( + &f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt, + ) + if err != nil { + return Finding{}, fmt.Errorf("store: create finding: %w", err) + } + return f, nil +} + +// ListCurrentFindings liefert die aktuell gültigen Findings eines +// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes +// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes). +func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.message, f.created_at + FROM finding f + WHERE f.submission_id = $1 + AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id) + ORDER BY f.created_at + `, submissionID) + if err != nil { + return nil, fmt.Errorf("store: list current findings: %w", err) + } + defer rows.Close() + + var findings []Finding + for rows.Next() { + var f Finding + if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan finding: %w", err) + } + findings = append(findings, f) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list current findings: %w", err) + } + return findings, nil +} diff --git a/internal/store/submission.go b/internal/store/submission.go new file mode 100644 index 0000000..296e300 --- /dev/null +++ b/internal/store/submission.go @@ -0,0 +1,66 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Submission ist ein eingereichter Beitrag. +type Submission struct { + ID string + Platform string + PostType string + Caption string + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +// CreateSubmission legt einen neuen Beitrag an (Status "draft"). +func (s *Store) CreateSubmission(ctx context.Context, platform, postType, caption string) (Submission, error) { + var sub Submission + err := s.Pool.QueryRow(ctx, ` + INSERT INTO submission (platform, post_type, caption) + VALUES ($1, $2, $3) + RETURNING id, platform, post_type, caption, status, created_at, updated_at + `, platform, postType, caption).Scan( + &sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, + ) + if err != nil { + return Submission{}, fmt.Errorf("store: create submission: %w", err) + } + return sub, nil +} + +// GetSubmission liest einen Beitrag anhand seiner ID. +func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) { + var sub Submission + err := s.Pool.QueryRow(ctx, ` + SELECT id, platform, post_type, caption, status, created_at, updated_at + FROM submission WHERE id = $1 + `, id).Scan( + &sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, + ) + if err != nil { + return Submission{}, fmt.Errorf("store: get submission: %w", err) + } + return sub, nil +} + +// SetSubmissionStatus setzt den Status eines Beitrags (submission ist, +// anders als extraction/finding/evidence_package, NICHT append-only — +// der Lebenszyklus draft → checked → published → archived ist eine +// normale Zustandsänderung, kein Beweis-Eintrag). +func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error { + tag, err := s.Pool.Exec(ctx, ` + UPDATE submission SET status = $2, updated_at = now() WHERE id = $1 + `, id, status) + if err != nil { + return fmt.Errorf("store: set submission status: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id) + } + return nil +}