package services // Approval-service tests. Two layers: // // - Pure-Go: professionLevel strict ladder + IsValidRequiredRole + // responsibilityOpensGate (t-paliad-148). No DB touch. // - Live-DB: the full submit→approve and submit→reject flows on real // paliad.deadlines / paliad.approval_requests rows. Skipped when // TEST_DATABASE_URL is unset, mirroring audit_service_test and // deadline_service_test. import ( "context" "errors" "os" "testing" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // ============================================================================ // Pure-Go tests. // ============================================================================ func TestProfessionLevel_StrictLadder(t *testing.T) { cases := []struct { profession string want int }{ {"partner", 5}, {"of_counsel", 4}, {"associate", 3}, {"senior_pa", 2}, {"pa", 1}, {"paralegal", 0}, {"", 0}, {"unknown", 0}, // Legacy values that pre-dated the t-paliad-148 split must NOT // satisfy the ladder. The SQL helper still recognises 'lead' as a // deprecated-shadow row until migration 058; the Go helper does // not — call sites have all migrated to read users.profession. {"lead", 0}, {"local_counsel", 0}, {"expert", 0}, {"observer", 0}, } for _, c := range cases { t.Run(c.profession, func(t *testing.T) { if got := professionLevel(c.profession); got != c.want { t.Errorf("professionLevel(%q) = %d, want %d", c.profession, got, c.want) } }) } } func TestProfessionLevel_NilIsZero(t *testing.T) { // CRITICAL trap pin: NULL profession (empty string in Go) returns 0, // not "default to associate" or anything similar. This is what gates // external collaborators (local_counsel, expert) out of the approval // ladder when their project responsibility is set to 'external' but // their users.profession is also set to a real tier by mistake. if got := professionLevel(""); got != 0 { t.Errorf("professionLevel(\"\") must be 0, got %d — NULL profession is ineligible", got) } } func TestProfessionLevel_HigherSatisfiesLower(t *testing.T) { // "Anyone strictly above the required level satisfies it" — verify by // asserting the ladder is monotonic. if professionLevel("partner") <= professionLevel("associate") { t.Errorf("partner must outrank associate") } if professionLevel("associate") <= professionLevel("senior_pa") { t.Errorf("associate must outrank senior_pa") } if professionLevel("senior_pa") <= professionLevel("pa") { t.Errorf("senior_pa must outrank pa") } if professionLevel("of_counsel") <= professionLevel("associate") { t.Errorf("of_counsel must outrank associate") } // PA-required policy: anyone associate-or-above must satisfy. if professionLevel("associate") < professionLevel("pa") { t.Errorf("associate must satisfy a pa-required policy") } } func TestResponsibilityOpensGate(t *testing.T) { cases := []struct { responsibility string open bool }{ {"lead", true}, {"member", true}, {"observer", false}, {"external", false}, {"", false}, {"unknown", false}, } for _, c := range cases { t.Run(c.responsibility, func(t *testing.T) { if got := responsibilityOpensGate(c.responsibility); got != c.open { t.Errorf("responsibilityOpensGate(%q) = %v, want %v", c.responsibility, got, c.open) } }) } } func TestIsValidRequiredRole(t *testing.T) { cases := []struct { role string ok bool }{ {"partner", true}, {"of_counsel", true}, {"associate", true}, {"senior_pa", true}, {"pa", true}, {"paralegal", false}, // Legacy values that pre-dated the t-paliad-148 split must be // rejected as policy targets. {"lead", false}, {"local_counsel", false}, {"expert", false}, {"observer", false}, {"", false}, // 'none' is the t-paliad-154 sentinel for explicit suppression — it // is NOT a valid required_role for the gate (level 0). Use // IsValidPolicyRole if you want to allow it as a stored value. {"none", false}, } for _, c := range cases { t.Run(c.role, func(t *testing.T) { if got := IsValidRequiredRole(c.role); got != c.ok { t.Errorf("IsValidRequiredRole(%q) = %v, want %v", c.role, got, c.ok) } }) } } // TestIsValidPolicyRole pins the t-paliad-154 helper used by Upsert*Policy: // it accepts the strict-ladder roles AND the 'none' sentinel that suppresses // inherited defaults at project-row level. func TestIsValidPolicyRole(t *testing.T) { cases := []struct { role string ok bool }{ {"partner", true}, {"of_counsel", true}, {"associate", true}, {"senior_pa", true}, {"pa", true}, {"none", true}, // sentinel {"paralegal", false}, {"lead", false}, {"observer", false}, {"", false}, } for _, c := range cases { t.Run(c.role, func(t *testing.T) { if got := IsValidPolicyRole(c.role); got != c.ok { t.Errorf("IsValidPolicyRole(%q) = %v, want %v", c.role, got, c.ok) } }) } } func TestIsValidProfession(t *testing.T) { for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} { t.Run(p, func(t *testing.T) { if !IsValidProfession(p) { t.Errorf("IsValidProfession(%q) must be true", p) } }) } for _, p := range []string{"", "lead", "junior_associate", "trainee", "unknown"} { t.Run("invalid_"+p, func(t *testing.T) { if IsValidProfession(p) { t.Errorf("IsValidProfession(%q) must be false", p) } }) } } func TestIsValidResponsibility(t *testing.T) { // t-paliad-223 added 'admin'; the four legacy values stay valid. for _, r := range []string{"admin", "lead", "member", "observer", "external"} { t.Run(r, func(t *testing.T) { if !IsValidResponsibility(r) { t.Errorf("IsValidResponsibility(%q) must be true", r) } }) } for _, r := range []string{"", "associate", "lead2", "unknown"} { t.Run("invalid_"+r, func(t *testing.T) { if IsValidResponsibility(r) { t.Errorf("IsValidResponsibility(%q) must be false", r) } }) } } // t-paliad-223: admin maps to legacy 'lead' for the deprecated shadow // column. The other mappings are unchanged from t-paliad-148. Pin them // so a future refactor doesn't silently flip them. func TestLegacyRoleFromResponsibility(t *testing.T) { cases := []struct { in, want string }{ {ResponsibilityAdmin, "lead"}, {ResponsibilityLead, "lead"}, {ResponsibilityObserver, "observer"}, {ResponsibilityExternal, "local_counsel"}, {ResponsibilityMember, "associate"}, {"", "associate"}, // unknown / empty falls through to associate } for _, c := range cases { t.Run(c.in, func(t *testing.T) { got := legacyRoleFromResponsibility(c.in) if got != c.want { t.Errorf("legacyRoleFromResponsibility(%q) = %q, want %q", c.in, got, c.want) } }) } } func TestApprovalEventType(t *testing.T) { cases := []struct { entity, step, want string }{ {"deadline", "requested", "deadline_approval_requested"}, {"deadline", "approved", "deadline_approval_approved"}, {"deadline", "rejected", "deadline_approval_rejected"}, {"deadline", "revoked", "deadline_approval_revoked"}, {"appointment", "requested", "appointment_approval_requested"}, } for _, c := range cases { if got := approvalEventType(c.entity, c.step); got != c.want { t.Errorf("approvalEventType(%q,%q) = %q, want %q", c.entity, c.step, got, c.want) } } } // ============================================================================ // Live-DB tests. // ============================================================================ // approvalTestEnv holds a configured ApprovalService + helpers tied to a // throwaway project / user pool. Caller cleans up via env.cleanup(). type approvalTestEnv struct { t *testing.T pool *sqlx.DB approvals *ApprovalService deadlines *DeadlineService users *UserService projects *ProjectService projectID uuid.UUID requester uuid.UUID approver uuid.UUID other uuid.UUID cleanup func() } func setupApprovalTest(t *testing.T) *approvalTestEnv { t.Helper() 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) } ctx := context.Background() users := NewUserService(pool) projects := NewProjectService(pool, users) deadlines := NewDeadlineService(pool, projects, nil) approvals := NewApprovalService(pool, users) // Seed two users + one project. The requester owns the deadline; the // approver is the other lead on the team. "other" has no role and is // used for the deadlock check (no qualified approver scenario). requesterID := uuid.New() approverID := uuid.New() otherID := uuid.New() for _, id := range []uuid.UUID{requesterID, approverID, otherID} { if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local') ON CONFLICT (id) DO NOTHING`, id); err != nil { t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.users (id, email, display_name, office, global_role) VALUES ($1, $1::text || '@test.local', 'Test User', 'munich', 'standard') ON CONFLICT (id) DO NOTHING`, id); err != nil { t.Fatalf("seed paliad.users: %v", err) } } projectID := uuid.New() if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, title, status, created_by) VALUES ($1, 'project', 'Approval Test Project', 'active', $2)`, projectID, requesterID); err != nil { t.Fatalf("seed project: %v", err) } // Add requester + approver to the project team. Requester=associate // (cannot approve associate-required policy), approver=lead (can). for _, m := range []struct { uid uuid.UUID role string }{ {requesterID, "associate"}, {approverID, "lead"}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_teams (project_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, projectID, m.uid, m.role); err != nil { t.Fatalf("seed project_teams: %v", err) } } cleanup := func() { ctx := context.Background() pool.ExecContext(ctx, `DELETE FROM paliad.approval_requests WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.approval_policies WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) for _, id := range []uuid.UUID{requesterID, approverID, otherID} { pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id) } pool.Close() } return &approvalTestEnv{ t: t, pool: pool, approvals: approvals, deadlines: deadlines, users: users, projects: projects, projectID: projectID, requester: requesterID, approver: approverID, other: otherID, cleanup: cleanup, } } // seedPolicy sets a policy on the env's project for one (entity, lifecycle). func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) { e.t.Helper() if _, err := e.approvals.UpsertProjectPolicy(context.Background(), e.requester, e.projectID, entityType, lifecycle, requiredRole); err != nil { e.t.Fatalf("seed policy: %v", err) } } // seedDeadline inserts a basic deadline row directly (bypassing the // service so we can test ApprovalService.Submit* in isolation). Returns // the deadline's ID. func (e *approvalTestEnv) seedDeadline(due time.Time) uuid.UUID { e.t.Helper() id := uuid.New() if _, err := e.pool.ExecContext(context.Background(), `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by, approval_status) VALUES ($1, $2, 'Test Deadline', $3, 'manual', 'pending', $4, 'approved')`, id, e.projectID, due, e.requester); err != nil { e.t.Fatalf("seed deadline: %v", err) } return id } // TestApprovalService_NoPolicyIsNoop: with no policy, Submit* returns // (nil, nil) and the entity stays approval_status='approved'. func TestApprovalService_NoPolicyIsNoop(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } defer tx.Rollback() reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if err != nil { t.Fatalf("SubmitCreate: %v", err) } if reqID != nil { t.Errorf("expected nil request id with no policy, got %v", reqID) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } var status string if err := env.pool.GetContext(ctx, &status, `SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read status: %v", err) } if status != "approved" { t.Errorf("expected approval_status=approved, got %q", status) } } // TestApprovalService_SubmitMarksPendingAndApproveClears: end-to-end happy // path. With a policy in place: submit → request row + entity pending → // approve → entity back to approved with approved_by set. func TestApprovalService_SubmitApproveCycle(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) // Submit (inside a tx, as DeadlineService would). tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, map[string]any{"due_date": "2026-05-20"}) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if reqID == nil { tx.Rollback() t.Fatalf("expected request id, got nil") } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } // Entity is now pending. var status string if err := env.pool.GetContext(ctx, &status, `SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read status: %v", err) } if status != "pending" { t.Errorf("after submit: approval_status=%q, want pending", status) } // Self-approval blocks. if err := env.approvals.Approve(ctx, *reqID, env.requester, ""); !errors.Is(err, ErrSelfApproval) { t.Errorf("self-approve: got %v, want ErrSelfApproval", err) } // Approver (lead) signs off. if err := env.approvals.Approve(ctx, *reqID, env.approver, "looks good"); err != nil { t.Fatalf("Approve: %v", err) } // Entity flipped back to approved with approved_by populated. row := struct { Status string `db:"approval_status"` ApprovedBy *uuid.UUID `db:"approved_by"` }{} if err := env.pool.GetContext(ctx, &row, `SELECT approval_status, approved_by FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read post-approve: %v", err) } if row.Status != "approved" { t.Errorf("after approve: approval_status=%q, want approved", row.Status) } if row.ApprovedBy == nil || *row.ApprovedBy != env.approver { t.Errorf("after approve: approved_by=%v, want %v", row.ApprovedBy, env.approver) } // Request row marked approved. var reqStatus string if err := env.pool.GetContext(ctx, &reqStatus, `SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil { t.Fatalf("read request status: %v", err) } if reqStatus != "approved" { t.Errorf("request status=%q, want approved", reqStatus) } // Approving again fails (not pending anymore). if err := env.approvals.Approve(ctx, *reqID, env.approver, ""); !errors.Is(err, ErrRequestNotPending) { t.Errorf("re-approve: got %v, want ErrRequestNotPending", err) } } // TestApprovalService_RejectRevertsCreateAsDelete: rejecting a CREATE // request hard-deletes the entity (it never should have existed). func TestApprovalService_RejectCreateDeletes(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 7)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } if err := env.approvals.Reject(ctx, *reqID, env.approver, "wrong date"); err != nil { t.Fatalf("Reject: %v", err) } // Entity row is gone. var n int if err := env.pool.GetContext(ctx, &n, `SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("count deadline: %v", err) } if n != 0 { t.Errorf("after reject-create: deadline still exists (count=%d)", n) } } // TestApprovalService_RejectUpdateRestoresPreImage: rejecting an UPDATE // reverts the date fields back to the snapshotted pre_image values. func TestApprovalService_RejectUpdateRestoresPreImage(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate") originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) deadlineID := env.seedDeadline(originalDue) // Simulate an update: set due to 2026-06-15, then submit. newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`, newDue, deadlineID); err != nil { tx.Rollback() t.Fatalf("UPDATE pre-submit: %v", err) } preImage := map[string]any{"due_date": "2026-06-01"} payload := map[string]any{"due_date": "2026-06-15"} reqID, err := env.approvals.SubmitUpdate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, preImage, payload) if err != nil { tx.Rollback() t.Fatalf("SubmitUpdate: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } // Reject — due_date should snap back to 2026-06-01. if err := env.approvals.Reject(ctx, *reqID, env.approver, ""); err != nil { t.Fatalf("Reject: %v", err) } var got time.Time if err := env.pool.GetContext(ctx, &got, `SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read due_date: %v", err) } if !got.Equal(originalDue) { t.Errorf("after reject-update: due_date=%v, want %v", got, originalDue) } } // TestApprovalService_NoQualifiedApprover: when only the requester would // qualify, Submit returns ErrNoQualifiedApprover. func TestApprovalService_NoQualifiedApprover(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() // Demote the approver to observer (level 0 = ineligible). Now requester // (associate) is the only on-team user with any role, and observer // can't approve. if _, err := env.pool.ExecContext(ctx, `UPDATE paliad.project_teams SET role='observer' WHERE project_id=$1 AND user_id=$2`, env.projectID, env.approver); err != nil { t.Fatalf("demote approver: %v", err) } // Make sure no global_admin exists in our test pool — promote-and-revert // any existing global_admin so the deadlock kicks in. We can't safely do // that without affecting other tests, so use a project where the // requester is the only person + setup excludes other users. // Easier approach: temporarily set requester to global_admin, then test // against a different "pretend requester" — but we want the case where // our seeded requester is the only candidate. // // Approach: use UpsertPolicy to set 'lead' as required role. Then no // project team member (associate, observer) qualifies. The deadlock // check still passes if any global_admin exists firmwide (Q8 escape // hatch), so we accept this test may be a no-op on pools with admins. env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "lead") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) // Count global admins; if any exist (e.g. m or tester) the deadlock // path can't fire — skip with a note. var nAdmins int if err := env.pool.GetContext(ctx, &nAdmins, `SELECT COUNT(*) FROM paliad.users WHERE global_role='global_admin' AND id <> $1`, env.requester); err != nil { t.Fatalf("count admins: %v", err) } if nAdmins > 0 { t.Skip("global_admin exists in test pool — deadlock fallback hides ErrNoQualifiedApprover; covered indirectly via canApprove unit checks") } tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } defer tx.Rollback() _, err = env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if !errors.Is(err, ErrNoQualifiedApprover) { t.Errorf("got %v, want ErrNoQualifiedApprover", err) } } // TestApprovalService_RevokeRevertsAndMarksRevoked: requester revokes // their own pending → entity reverts, request status='revoked'. func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } // Non-requester can't revoke. if err := env.approvals.Revoke(ctx, *reqID, env.approver); !errors.Is(err, ErrNotApprover) { t.Errorf("non-requester revoke: got %v, want ErrNotApprover", err) } // Requester revokes — succeeds. Create lifecycle = entity gets deleted. if err := env.approvals.Revoke(ctx, *reqID, env.requester); err != nil { t.Fatalf("Revoke: %v", err) } var n int if err := env.pool.GetContext(ctx, &n, `SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("count: %v", err) } if n != 0 { t.Errorf("after revoke-create: entity should be gone (count=%d)", n) } var reqStatus string if err := env.pool.GetContext(ctx, &reqStatus, `SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil { t.Fatalf("read request: %v", err) } if reqStatus != "revoked" { t.Errorf("request status=%q, want revoked", reqStatus) } } // TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete. // Uses post-t-paliad-148 profession enum (partner replaced legacy 'lead') // and post-t-paliad-154 method names (UpsertProjectPolicy / etc). func TestApprovalService_PolicyCRUD(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() // Upsert two rows. if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil { t.Fatalf("upsert 1: %v", err) } if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeAppointment, LifecycleUpdate, "partner"); err != nil { t.Fatalf("upsert 2: %v", err) } // List. got, err := env.approvals.ListProjectPolicies(ctx, env.projectID) if err != nil { t.Fatalf("list: %v", err) } if len(got) != 2 { t.Errorf("list returned %d rows, want 2", len(got)) } // Re-upsert the first to a different role. if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "partner"); err != nil { t.Fatalf("re-upsert: %v", err) } got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID) for _, p := range got { if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate { gotRole := "" if p.MinRole != nil { gotRole = *p.MinRole } if gotRole != "partner" { t.Errorf("after re-upsert: min_role=%q, want partner", gotRole) } } } // Invalid role rejected. if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) { t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err) } // 'none' sentinel accepted (suppresses inherited defaults). if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleDelete, "none"); err != nil { t.Errorf("'none' sentinel rejected: %v", err) } // Delete. if err := env.approvals.DeleteProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil { t.Fatalf("delete: %v", err) } got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID) if len(got) != 2 { // appointment.update + deadline.delete='none' remain t.Errorf("after delete: %d rows, want 2", len(got)) } } // TestApprovalService_ListSubmittedByUser_PendingVisible pins t-paliad-160 // §D: a user with one pending approval_request must see it on /api/inbox/mine // — neither the service nor the handler may filter pending rows out of the // "Meine Anfragen" view. The only legitimate filter is the explicit // ?status=... query parameter, which the handler validates against an // allowlist (everything else is ignored). func TestApprovalService_ListSubmittedByUser_PendingVisible(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if reqID == nil { tx.Rollback() t.Fatal("SubmitCreate returned nil request id") } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } // No filter — must include the pending row authored by env.requester. rows, err := env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{}) if err != nil { t.Fatalf("ListSubmittedByUser: %v", err) } if len(rows) != 1 { t.Fatalf("len(rows) = %d, want 1 — pending row must surface on Meine Anfragen", len(rows)) } if rows[0].ID != *reqID { t.Errorf("rows[0].ID = %s, want %s", rows[0].ID, *reqID) } if rows[0].Status != RequestStatusPending { t.Errorf("rows[0].Status = %q, want pending", rows[0].Status) } // Explicit ?status=pending filter — same row. rows, err = env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{Status: RequestStatusPending}) if err != nil { t.Fatalf("ListSubmittedByUser status=pending: %v", err) } if len(rows) != 1 { t.Errorf("status=pending filter: len(rows) = %d, want 1", len(rows)) } // Explicit ?status=approved filter — empty (the row is pending). rows, err = env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{Status: RequestStatusApproved}) if err != nil { t.Fatalf("ListSubmittedByUser status=approved: %v", err) } if len(rows) != 0 { t.Errorf("status=approved filter: len(rows) = %d, want 0", len(rows)) } // Different user — empty (this is "MY submissions", scoped by requested_by). rows, err = env.approvals.ListSubmittedByUser(ctx, env.approver, InboxFilter{}) if err != nil { t.Fatalf("ListSubmittedByUser other user: %v", err) } if len(rows) != 0 { t.Errorf("other user: len(rows) = %d, want 0 — must scope by requested_by", len(rows)) } } // TestApprovalService_ViewerFlags pins the per-viewer eligibility flags on // ApprovalRequestView (t-paliad-202). Drives /inbox grey-out of // Genehmigen/Ablehnen/Zurückziehen instead of click-then-error. // // Matrix (one pending request, four viewers): // // viewer viewer_can_approve viewer_is_requester // requester (self) false true → only Zurückziehen // approver (peer) true false → Genehmigen + Ablehnen // other (no team) false false → all three disabled // global_admin true false → Genehmigen + Ablehnen func TestApprovalService_ViewerFlags(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() // Profession + global_role tuning: the live-DB seed gives every user // global_role='standard' + profession=NULL, which means nobody is // eligible by default. Promote requester→associate (matches threshold) // and approver→partner (above threshold), and create a fourth user // with global_role='global_admin' (the override branch). if _, err := env.pool.ExecContext(ctx, `UPDATE paliad.users SET profession = 'associate' WHERE id = $1`, env.requester); err != nil { t.Fatalf("set requester profession: %v", err) } if _, err := env.pool.ExecContext(ctx, `UPDATE paliad.users SET profession = 'partner' WHERE id = $1`, env.approver); err != nil { t.Fatalf("set approver profession: %v", err) } adminID := uuid.New() if _, err := env.pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local') ON CONFLICT (id) DO NOTHING`, adminID); err != nil { t.Logf("skip auth.users seed for admin: %v (continuing)", err) } if _, err := env.pool.ExecContext(ctx, `INSERT INTO paliad.users (id, email, display_name, office, global_role) VALUES ($1, $1::text || '@test.local', 'Admin', 'munich', 'global_admin') ON CONFLICT (id) DO UPDATE SET global_role = 'global_admin'`, adminID); err != nil { t.Fatalf("seed admin: %v", err) } defer func() { ctx := context.Background() env.pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID) env.pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID) }() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if reqID == nil { tx.Rollback() t.Fatal("SubmitCreate returned nil request id") } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } cases := []struct { name string viewer uuid.UUID wantCanApprove bool wantIsRequester bool }{ {"self_authored", env.requester, false, true}, {"eligible_approver", env.approver, true, false}, {"non_eligible_viewer", env.other, false, false}, {"global_admin", adminID, true, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { row, err := env.approvals.GetRequest(ctx, c.viewer, *reqID) if err != nil { t.Fatalf("GetRequest: %v", err) } if row == nil { t.Fatal("GetRequest returned nil — request should exist") } if row.ViewerCanApprove != c.wantCanApprove { t.Errorf("viewer_can_approve = %v, want %v", row.ViewerCanApprove, c.wantCanApprove) } if row.ViewerIsRequester != c.wantIsRequester { t.Errorf("viewer_is_requester = %v, want %v", row.ViewerIsRequester, c.wantIsRequester) } }) } // ListPendingForApprover stamps the same flags. The approver runs the // query; they should see one row with viewer_can_approve=true, // viewer_is_requester=false. pending, err := env.approvals.ListPendingForApprover(ctx, env.approver, InboxFilter{}) if err != nil { t.Fatalf("ListPendingForApprover: %v", err) } if len(pending) != 1 { t.Fatalf("len(pending) = %d, want 1", len(pending)) } if !pending[0].ViewerCanApprove { t.Error("ListPendingForApprover: viewer_can_approve = false, want true") } if pending[0].ViewerIsRequester { t.Error("ListPendingForApprover: viewer_is_requester = true, want false") } // ListSubmittedByUser carries them too. Requester runs the query; the // one row must have viewer_can_approve=false (self-approval blocked) // and viewer_is_requester=true. mine, err := env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{}) if err != nil { t.Fatalf("ListSubmittedByUser: %v", err) } if len(mine) != 1 { t.Fatalf("len(mine) = %d, want 1", len(mine)) } if mine[0].ViewerCanApprove { t.Error("ListSubmittedByUser: viewer_can_approve = true on self-authored row, want false") } if !mine[0].ViewerIsRequester { t.Error("ListSubmittedByUser: viewer_is_requester = false on self-authored row, want true") } } // ============================================================================ // SuggestChanges — t-paliad-216 Slice A. The fourth approval action: the // approver authors a counter-proposal which becomes a NEW pending row // requested by the approver. 4-Augen still holds via the standard // self-approval guard. // ============================================================================ // seedPendingUpdate spins up the {policy, deadline, pending update // request} triple SuggestChanges needs. Returns the deadline id, the // pending request id, and the pre-image due_date (so callers can assert // applyRevert restored it correctly). func (e *approvalTestEnv) seedPendingUpdate(t *testing.T) (uuid.UUID, uuid.UUID, time.Time) { t.Helper() ctx := context.Background() e.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate") originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) deadlineID := e.seedDeadline(originalDue) newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) tx, err := e.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`, newDue, deadlineID); err != nil { tx.Rollback() t.Fatalf("UPDATE pre-submit: %v", err) } preImage := map[string]any{"due_date": "2026-06-01"} payload := map[string]any{"due_date": "2026-06-15"} reqID, err := e.approvals.SubmitUpdate(ctx, tx, e.projectID, deadlineID, e.requester, EntityTypeDeadline, preImage, payload) if err != nil { tx.Rollback() t.Fatalf("SubmitUpdate: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } if reqID == nil { t.Fatal("SubmitUpdate returned nil request id") } return deadlineID, *reqID, originalDue } // TestApprovalService_SuggestChanges_HappyPath: approver suggests a // different due_date + note. Expected end state: // - OLD request: status='changes_requested', decision_note set, // counter_payload set, decided_by=approver. // - Entity: approval_status='pending', pending_request_id points at // a NEW pending row, due_date == approver's counter_payload value. // - NEW request: status='pending', requested_by=approver, // payload=counter_payload, previous_request_id=OLD. // - Two project_events emitted: *_approval_changes_suggested and // *_approval_requested. func TestApprovalService_SuggestChanges_HappyPath(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() deadlineID, oldReqID, _ := env.seedPendingUpdate(t) counterDue := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC) counter := map[string]any{"due_date": "2026-06-20"} newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Bitte später, Raumkonflikt am 15.6.") if err != nil { t.Fatalf("SuggestChanges: %v", err) } if newReqID == nil { t.Fatal("expected new request id, got nil") } if *newReqID == oldReqID { t.Fatal("new request id must differ from old") } // OLD row. oldRow := struct { Status string `db:"status"` DecidedBy *uuid.UUID `db:"decided_by"` DecidedAt *time.Time `db:"decided_at"` DecisionNote *string `db:"decision_note"` CounterPayload []byte `db:"counter_payload"` PreviousRequest *uuid.UUID `db:"previous_request_id"` DecisionKind *string `db:"decision_kind"` }{} if err := env.pool.GetContext(ctx, &oldRow, `SELECT status, decided_by, decided_at, decision_note, counter_payload, previous_request_id, decision_kind FROM paliad.approval_requests WHERE id = $1`, oldReqID); err != nil { t.Fatalf("read old row: %v", err) } if oldRow.Status != RequestStatusChangesRequested { t.Errorf("old row status = %q, want %q", oldRow.Status, RequestStatusChangesRequested) } if oldRow.DecidedBy == nil || *oldRow.DecidedBy != env.approver { t.Errorf("old row decided_by = %v, want %v", oldRow.DecidedBy, env.approver) } if oldRow.DecisionNote == nil || *oldRow.DecisionNote == "" { t.Error("old row decision_note should be set") } if len(oldRow.CounterPayload) == 0 { t.Error("old row counter_payload should be set") } if oldRow.PreviousRequest != nil { t.Errorf("old row previous_request_id = %v, want NULL", oldRow.PreviousRequest) } if oldRow.DecisionKind == nil || (*oldRow.DecisionKind != DecisionKindPeer && *oldRow.DecisionKind != DecisionKindAdminOverride) { t.Errorf("old row decision_kind = %v, want peer or admin_override", oldRow.DecisionKind) } // NEW row. newRow := struct { Status string `db:"status"` RequestedBy uuid.UUID `db:"requested_by"` Payload []byte `db:"payload"` PreviousRequestID *uuid.UUID `db:"previous_request_id"` LifecycleEvent string `db:"lifecycle_event"` }{} if err := env.pool.GetContext(ctx, &newRow, `SELECT status, requested_by, payload, previous_request_id, lifecycle_event FROM paliad.approval_requests WHERE id = $1`, *newReqID); err != nil { t.Fatalf("read new row: %v", err) } if newRow.Status != RequestStatusPending { t.Errorf("new row status = %q, want pending", newRow.Status) } if newRow.RequestedBy != env.approver { t.Errorf("new row requested_by = %v, want %v (approver)", newRow.RequestedBy, env.approver) } if newRow.PreviousRequestID == nil || *newRow.PreviousRequestID != oldReqID { t.Errorf("new row previous_request_id = %v, want %v", newRow.PreviousRequestID, oldReqID) } if newRow.LifecycleEvent != LifecycleUpdate { t.Errorf("new row lifecycle = %q, want update", newRow.LifecycleEvent) } // Entity: pending, due_date == counter. entity := struct { Status string `db:"approval_status"` PendingRequest *uuid.UUID `db:"pending_request_id"` DueDate time.Time `db:"due_date"` }{} if err := env.pool.GetContext(ctx, &entity, `SELECT approval_status, pending_request_id, due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read entity: %v", err) } if entity.Status != "pending" { t.Errorf("entity approval_status = %q, want pending", entity.Status) } if entity.PendingRequest == nil || *entity.PendingRequest != *newReqID { t.Errorf("entity pending_request_id = %v, want %v", entity.PendingRequest, *newReqID) } if !entity.DueDate.Equal(counterDue) { t.Errorf("entity due_date = %v, want %v (counter)", entity.DueDate, counterDue) } // Two project_events: one *_approval_changes_suggested + one *_approval_requested // for the NEW row. var nSuggested, nRequested int if err := env.pool.GetContext(ctx, &nSuggested, `SELECT COUNT(*) FROM paliad.project_events WHERE project_id = $1 AND event_type = 'deadline_approval_changes_suggested'`, env.projectID); err != nil { t.Fatalf("count changes_suggested events: %v", err) } if nSuggested != 1 { t.Errorf("expected 1 deadline_approval_changes_suggested event, got %d", nSuggested) } if err := env.pool.GetContext(ctx, &nRequested, `SELECT COUNT(*) FROM paliad.project_events WHERE project_id = $1 AND event_type = 'deadline_approval_requested'`, env.projectID); err != nil { t.Fatalf("count requested events: %v", err) } // Two requested events expected: one from the original SubmitUpdate + // one from the SuggestChanges spawn. if nRequested != 2 { t.Errorf("expected 2 deadline_approval_requested events (original + spawn), got %d", nRequested) } } // TestApprovalService_SuggestChanges_NoOpRejected: identical counter + // empty note returns ErrSuggestionRequiresChange. func TestApprovalService_SuggestChanges_NoOpRejected(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() _, oldReqID, _ := env.seedPendingUpdate(t) // Same payload as the original SubmitUpdate. No note. identical := map[string]any{"due_date": "2026-06-15"} _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "") if !errors.Is(err, ErrSuggestionRequiresChange) { t.Errorf("no-op suggest: got %v, want ErrSuggestionRequiresChange", err) } // Empty counter, empty note → also rejected. _, err = env.approvals.SuggestChanges(ctx, oldReqID, env.approver, nil, "") if !errors.Is(err, ErrSuggestionRequiresChange) { t.Errorf("empty suggest: got %v, want ErrSuggestionRequiresChange", err) } } // TestApprovalService_SuggestChanges_NoteOnlyAccepted: when the counter // is unchanged but a non-empty note is present, the call succeeds. The // new row's payload equals the OLD payload (the approver said "I want a // fresh look from someone else; here's why", without a different value). func TestApprovalService_SuggestChanges_NoteOnlyAccepted(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() deadlineID, oldReqID, _ := env.seedPendingUpdate(t) identical := map[string]any{"due_date": "2026-06-15"} newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "Bitte nochmal prüfen.") if err != nil { t.Fatalf("note-only suggest: %v", err) } if newReqID == nil { t.Fatal("expected new request id, got nil") } // Entity's due_date stays at 2026-06-15 (the original counter == original payload). var got time.Time if err := env.pool.GetContext(ctx, &got, `SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read due_date: %v", err) } want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) if !got.Equal(want) { t.Errorf("entity due_date = %v, want %v", got, want) } } // TestApprovalService_SuggestChanges_SelfApprovalBlocked: the original // requester cannot suggest changes on their own row (would equal // self-approval). func TestApprovalService_SuggestChanges_SelfApprovalBlocked(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() _, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"due_date": "2026-06-20"} _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.requester, counter, "") if !errors.Is(err, ErrSelfApproval) { t.Errorf("self suggest: got %v, want ErrSelfApproval", err) } } // TestApprovalService_SuggestChanges_RequestNotPending: a row already // decided (approved/rejected/revoked/changes_requested) rejects further // suggest-changes calls. func TestApprovalService_SuggestChanges_RequestNotPending(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() _, oldReqID, _ := env.seedPendingUpdate(t) // Approve first. if err := env.approvals.Approve(ctx, oldReqID, env.approver, "ok"); err != nil { t.Fatalf("Approve: %v", err) } counter := map[string]any{"due_date": "2026-06-20"} _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "too late") if !errors.Is(err, ErrRequestNotPending) { t.Errorf("decided row suggest: got %v, want ErrRequestNotPending", err) } } // TestApprovalService_SuggestChanges_LifecycleInvalid: lifecycle ∉ // (update, complete) rejects with ErrSuggestionLifecycleInvalid. A // create-lifecycle pending request is the easiest to set up. func TestApprovalService_SuggestChanges_LifecycleInvalid(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate") deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14)) tx, err := env.pool.BeginTxx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, map[string]any{"due_date": "2026-05-20"}) if err != nil { tx.Rollback() t.Fatalf("SubmitCreate: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } counter := map[string]any{"due_date": "2026-06-01"} _, err = env.approvals.SuggestChanges(ctx, *reqID, env.approver, counter, "different date") if !errors.Is(err, ErrSuggestionLifecycleInvalid) { t.Errorf("create-lifecycle suggest: got %v, want ErrSuggestionLifecycleInvalid", err) } } // TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter: // the cleanest verification of m's Q6 mental model — after the approver // suggests changes, the ORIGINAL REQUESTER is no longer the new row's // requested_by and can now approve the counter themselves (provided // their profession is sufficient). For this test we promote the requester // to 'partner' profession so they pass the canApprove gate. func TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() // Promote the requester so they qualify as an approver of the counter. // The original Submit was theirs (excluded as requested_by); for the // counter their role lets them sign off. if _, err := env.pool.ExecContext(ctx, `UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.requester); err != nil { t.Fatalf("promote requester profession: %v", err) } if _, err := env.pool.ExecContext(ctx, `UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.approver); err != nil { t.Fatalf("promote approver profession: %v", err) } deadlineID, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"due_date": "2026-06-22"} newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Lieber den 22.") if err != nil { t.Fatalf("SuggestChanges: %v", err) } // Original requester approves the counter. if err := env.approvals.Approve(ctx, *newReqID, env.requester, "Ja, passt."); err != nil { t.Fatalf("original requester approves counter: %v", err) } // Entity is back to approved with the counter date. row := struct { Status string `db:"approval_status"` ApprovedBy *uuid.UUID `db:"approved_by"` DueDate time.Time `db:"due_date"` }{} if err := env.pool.GetContext(ctx, &row, `SELECT approval_status, approved_by, due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read entity: %v", err) } if row.Status != "approved" { t.Errorf("entity approval_status = %q, want approved", row.Status) } if row.ApprovedBy == nil || *row.ApprovedBy != env.requester { t.Errorf("approved_by = %v, want %v (original requester)", row.ApprovedBy, env.requester) } want := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) if !row.DueDate.Equal(want) { t.Errorf("due_date = %v, want %v", row.DueDate, want) } } // TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove: // after suggest-changes, the approver who suggested (= new row's // requested_by) is blocked from approving their own counter — 4-Augen // still holds. func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() _, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"due_date": "2026-06-22"} newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "") if err != nil { t.Fatalf("SuggestChanges: %v", err) } if err := env.approvals.Approve(ctx, *newReqID, env.approver, ""); !errors.Is(err, ErrSelfApproval) { t.Errorf("counter author self-approves: got %v, want ErrSelfApproval", err) } } // TestApprovalService_SuggestChanges_TitleOnlyCounter pins t-paliad-217 // Slice B: the counter-allowlist now accepts the wider field set // (title / description / notes / rule_code / event_type_ids on // deadlines). A counter that ONLY changes the title (no date diff) must // succeed — the new pending row's payload carries the title, and the // entity row's title field is updated in-tx. func TestApprovalService_SuggestChanges_TitleOnlyCounter(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() deadlineID, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"title": "Klageerwiderung — Vorschlag Hertz"} newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "") if err != nil { t.Fatalf("title-only suggest: %v", err) } if newReqID == nil { t.Fatal("expected new request id, got nil") } // Entity's title flipped. var gotTitle string if err := env.pool.GetContext(ctx, &gotTitle, `SELECT title FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read title: %v", err) } if gotTitle != "Klageerwiderung — Vorschlag Hertz" { t.Errorf("entity title = %q, want %q", gotTitle, "Klageerwiderung — Vorschlag Hertz") } } // TestApprovalService_SuggestChanges_NotesOnlyCounter pins t-paliad-217 // Slice B: notes is in the counter-allowlist and a notes-only counter // must succeed. Empty-string clears the column (NULLable text). func TestApprovalService_SuggestChanges_NotesOnlyCounter(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() deadlineID, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"notes": "Bitte vor Einreichung mit Mandant abstimmen."} if _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, ""); err != nil { t.Fatalf("notes-only suggest: %v", err) } var gotNotes *string if err := env.pool.GetContext(ctx, &gotNotes, `SELECT notes FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil { t.Fatalf("read notes: %v", err) } if gotNotes == nil || *gotNotes != "Bitte vor Einreichung mit Mandant abstimmen." { t.Errorf("entity notes = %v, want set", gotNotes) } } // TestApprovalService_SuggestChanges_EmptyTitleRejected pins the title // non-empty CHECK on the counter-allowlist: title is NOT NULL on the // deadlines column, so a counter that explicitly sends "" for title // must be rejected with ErrSuggestionRequiresChange (not silently // dropped or written as a NULL). func TestApprovalService_SuggestChanges_EmptyTitleRejected(t *testing.T) { env := setupApprovalTest(t) defer env.cleanup() ctx := context.Background() _, oldReqID, _ := env.seedPendingUpdate(t) counter := map[string]any{"title": " "} // whitespace-only _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "") if !errors.Is(err, ErrSuggestionRequiresChange) { t.Errorf("empty-title suggest: got %v, want ErrSuggestionRequiresChange", err) } }