#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg. - i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs). - tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table. go build && go test -short ./internal/... && bun run build all clean.
1441 lines
51 KiB
Go
1441 lines
51 KiB
Go
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)
|
|
}
|
|
}
|
|
|