Files
paliad/internal/services/scenario_builder_service_test.go
mAi 9679a98666
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
PRD §2.3 + §10. Implements the dual-write rule (load-bearing
complexity per PRD §10): project-backed scenarios mirror flag
toggles to paliad.projects.scenario_flags and filed event states
to paliad.deadlines, while kontextfrei scenarios continue writing
only to paliad.scenario_events. Visible affordances: page-header
Akte picker, enabled "Aus Akte" mode tab, Akte banner on the
project-backed canvas, cross-surface scenario-flag-changed
dispatch + listener for live peer-surface coherence.

Backend
- ScenarioBuilderService takes ProjectService + ScenarioFlagsService
  deps so dual-write hits live tables.
- CreateScenarioFromProject seeds a scenario from a project: copies
  proceeding_type_id + scenario_flags, normalises our_side to the
  builder's binary claimant|defendant axis, surfaces existing
  rule-bound deadlines as scenario_events (filed when completed,
  planned otherwise).
- PatchProceeding on a project-backed top-level triplet dual-writes
  scenario_flags to projects.scenario_flags via flagDeltaFromBuilder.
- PatchEvent transitioning to state='filed' on a project-backed
  scenario upserts paliad.deadlines (status='completed', completed_
  at, source='rule') inside the same tx as the scenario_events
  UPDATE — canvas and project surfaces never diverge mid-flight.
- POST /api/builder/scenarios/from-project handler wires the entry
  point.

Frontend
- builder-akte.ts: project list fetch + dropdown render, Akte
  banner, createScenarioFromProject POST helper.
- builder.ts: mode branching — picking an Akte (search hit or
  page-header pick) creates a project-backed scenario and loads it;
  loaded scenarios reflect their origin_project_id on the picker +
  banner; flag toggles on Akte-backed top-level triplets dispatch
  scenario-flag-changed so the Verfahrensablauf strip / project
  surfaces refresh; the builder listens to inbound scenario-flag-
  changed and refetches its scenario when the changed project
  matches origin_project_id.
- procedures.tsx: enable the previously-disabled Aus Akte tab.
- i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted
  banner styling.

Tests
- TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write
  contract: Akte flag toggle → projects.scenario_flags updated,
  Akte filed event → deadlines row inserted; kontextfrei flag
  toggle leaves projects.scenario_flags untouched, kontextfrei
  filed event leaves deadlines untouched.
- Existing TestScenarioBuilderService passes against the new
  signature (nil deps short-circuit dual-write paths).

Verification: go test ./... + go vet ./... + bun run build all
clean. Playwright smoke against the static dist build confirms
the Akte tab + picker render correctly, fetchAkteProjects fires
on mount, and the scenario-flag-changed CustomEvent dispatches +
receives without runtime errors.

t-paliad-347
2026-05-28 10:44:33 +02:00

475 lines
17 KiB
Go

package services
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"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, nil, nil)
// 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)
}
}
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
//
// - PatchProceeding on a project-backed scenario (origin_project_id
// IS NOT NULL) MUST mirror scenario_flags onto
// paliad.projects.scenario_flags;
// - PatchEvent flipping state→'filed' on a project-backed scenario
// MUST upsert a paliad.deadlines row (status='completed',
// completed_at=actual_date);
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
// MUST NOT touch paliad.projects.scenario_flags or
// paliad.deadlines.
//
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
// by TestScenarioBuilderService above.
func TestScenarioBuilderAkteDualWrite(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()
cleanup := func() {
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id = $1`, owner)
}
cleanup()
defer cleanup()
// Seed owner.
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
owner, "builder-akte-test@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Look up a real proceeding_type_id + a sequencing_rule_id on that
// proceeding so the deadline upsert has a real rule to point at.
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)
}
var ruleID uuid.UUID
if err := pool.GetContext(ctx, &ruleID,
`SELECT id FROM paliad.sequencing_rules
WHERE proceeding_type_id = $1
AND is_active = true
AND lifecycle_state = 'published'
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
t.Fatalf("look up sequencing_rule: %v", err)
}
// Seed a paliad.projects (type='case') row pinned to that
// proceeding_type. our_side='defendant' so the builder triplet's
// primary_party derives from it.
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, title, status, proceeding_type_id, our_side, created_by)
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
projectID, ptID, owner); err != nil {
t.Fatalf("seed project: %v", err)
}
// Place the owner on the project team so visibility checks pass.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
t.Fatalf("seed project_teams: %v", err)
}
// Wire up the service with the real project + flag deps so dual-
// write hits live tables. NewProjectService + NewScenarioFlags
// match the production wiring in cmd/server/main.go.
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
// ──────────────────────────────────────────────────────────────────
// Phase A — Akte-backed scenario writes through to project tables.
// ──────────────────────────────────────────────────────────────────
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
if err != nil {
t.Fatalf("CreateScenarioFromProject: %v", err)
}
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
}
if len(akte.Proceedings) != 1 {
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
}
procID := akte.Proceedings[0].ID
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
}
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
// the same key on projects.scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (Akte): %v", err)
}
var rawProjFlags []byte
if err := pool.GetContext(ctx, &rawProjFlags,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("read project scenario_flags: %v", err)
}
var projFlags map[string]any
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
t.Fatalf("decode project scenario_flags: %v", err)
}
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
}
// Add an event card backed by a real sequencing rule, then PATCH
// state='filed' with actual_date. Dual-write should insert a
// paliad.deadlines row (status='completed', completed_at=actual_date).
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (Akte): %v", err)
}
filedDate := mustDate(t, "2026-04-15")
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &filedDate,
}); err != nil {
t.Fatalf("PatchEvent filed (Akte): %v", err)
}
var deadlineCount int
if err := pool.GetContext(ctx, &deadlineCount,
`SELECT COUNT(*) FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2
AND status = 'completed'`,
projectID, ruleID); err != nil {
t.Fatalf("count deadlines: %v", err)
}
if deadlineCount != 1 {
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
}
// ──────────────────────────────────────────────────────────────────
// Phase B — kontextfrei scenario does NOT touch project surfaces.
// ──────────────────────────────────────────────────────────────────
// Capture project scenario_flags + deadline count before the
// kontextfrei mutations so we can assert no change.
var beforeFlagsRaw []byte
_ = pool.GetContext(ctx, &beforeFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
var beforeDeadlines int
_ = pool.GetContext(ctx, &beforeDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario (kontextfrei): %v", err)
}
if kf.OriginProjectID != nil {
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
}
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("claimant"),
})
if err != nil {
t.Fatalf("AddProceeding (kontextfrei): %v", err)
}
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
// project's scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
}
var afterFlagsRaw []byte
if err := pool.GetContext(ctx, &afterFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("re-read project scenario_flags: %v", err)
}
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
beforeFlagsRaw, afterFlagsRaw)
}
// Filed-state event on a kontextfrei scenario MUST NOT touch
// paliad.deadlines.
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (kontextfrei): %v", err)
}
kfDate := mustDate(t, "2026-04-16")
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &kfDate,
}); err != nil {
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
}
var afterDeadlines int
if err := pool.GetContext(ctx, &afterDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
t.Fatalf("re-count deadlines: %v", err)
}
if afterDeadlines != beforeDeadlines {
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
beforeDeadlines, afterDeadlines)
}
}
// mustDate parses an ISO date or fails the test. Helper for the
// dual-write test above.
func mustDate(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parse date %q: %v", s, err)
}
return d
}
// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)