Merge: t-paliad-325 — mig 153 proceeding_types kind discriminator + ProjectService hardening (m/paliad#147)
ritchie shipped atlas's design (docs/design-proceeding-types-taxonomy-2026-05-26.md):
- mig 153 additive: ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; UPDATE 4 phase + 10 side_action + 9 meta; per m's Q9 flips is_active=false on the same 23 rows in the same TX. CHECK trigger projects_proceeding_type_kind_check blocks projects.proceeding_type_id from pointing at non-proceeding kinds. Snapshot to paliad.proceeding_types_pre_153 in the same TX. set_config('paliad.audit_reason', ...) defensively.
- ProjectService.SetProceedingType hardened: new ErrInvalidProceedingTypeKind, single-SELECT validator checks category + kind + is_active before assigning.
- 4-angle test (TestProjectService_ProceedingTypeKindGuard) covers happy-path proceeding, rejected phase, rejected inactive, rejected wrong category.
- cmd/gen-upc-snapshot/main.go gains the AND kind='proceeding' filter; embedded snapshot JSON regen flagged as follow-up (needs DATABASE_URL at runtime).
Mode B R3 query now becomes WHERE is_active=true AND kind='proceeding' for a 23-row clean primary list. Phase/side_action/meta rows survive in the table for taxonomic reference but never surface in pickers.
This commit is contained in:
@@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
||||
return fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
|
||||
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
||||
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
|
||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||
// (is_active=false) are filtered out by the WHERE.
|
||||
// (is_active=false) are filtered out by the is_active predicate.
|
||||
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
|
||||
// is_active filter so phase/side_action/meta rows can't slip into
|
||||
// the embedded catalog even if some future deploy re-activates one
|
||||
// for an admin task.
|
||||
var procs []litigationplanner.ProceedingType
|
||||
if err := pool.SelectContext(ctx, &procs, `
|
||||
SELECT id, code, name, name_en, description, jurisdiction,
|
||||
@@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target
|
||||
FROM paliad.proceeding_types
|
||||
WHERE jurisdiction = 'UPC' AND is_active = true
|
||||
WHERE jurisdiction = 'UPC'
|
||||
AND is_active = true
|
||||
AND kind = 'proceeding'
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147
|
||||
--
|
||||
-- Best-effort rollback of mig 153. Restores the pre-mig state of
|
||||
-- paliad.proceeding_types from the same-TX snapshot, drops the kind
|
||||
-- column, drops the backstop trigger.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 153 down: revert proceeding_types kind discriminator',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Drop the backstop trigger + function.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||
ON paliad.projects;
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check();
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Restore is_active flags from the snapshot. We only touch rows
|
||||
-- whose is_active value diverged from the snapshot — i.e. the 23
|
||||
-- rows that mig 153 §4 deactivated.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types pt
|
||||
SET is_active = pre.is_active
|
||||
FROM paliad.proceeding_types_pre_153 pre
|
||||
WHERE pt.id = pre.id
|
||||
AND pt.is_active IS DISTINCT FROM pre.is_active;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Drop the kind column (cascades the index).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS kind;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Drop the snapshot table.
|
||||
-- (The CHECK constraint on the kind column is dropped implicitly
|
||||
-- when the column is dropped.)
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DROP TABLE IF EXISTS paliad.proceeding_types_pre_153;
|
||||
|
||||
COMMIT;
|
||||
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
@@ -0,0 +1,201 @@
|
||||
-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147
|
||||
--
|
||||
-- Purpose: tag every paliad.proceeding_types row with a structural
|
||||
-- classification so the Mode B R3 wizard (Fristenrechner overhaul,
|
||||
-- m/paliad#146), the projects.proceeding_type_id binding, and the
|
||||
-- pkg/litigationplanner snapshot can filter to primary proceedings
|
||||
-- only — separating self-contained matters from CFI phases,
|
||||
-- in-proceeding side-actions, and cross-cutting RoP/admin rows.
|
||||
--
|
||||
-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md
|
||||
-- §0–§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion
|
||||
-- batch; "proceed, sure" greenlight at 09:57).
|
||||
--
|
||||
-- This mig is purely additive: ALTER TABLE adds the kind column with
|
||||
-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a
|
||||
-- BEFORE INSERT/UPDATE trigger backstops the new
|
||||
-- "projects.proceeding_type_id must point at kind='proceeding'"
|
||||
-- invariant. The 23 rows being reclassified have zero downstream
|
||||
-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0
|
||||
-- projects bind, 0 event_category_concepts reference) so no FK
|
||||
-- reparenting is needed — verified via Supabase MCP 2026-05-27
|
||||
-- before write.
|
||||
--
|
||||
-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/
|
||||
-- 140/151/152):
|
||||
-- * No deletions. Non-primary rows flip is_active=false but stay in
|
||||
-- the table for audit + future re-activation.
|
||||
-- * Snapshot the affected proceeding_types into
|
||||
-- paliad.proceeding_types_pre_153 in the same TX.
|
||||
-- * set_config('paliad.audit_reason') is defensively called even
|
||||
-- though no audit trigger fires on proceeding_types today; a
|
||||
-- future audit trigger would inherit the reason automatically.
|
||||
-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS
|
||||
-- semantics through golang-migrate's tracker (mig only fires
|
||||
-- once); the UPDATEs only touch rows that match the explicit ID
|
||||
-- list from the ratified design §3.2 / §10.2.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 153: proceeding_types kind discriminator (m/paliad#147)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Snapshot the pre-mig state for audit + rollback safety.
|
||||
-- Mirrors precedent: sequencing_rules_pre_151/_pre_152,
|
||||
-- procedural_events_pre_151.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.proceeding_types_pre_153 AS
|
||||
SELECT * FROM paliad.proceeding_types;
|
||||
|
||||
COMMENT ON TABLE paliad.proceeding_types_pre_153 IS
|
||||
'Snapshot of paliad.proceeding_types taken in the same TX as '
|
||||
'mig 153 (kind discriminator). Audit + rollback safety per the '
|
||||
'precedent set by migs 091/093/095/098/140/151/152. Drop only '
|
||||
'when the kind taxonomy has held in prod for at least one '
|
||||
'release cycle and no rollback is anticipated.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Add the kind column.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Reclassify the 23 non-primary rows.
|
||||
-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps
|
||||
-- upc.costs.cfi (176) as kind='proceeding' (defaults to that);
|
||||
-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185);
|
||||
|
||||
-- 3.2 Side-actions: 10 rows (§0.4 Group C).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183);
|
||||
|
||||
-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169);
|
||||
|
||||
-- 3.4 Defensive integrity check — every reclassified ID must have been
|
||||
-- reached. If the live table drifted between design (2026-05-26)
|
||||
-- and apply, this raises before the trigger ships.
|
||||
DO $$
|
||||
DECLARE
|
||||
expected int := 23;
|
||||
actual int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO actual
|
||||
FROM paliad.proceeding_types
|
||||
WHERE kind <> 'proceeding';
|
||||
IF actual <> expected THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 153] expected % rows reclassified to non-proceeding kind, found % — '
|
||||
'live IDs drifted from the design. Abort.',
|
||||
expected, actual;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Per m's Q9: deactivate the non-primary rows so the admin list
|
||||
-- surfaces only primaries. The kind column carries the semantic
|
||||
-- info; is_active controls UI visibility. Reversible — flip
|
||||
-- is_active back on if a row gains corpus.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8).
|
||||
-- Complements mig 088's category check; rejects any
|
||||
-- INSERT/UPDATE that would bind a project to a non-proceeding
|
||||
-- kind. Independent from the category trigger so each invariant
|
||||
-- can be dropped in isolation.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_kind text;
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT kind INTO v_kind
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id;
|
||||
|
||||
IF v_kind IS NULL THEN
|
||||
-- FK should have caught this; defensive for any future FK relax.
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id = % does not resolve to a '
|
||||
'proceeding_types row — FK constraint should have caught this.',
|
||||
NEW.proceeding_type_id;
|
||||
END IF;
|
||||
|
||||
IF v_kind <> 'proceeding' THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' '
|
||||
'proceeding_types row (got kind=''%''). '
|
||||
'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). '
|
||||
'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine '
|
||||
'wählbaren Projekt-Verfahrenstypen.',
|
||||
v_kind, v_kind
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS
|
||||
'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 '
|
||||
'invariant: paliad.projects.proceeding_type_id may only '
|
||||
'reference kind=''proceeding'' proceeding_types rows. NULL is '
|
||||
'allowed. Complements mig 088''s category check.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||
ON paliad.projects;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check();
|
||||
|
||||
COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS
|
||||
'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects '
|
||||
'any INSERT/UPDATE that would bind a project to a phase/'
|
||||
'side_action/meta proceeding_types row. The Go service layer '
|
||||
'also enforces this with a typed error; this trigger is the '
|
||||
'defence-in-depth backstop.';
|
||||
|
||||
COMMIT;
|
||||
@@ -58,6 +58,14 @@ var (
|
||||
// surface this as a 400 with a bilingual friendly message; the
|
||||
// matching DB trigger (mig 088) is the defence-in-depth backstop.
|
||||
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
|
||||
// ErrInvalidProceedingTypeKind signals that the caller supplied a
|
||||
// proceeding_type_id pointing at a non-primary row — i.e. a
|
||||
// phase/side_action/meta row, or an inactive row. Mig 153
|
||||
// (t-paliad-325, design §1) carved the taxonomy so only
|
||||
// kind='proceeding' AND is_active=true rows may bind to a
|
||||
// project. Handlers surface this as a 400; the matching DB
|
||||
// trigger (mig 153) is the defence-in-depth backstop.
|
||||
ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
|
||||
)
|
||||
|
||||
// ProjectType values enumerated on the projects.type CHECK constraint.
|
||||
@@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
|
||||
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
|
||||
// to a fristenrechner-category proceeding_types row. NULL passes
|
||||
// through; the matching DB trigger (mig 088) is the defence-in-depth
|
||||
// backstop should this slip somehow.
|
||||
// validateProceedingTypeCategory enforces the project-binding invariants
|
||||
// on paliad.projects.proceeding_type_id:
|
||||
//
|
||||
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
|
||||
// 400 with a bilingual user-facing message.
|
||||
// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
|
||||
// category='fristenrechner'. DB-side backstop: mig 088 trigger.
|
||||
// Surfaces ErrInvalidProceedingTypeCategory.
|
||||
//
|
||||
// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
|
||||
// kind='proceeding' AND is_active=true. DB-side backstop: mig 153
|
||||
// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
|
||||
// side_action / meta rows and any deactivated row.
|
||||
//
|
||||
// NULL passes through. The Go layer fires first so handlers get typed
|
||||
// errors; the DB triggers catch any writer that bypasses the service.
|
||||
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
|
||||
if ptID == nil {
|
||||
return nil
|
||||
}
|
||||
var category sql.NullString
|
||||
if err := s.db.GetContext(ctx, &category,
|
||||
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||
var row struct {
|
||||
Category sql.NullString `db:"category"`
|
||||
Kind sql.NullString `db:"kind"`
|
||||
IsActive bool `db:"is_active"`
|
||||
}
|
||||
if err := s.db.GetContext(ctx, &row,
|
||||
`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
|
||||
}
|
||||
return fmt.Errorf("lookup proceeding_type category: %w", err)
|
||||
return fmt.Errorf("lookup proceeding_type: %w", err)
|
||||
}
|
||||
if !category.Valid || category.String != "fristenrechner" {
|
||||
if !row.Category.Valid || row.Category.String != "fristenrechner" {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
|
||||
ErrInvalidProceedingTypeCategory, *ptID, category.String)
|
||||
ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
|
||||
}
|
||||
if !row.Kind.Valid || row.Kind.String != "proceeding" {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
|
||||
ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
|
||||
}
|
||||
if !row.IsActive {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
|
||||
ErrInvalidProceedingTypeKind, *ptID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectService_ProceedingTypeKindGuard exercises the mig 153
|
||||
// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on
|
||||
// paliad.projects.proceeding_type_id from three angles:
|
||||
//
|
||||
// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||
// handed an id pointing at a kind='phase' / 'side_action' / 'meta'
|
||||
// row (the Go service guard fires before the DB trigger).
|
||||
//
|
||||
// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||
// handed an id pointing at a row with is_active=false (mig 153 §4
|
||||
// deactivated all non-primary rows so this is the same set of IDs;
|
||||
// the test still independently asserts the is_active branch by
|
||||
// re-activating a phase row inside the test and confirming the kind
|
||||
// check still fires).
|
||||
//
|
||||
// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses
|
||||
// the Go service layer (defence-in-depth). Bypasses mig 088's
|
||||
// category trigger by also picking a fristenrechner-category row.
|
||||
//
|
||||
// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still
|
||||
// succeeds — proves the new guard doesn't break the happy path.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this
|
||||
// file.
|
||||
func TestProjectService_ProceedingTypeKindGuard(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()
|
||||
|
||||
// A row that is fristenrechner-category but kind != 'proceeding'.
|
||||
// Picks the first phase row by id (deterministic). Falls back to any
|
||||
// non-proceeding kind if no phase rows are present (post-data-drift
|
||||
// hardening).
|
||||
var phaseID int
|
||||
if err := pool.GetContext(ctx, &phaseID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND kind <> 'proceeding'
|
||||
ORDER BY (kind = 'phase') DESC, id
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("look up non-proceeding kind id: %v", err)
|
||||
}
|
||||
|
||||
// A primary id for the happy-path case + raw-INSERT control.
|
||||
var proceedingID int
|
||||
if err := pool.GetContext(ctx, &proceedingID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND kind = 'proceeding'
|
||||
AND is_active = true AND code = $1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
|
||||
}
|
||||
|
||||
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, 'mig153-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, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the
|
||||
// service guard. (The row is also is_active=false post-mig-153,
|
||||
// but the kind check fires first.)
|
||||
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — non-proceeding-kind reject",
|
||||
ProceedingTypeID: &phaseID,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded")
|
||||
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||
}
|
||||
|
||||
// 2. Re-activate the phase row in a savepoint so the kind check
|
||||
// still fires (proves the kind branch isn't shadowed by the
|
||||
// is_active branch).
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil {
|
||||
t.Fatalf("re-activate phase row: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.ExecContext(ctx,
|
||||
`UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID)
|
||||
})
|
||||
|
||||
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — active phase row still rejects on kind",
|
||||
ProceedingTypeID: &phaseID,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Create with active kind=phase row should still fail on kind check; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||
}
|
||||
|
||||
// 3. mig 153 trigger — raw INSERT bypassing Go service must raise.
|
||||
// We use the active phase row (still re-activated from step 2)
|
||||
// so we don't trip mig 088's category check first. Both triggers
|
||||
// are independent; mig 153's must fire on a category=fristenrechner
|
||||
// kind!=proceeding row.
|
||||
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, 'Mig 153 — trigger bypass', 'active', $2,
|
||||
$3, '{}'::jsonb, now(), now())`,
|
||||
rawID, userID, phaseID)
|
||||
if err == nil {
|
||||
t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil")
|
||||
}
|
||||
|
||||
// 4. Happy path: kind='proceeding' active id → success.
|
||||
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Mig 153 — primary proceeding accept",
|
||||
ProceedingTypeID: &proceedingID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err)
|
||||
}
|
||||
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID {
|
||||
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user