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.
144 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|