t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema + RLS land, dev-only test route exercises the surface, no user-facing change. B1 wires the actual builder UI on top. Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows in prod, safe to relax): - paliad.scenarios gets owner_id / status / origin_project_id / promoted_project_id / stichtag / notes. spec drops NOT NULL and the scenarios_unique_per_scope constraint drops (the builder allows multiple scratch + Unbenanntes Szenario rows per user). - New tables: scenario_proceedings, scenario_events, scenario_shares. - paliad.projects.origin_scenario_id for the promote-to-project audit trail (the FK lands now; the wizard ships in B5). - paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering owner / share / global_admin / two legacy paths. - Replacement RLS on scenarios + RLS on the three new tables; legacy service + handlers stay live and unchanged. PRD §5.1 deviations called out in the migration header: - proceeding_type_id is integer (live schema), not uuid (PRD draft). - FK target is paliad.users, matching the rest of paliad's schema. Go surface: - ScenarioBuilderService — list/create/get-deep/patch scenarios, add/patch/delete proceedings, add/patch/delete events, add/delete shares. Writes wrap in transactions with set_config( paliad.audit_reason, ..., true) per event_choice_service.go pattern. - /api/builder/scenarios/* — handlers register under a builder/ prefix so the legacy /api/scenarios surface still works. - /dev/scenario-builder — single-page HTML form gated to PaliadinOwnerEmail, exercises the B0 surface without Postman. - Live-DB integration test (TEST_DATABASE_URL gated) covers create + list + deep-get + share + visibility negatives + patch. Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against the live DB before commit; end-to-end sanity (insert chain + CHECK constraints + CASCADE-on-delete) verified via the Supabase MCP. bun build clean. go vet + go test -short ./... green.
221 lines
7.2 KiB
Go
221 lines
7.2 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
|
|
// surface end-to-end against a live DB: create + list + deep-get + patch
|
|
// + add-proceeding + add-event + add/delete-share, plus the visibility
|
|
// negative case (a non-owner can't see the scenario unless shared).
|
|
//
|
|
// Skipped without TEST_DATABASE_URL — matches the pattern in
|
|
// project_service_test.go / event_choice_service_test.go.
|
|
func TestScenarioBuilderService(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
owner := uuid.New()
|
|
other := uuid.New()
|
|
cleanup := func() {
|
|
// Cascade order: delete from scenarios → CASCADE clears
|
|
// proceedings, events, shares. Then the two users.
|
|
pool.ExecContext(ctx,
|
|
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
|
|
pool.ExecContext(ctx,
|
|
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
|
|
pool.ExecContext(ctx,
|
|
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
for _, seed := range []struct {
|
|
id uuid.UUID
|
|
email string
|
|
name string
|
|
}{
|
|
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
|
|
{other, "builder-other-test@hlc.com", "Builder Other"},
|
|
} {
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
|
seed.id, seed.email); err != nil {
|
|
t.Fatalf("seed auth.users %s: %v", seed.email, err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
|
VALUES ($1, $2, $3, 'munich', 'de')`,
|
|
seed.id, seed.email, seed.name); err != nil {
|
|
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
|
|
}
|
|
}
|
|
|
|
// Pick a real proceeding_type_id so the FK insert succeeds.
|
|
var ptID int
|
|
if err := pool.GetContext(ctx, &ptID,
|
|
`SELECT id FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true
|
|
LIMIT 1`, CodeUPCInfringement); err != nil {
|
|
t.Fatalf("look up upc.inf id: %v", err)
|
|
}
|
|
|
|
svc := NewScenarioBuilderService(pool)
|
|
|
|
// 1. Create a scenario for the owner. Empty name should default.
|
|
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
|
if err != nil {
|
|
t.Fatalf("CreateScenario: %v", err)
|
|
}
|
|
if sc.Name != "Unbenanntes Szenario" {
|
|
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
|
|
}
|
|
if sc.Status != "active" {
|
|
t.Errorf("default status = %q, want active", sc.Status)
|
|
}
|
|
if sc.OwnerID == nil || *sc.OwnerID != owner {
|
|
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
|
|
}
|
|
|
|
// 2. List — should return the one row.
|
|
list, err := svc.ListMyScenarios(ctx, owner, "active")
|
|
if err != nil {
|
|
t.Fatalf("ListMyScenarios: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ID != sc.ID {
|
|
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
|
|
}
|
|
|
|
// 3. Other user can NOT see the scenario.
|
|
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
|
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
|
|
}
|
|
|
|
// 4. Add a proceeding.
|
|
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
|
ProceedingTypeID: ptID,
|
|
PrimaryParty: ptrString("defendant"),
|
|
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddProceeding: %v", err)
|
|
}
|
|
if pr.ProceedingTypeID != ptID {
|
|
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
|
|
}
|
|
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
|
|
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
|
|
}
|
|
|
|
// 5. Add a custom-label event card.
|
|
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
|
CustomLabel: ptrString("Klageerwiderung"),
|
|
State: ptrString("planned"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddEvent: %v", err)
|
|
}
|
|
if ev.State != "planned" {
|
|
t.Errorf("event state = %q, want planned", ev.State)
|
|
}
|
|
|
|
// 5b. Add-event with NO anchor fields fails.
|
|
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
|
|
}
|
|
|
|
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
|
|
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetScenarioDeep: %v", err)
|
|
}
|
|
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
|
|
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
|
|
}
|
|
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
|
|
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
|
|
}
|
|
if len(deep.Shares) != 0 {
|
|
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
|
|
}
|
|
|
|
// 7. Share with `other`. Recipient should now see the scenario.
|
|
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
|
|
if err != nil {
|
|
t.Fatalf("AddShare: %v", err)
|
|
}
|
|
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
|
|
t.Errorf("GetScenarioDeep by share recipient: %v", err)
|
|
}
|
|
// But the recipient can NOT add proceedings.
|
|
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
|
|
ProceedingTypeID: ptID,
|
|
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
|
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
|
|
}
|
|
|
|
// 7b. Self-share should be rejected.
|
|
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("self-share = %v, want ErrInvalidInput", err)
|
|
}
|
|
|
|
// 8. Patch — archive then re-activate.
|
|
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
|
Status: ptrString("archived"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PatchScenario archive: %v", err)
|
|
}
|
|
if patched.Status != "archived" {
|
|
t.Errorf("after archive, status = %q, want archived", patched.Status)
|
|
}
|
|
// PATCH to 'promoted' is rejected — that's the wizard's job.
|
|
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
|
Status: ptrString("promoted"),
|
|
}); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
|
|
}
|
|
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
|
Status: ptrString("active"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PatchScenario re-activate: %v", err)
|
|
}
|
|
if patched.Status != "active" {
|
|
t.Errorf("after re-activate, status = %q, want active", patched.Status)
|
|
}
|
|
|
|
// 9. Revoke the share. Recipient loses visibility.
|
|
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
|
|
t.Fatalf("DeleteShare: %v", err)
|
|
}
|
|
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
|
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
|
|
}
|
|
}
|
|
|
|
// (Note: ptrString lives in rule_editor_service_test.go in this package
|
|
// and is reused here. No second declaration needed.)
|