Files
paliad/internal/services/project_service_test.go
mAi a55f45ebea feat(t-paliad-189): instance_level on project Create/Update
Phase 3 Slice 8 part 1 — wire the instance_level data field (mig 080
column, shipped in Slice 1) through the project service + handler.

  - CreateProjectInput / UpdateProjectInput gain InstanceLevel *string.
    Empty string is the explicit "clear" sentinel.
  - validateInstanceLevel + nullableInstanceLevel helpers mirror the
    OurSide pattern. Allowed values per mig 080 CHECK: 'first' |
    'appeal' | 'cassation' | NULL.
  - Service rejects bad values with ErrInvalidInput (existing handler
    error-mapping surfaces this as HTTP 400 with the standard message).
  - projectColumns SELECT now includes instance_level so reads
    populate the field; Project struct already has the field from
    Slice 1.
  - handleCreateProject accepts instance_level from the raw map; Update
    handler uses the standard JSON decoder into UpdateProjectInput.

Live-DB test exercises:
  - Create with instance_level='first' → roundtrips.
  - Update to 'appeal' → roundtrips.
  - Update to '' → NULL after the trip.
  - Update to 'supreme' → ErrInvalidInput.

The DB CHECK on mig 080 is the defence-in-depth backstop should an
SQL-direct INSERT bypass the service.
2026-05-15 01:28:45 +02:00

240 lines
8.5 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 litigation-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 litigation-category id
// INSERT via plain SQL must raise EXCEPTION.
//
// 4. Passing a fristenrechner-category id (UPC_INF) succeeds.
//
// 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 litigation id,
// success on fristenrechner id.
// -----------------------------------------------------------------
var litigationID int
if err := pool.GetContext(ctx, &litigationID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'litigation' AND code = 'INF' AND is_active = true`); err != nil {
t.Fatalf("look up INF 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. Litigation-category id → ErrInvalidProceedingTypeCategory.
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Slice 5 — litigation-id reject",
ProceedingTypeID: &litigationID,
})
if err == nil {
t.Error("Create with litigation-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, litigationID)
if err == nil {
t.Error("raw INSERT with litigation-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)
}
}