feat(bell): add immutable event store [T-015]
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
type storeIDs struct{ id string }
|
||||
|
||||
func (f storeIDs) NewEventID() (string, error) { return f.id, nil }
|
||||
|
||||
type allowVideo struct{}
|
||||
|
||||
func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func testCandidate(t *testing.T, configVersion string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(object, "id")
|
||||
object["config_version"] = configVersion
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func newEvent(t *testing.T, configVersion string) event.Event {
|
||||
t.Helper()
|
||||
guard, err := event.NewEvidenceGuard()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, err := event.NewFactory(
|
||||
contracts.EventV01Schema,
|
||||
storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"},
|
||||
allowVideo{}, guard,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := factory.Create(context.Background(), testCandidate(t, configVersion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestPostgresImmutableEventAndOutcome(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()
|
||||
repo, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
value := newEvent(t, "sp-v1-2026.07.20")
|
||||
created, err := repo.InsertEvent(ctx, value)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first insert: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repo.InsertEvent(ctx, value)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent replay: created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected immutable conflict, got %v", err)
|
||||
}
|
||||
|
||||
reason := "confirmed by operator"
|
||||
outcome := Outcome{
|
||||
ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(),
|
||||
Value: "true_positive", Source: "manual", Reason: &reason,
|
||||
ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(),
|
||||
}
|
||||
created, err = repo.AppendOutcome(ctx, outcome)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("append outcome: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repo.AppendOutcome(ctx, outcome)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil {
|
||||
t.Fatal("runtime unexpectedly updated immutable event")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil {
|
||||
t.Fatal("runtime unexpectedly deleted immutable outcome")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user