Files
paliad/internal/services/deadline_service_test.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

144 lines
4.9 KiB
Go

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)
}
}