package store import ( "context" "crypto/sha256" "database/sql" "encoding/json" "errors" "os" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" "yovision/bell/internal/audit" ) func auditCandidate(t *testing.T, eventID, actorID string) audit.Candidate { t.Helper() data := json.RawMessage(`{"kind":"device_created","area_id":"area","modality":"video","capabilities":["video_capture"],"desired_state":"enabled"}`) value := audit.Envelope{SchemaVersion: 1, Event: audit.Event{ EventID: eventID, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1", Actor: audit.Actor{Type: "system", ID: actorID}, AggregateGeneration: 1, ProjectionVersions: audit.ProjectionVersions{}, Data: data, OccurredAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC), }} raw, err := json.Marshal(value) if err != nil { t.Fatal(err) } return audit.Candidate{Envelope: value, RecordHash: sha256.Sum256(raw)} } func TestPostgresAuditBatchReceiptAndImmutableFact(t *testing.T) { dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN") if dsn == "" { t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set") } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } defer db.Close() ctx := context.Background() repository, err := OpenPostgres(ctx, db) if err != nil { t.Fatal(err) } if err := repository.AuditRelayReady(ctx); err != nil { t.Fatal(err) } requestHash := sha256.Sum256([]byte("request-one")) eventID := "audit_10000000000000000000000000000001" results, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "sense")}) if err != nil || len(results) != 1 || results[0].Status != "accepted" { t.Fatalf("first batch: %+v %v", results, err) } replayed, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "ignored-by-receipt")}) if err != nil || replayed[0].Status != "accepted" { t.Fatalf("receipt replay: %+v %v", replayed, err) } different := sha256.Sum256([]byte("request-two")) if _, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}); !errors.Is(err, audit.ErrReplayConflict) { t.Fatalf("expected replay conflict, got %v", err) } duplicate, err := repository.ProcessAuditBatch(ctx, "sense-a", "BBBBBBBBBBBBBBBBBBBBBB", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}) if err != nil || duplicate[0].Status != "duplicate" { t.Fatalf("event duplicate: %+v %v", duplicate, err) } conflictHash := sha256.Sum256([]byte("request-three")) conflict, err := repository.ProcessAuditBatch(ctx, "sense-a", "CCCCCCCCCCCCCCCCCCCCCC", conflictHash, []audit.Candidate{auditCandidate(t, eventID, "other")}) if err != nil || conflict[0].Status != "rejected" || conflict[0].ErrorCode == nil || *conflict[0].ErrorCode != "id_conflict" { t.Fatalf("event conflict: %+v %v", conflict, err) } if _, err := db.ExecContext(ctx, `UPDATE bell.audit_events SET actor_id='mutated' WHERE event_id=$1`, eventID); err == nil { t.Fatal("runtime updated immutable audit fact") } if _, err := db.ExecContext(ctx, `DELETE FROM bell.audit_events WHERE event_id=$1`, eventID); err == nil { t.Fatal("runtime deleted immutable audit fact") } }