package services 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" ) // TestDeadlineReopen_AdminAndNonAdmin verifies the Reopen lifecycle end-to-end: // - admin can reopen a completed Deadline (status flips to pending, // completed_at cleared, project_events row appended) // - a visible-but-not-admin user gets ErrForbidden when calling Reopen // // Skipped when TEST_DATABASE_URL is unset. func TestDeadlineReopen_AdminAndNonAdmin(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") } if err := db.ApplyMigrations(url); err != nil { t.Fatalf("apply migrations: %v", err) } pool, err := sqlx.Connect("postgres", url) if err != nil { t.Fatalf("connect: %v", err) } defer pool.Close() ctx := context.Background() adminID := uuid.New() memberID := uuid.New() projectID := uuid.New() deadlineID := uuid.New() cleanup := func() { pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, adminID, memberID) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, memberID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`, adminID, "reopen-admin@hlc.com", memberID, "reopen-member@hlc.com"); err != nil { t.Fatalf("seed auth.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.users (id, email, display_name, office, role, lang) VALUES ($1, $2, 'Reopen Admin', 'munich', 'admin', 'de'), ($3, $4, 'Reopen Member', 'munich', 'associate', 'de')`, adminID, "reopen-admin@hlc.com", memberID, "reopen-member@hlc.com"); err != nil { t.Fatalf("seed paliad.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) VALUES ($1, 'project', $1::text, 'Reopen Test Project', '2026/9998', 'active', $2)`, projectID, adminID); err != nil { t.Fatalf("seed paliad.projects: %v", err) } // memberID is on the project team but only as 'associate', so Reopen must // reject them. Visibility should still let GetByID succeed. if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by) VALUES ($1, $2, 'associate', false, $3)`, projectID, memberID, adminID); err != nil { t.Fatalf("seed paliad.project_teams: %v", err) } completedAt := time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC) if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, completed_at, created_by) VALUES ($1, $2, 'Schriftsatz einreichen', $3, 'manual', 'completed', $4, $5)`, deadlineID, projectID, completedAt, completedAt, adminID); err != nil { t.Fatalf("seed paliad.deadlines: %v", err) } users := NewUserService(pool) projects := NewProjectService(pool, users) svc := NewDeadlineService(pool, projects, nil) // Non-admin associate cannot reopen. if _, err := svc.Reopen(ctx, memberID, deadlineID); !errors.Is(err, ErrForbidden) { t.Fatalf("Reopen as associate: err=%v, want ErrForbidden", err) } // Admin can reopen. got, err := svc.Reopen(ctx, adminID, deadlineID) if err != nil { t.Fatalf("Reopen as admin: %v", err) } if got.Status != "pending" { t.Errorf("Status after reopen = %q, want pending", got.Status) } if got.CompletedAt != nil { t.Errorf("CompletedAt after reopen = %v, want nil", got.CompletedAt) } // Audit row landed. var eventCount int if err := pool.GetContext(ctx, &eventCount, `SELECT COUNT(*) FROM paliad.project_events WHERE project_id = $1 AND event_type = 'deadline_reopened'`, projectID); err != nil { t.Fatalf("count events: %v", err) } if eventCount != 1 { t.Errorf("deadline_reopened events = %d, want 1", eventCount) } // Reopen on an already-pending Deadline is a no-op (no extra event). again, err := svc.Reopen(ctx, adminID, deadlineID) if err != nil { t.Fatalf("Reopen idempotent: %v", err) } if again.Status != "pending" { t.Errorf("idempotent Reopen status = %q, want pending", again.Status) } if err := pool.GetContext(ctx, &eventCount, `SELECT COUNT(*) FROM paliad.project_events WHERE project_id = $1 AND event_type = 'deadline_reopened'`, projectID); err != nil { t.Fatalf("count events post-noop: %v", err) } if eventCount != 1 { t.Errorf("idempotent reopen wrote %d audit rows, want 1", eventCount) } }