Lorenz's Slice 9 (t-paliad-195) deferred mig 093 because 40 active
paliad.deadline_rules still pointed at the 7 litigation-category
proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
Slice 5 (mig 087/088) already retired the category from project-binding;
this migration retires it from the rule corpus.
PLAN CHOICE (audit-gated, paliadin-approved): archive-all-40 rather than
the original re-parent plan. The audit found that 23 of 40 Pipeline-A
rules share their `code` with an existing fristenrechner rule on the
proposed re-parent target (e.g. inf.oral exists on both INF and
UPC_INF). Re-parenting would leave two rules with identical
(proceeding_type_id, code), breaking the implicit per-proceeding
rule_code identity contract keyed off by projection / search /
rule_editor. The fristenrechner rules are clearly the production
version (proper German names, legal_source pinned to UPC.RoP citations,
full bilateral chains, intra-proceeding counterclaim handling); the
Pipeline-A rules are stubs (English-only, mostly NULL legal_source,
duration_value=0 for 28 of 40, no spawn_proceeding_type_id wiring).
Migration 093 sequence (atomic):
1. Snapshot proceeding_types_pre_093 + deadline_rules_pre_093 as
permanent audit anchors.
2. INSERT _archived_litigation pt (category='archived',
is_active=false, jurisdiction='UPC') to home the rules.
3. UPDATE all 40 rules → archive pt + lifecycle_state='archived' +
is_active=false. Captured in paliad.deadline_rule_audit via the
mig 079 trigger.
4. DELETE the 7 litigation rows from paliad.proceeding_types (now
safe — nothing references them).
5. Hard assertions: 0 litigation rows survive, exactly 40 rules on
the archive pt, every snapshot row matches a surviving rule by id.
Critical FK note: deadline_rules.proceeding_type_id is ON DELETE CASCADE
→ proceeding_types(id). A naive DELETE of the 7 litigation rows would
cascade-delete all 40 rules and break the FK from the 1 live deadline
("Lecker Frist", completed) that still references inf.rejoin/INF.
Re-homing the rules before deleting the pt rows is mandatory.
Verified via BEGIN..ROLLBACK against live DB: assertions pass, all 30
intra-litigation parent_id chains preserved, the live deadline FK
stays valid.
Test impact:
internal/services/project_service_test.go:72 used to look up
category='litigation' AND code='INF' to exercise the Slice 5 negative
case. Post-mig-093 that lookup returns NULL. Rewritten to fetch any
category <> 'fristenrechner' row (the _archived_litigation pt is the
canonical post-093 row); defence-in-depth coverage of both the Go
service guard and the mig 088 SQL trigger is preserved.
SURFACED FOR LEGAL REVIEW (4 coverage questions the audit found, to be
triaged as follow-up tasks):
1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not present
on UPC_INF. Possible coverage gap; legal review to decide whether
to add it to the fristenrechner ruleset.
2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
currently starts standalone with no spawn from UPC_INF/UPC_REV.
Possible UX gap; Pipeline-A versions had
spawn_proceeding_type_id=NULL so they weren't functional spawns
either.
3. ccr.amend / rev.amend (spawn rules) — superseded by
inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe to
drop; no action needed.
4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
analogue; redundant with the DE_INF / DE_INF_OLG / DE_INF_BGH and
DE_NULL / DE_NULL_BGH chains. Safe to drop; no action needed.
Files:
internal/db/migrations/093_retire_litigation_category.up.sql (new)
internal/db/migrations/093_retire_litigation_category.down.sql (new)
internal/services/project_service_test.go (test rewrite)
255 lines
9.3 KiB
Go
255 lines
9.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
// TestProjectService_ProceedingTypeCategoryGuard exercises the Phase 3
|
|
// Slice 5 (t-paliad-186) "fristenrechner-category only" invariant on
|
|
// paliad.projects.proceeding_type_id from three angles:
|
|
//
|
|
// 1. Migration smoke: post-mig 087, no project points at a
|
|
// non-fristenrechner-category proceeding_types row.
|
|
//
|
|
// 2. ProjectService.Create returns ErrInvalidProceedingTypeCategory
|
|
// when handed a non-fristenrechner-category id. The server-side
|
|
// service guard fires BEFORE the DB write hits the trigger from
|
|
// mig 088.
|
|
//
|
|
// 3. The mig 088 trigger rejects a raw INSERT that bypasses the Go
|
|
// service layer (defence-in-depth). A non-fristenrechner-category
|
|
// id INSERT via plain SQL must raise EXCEPTION.
|
|
//
|
|
// 4. Passing a fristenrechner-category id (UPC_INF) succeeds.
|
|
//
|
|
// Phase 3 Slice 9 follow-up B (t-paliad-200, mig 093) retired the
|
|
// 'litigation' category from the rule corpus; the negative-case lookup
|
|
// is now any non-fristenrechner-category row (the _archived_litigation
|
|
// pt mig 093 introduces is the canonical one and exists on every
|
|
// post-093 deploy).
|
|
//
|
|
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
|
func TestProjectService_ProceedingTypeCategoryGuard(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()
|
|
|
|
// -----------------------------------------------------------------
|
|
// 1. Migration smoke — no project points at a litigation-category code.
|
|
// -----------------------------------------------------------------
|
|
var leaked int
|
|
if err := pool.GetContext(ctx, &leaked, `
|
|
SELECT count(*)
|
|
FROM paliad.projects p
|
|
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
|
WHERE pt.category <> 'fristenrechner'`); err != nil {
|
|
t.Fatalf("count leaked refs: %v", err)
|
|
}
|
|
if leaked != 0 {
|
|
t.Errorf("%d projects still reference non-fristenrechner proceeding_types — mig 087 incomplete", leaked)
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// 2 + 4. ProjectService.Create guard — typed error on non-
|
|
// fristenrechner id, success on fristenrechner id.
|
|
//
|
|
// Pre-mig-093 this looked up category='litigation' AND code='INF';
|
|
// mig 093 retired the litigation category so the negative case now
|
|
// pulls any non-fristenrechner row (the _archived_litigation pt is
|
|
// the canonical post-093 row, but the query is broad in case other
|
|
// non-fristenrechner buckets are introduced).
|
|
// -----------------------------------------------------------------
|
|
var nonFristenrechnerID int
|
|
if err := pool.GetContext(ctx, &nonFristenrechnerID,
|
|
`SELECT id FROM paliad.proceeding_types
|
|
WHERE category <> 'fristenrechner'
|
|
ORDER BY id
|
|
LIMIT 1`); err != nil {
|
|
t.Fatalf("look up non-fristenrechner id: %v", err)
|
|
}
|
|
var fristenrechnerID int
|
|
if err := pool.GetContext(ctx, &fristenrechnerID,
|
|
`SELECT id FROM paliad.proceeding_types
|
|
WHERE category = 'fristenrechner' AND code = 'UPC_INF' AND is_active = true`); err != nil {
|
|
t.Fatalf("look up UPC_INF id: %v", err)
|
|
}
|
|
|
|
users := NewUserService(pool)
|
|
svc := NewProjectService(pool, users)
|
|
|
|
// Seed a user so Create has a creator with a paliad.users row.
|
|
userID := uuid.New()
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, 'slice5-guard-test@hlc.com')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed auth.users: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
|
VALUES ($1, 'slice5-guard-test@hlc.com', 'Slice5 Guard', 'munich', 'associate', 'de')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
|
|
// 2. Non-fristenrechner-category id → ErrInvalidProceedingTypeCategory.
|
|
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
|
Type: ProjectTypeProject,
|
|
Title: "Slice 5 — non-fristenrechner-id reject",
|
|
ProceedingTypeID: &nonFristenrechnerID,
|
|
})
|
|
if err == nil {
|
|
t.Error("Create with non-fristenrechner-category proceeding_type_id should fail, but succeeded")
|
|
} else if !errors.Is(err, ErrInvalidProceedingTypeCategory) {
|
|
t.Errorf("expected ErrInvalidProceedingTypeCategory, got %v", err)
|
|
}
|
|
|
|
// 4. Fristenrechner-category id → success.
|
|
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
|
Type: ProjectTypeProject,
|
|
Title: "Slice 5 — fristenrechner-id accept",
|
|
ProceedingTypeID: &fristenrechnerID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create with fristenrechner-category proceeding_type_id: %v", err)
|
|
}
|
|
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != fristenrechnerID {
|
|
t.Errorf("created project proceeding_type_id = %v, want %d", created.ProceedingTypeID, fristenrechnerID)
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// 3. mig 088 trigger — raw INSERT bypassing Go service must raise.
|
|
// -----------------------------------------------------------------
|
|
rawID := uuid.New()
|
|
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
|
|
|
|
_, err = pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects
|
|
(id, type, parent_id, path, title, status, created_by,
|
|
proceeding_type_id, metadata, created_at, updated_at)
|
|
VALUES ($1, 'project', NULL, $1::text, 'Slice 5 — trigger bypass', 'active', $2,
|
|
$3, '{}'::jsonb, now(), now())`,
|
|
rawID, userID, nonFristenrechnerID)
|
|
if err == nil {
|
|
t.Error("raw INSERT with non-fristenrechner-category proceeding_type_id should have raised; got nil")
|
|
}
|
|
}
|
|
|
|
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
|
|
// (t-paliad-189) instance_level data path: Create + Update both accept
|
|
// the four allowed shapes (first / appeal / cassation / NULL) and reject
|
|
// anything else with ErrInvalidInput. The DB CHECK from mig 080
|
|
// (Slice 1) is the defence-in-depth backstop; the service-layer
|
|
// validation provides a clearer error to the handler.
|
|
//
|
|
// Skipped when TEST_DATABASE_URL is unset.
|
|
func TestProjectService_InstanceLevel_Roundtrip(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()
|
|
users := NewUserService(pool)
|
|
svc := NewProjectService(pool, users)
|
|
|
|
userID := uuid.New()
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, 'slice8-instance-test@hlc.com')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed auth.users: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
|
VALUES ($1, 'slice8-instance-test@hlc.com', 'Slice8 Test', 'munich', 'associate', 'de')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
|
|
// Create with instance_level='first'.
|
|
first := "first"
|
|
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
|
Type: ProjectTypeProject,
|
|
Title: "Slice 8 — instance_level first",
|
|
InstanceLevel: &first,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create with instance_level=first: %v", err)
|
|
}
|
|
if created.InstanceLevel == nil || *created.InstanceLevel != "first" {
|
|
t.Errorf("created InstanceLevel = %v, want 'first'", created.InstanceLevel)
|
|
}
|
|
|
|
// Update to 'appeal'.
|
|
appeal := "appeal"
|
|
updated, err := svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &appeal})
|
|
if err != nil {
|
|
t.Fatalf("Update to appeal: %v", err)
|
|
}
|
|
if updated.InstanceLevel == nil || *updated.InstanceLevel != "appeal" {
|
|
t.Errorf("updated InstanceLevel = %v, want 'appeal'", updated.InstanceLevel)
|
|
}
|
|
|
|
// Update to '' (clear).
|
|
clear := ""
|
|
cleared, err := svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &clear})
|
|
if err != nil {
|
|
t.Fatalf("Update clear: %v", err)
|
|
}
|
|
if cleared.InstanceLevel != nil {
|
|
t.Errorf("cleared InstanceLevel = %v, want nil", cleared.InstanceLevel)
|
|
}
|
|
|
|
// Invalid value → ErrInvalidInput.
|
|
bogus := "supreme"
|
|
_, err = svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &bogus})
|
|
if err == nil {
|
|
t.Error("instance_level=supreme should fail; got nil")
|
|
} else if !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("want ErrInvalidInput, got %v", err)
|
|
}
|
|
}
|