Files
paliad/internal/services/audit_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

202 lines
6.3 KiB
Go

package services
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestAuditService_UnionFilterCursor exercises the live SQL union against a
// real Postgres so we catch syntax errors in the WITH/UNION query that Go's
// type checker can't see. Skipped when TEST_DATABASE_URL is unset, mirroring
// TestDeadlineReopen_AdminAndNonAdmin.
//
// What it validates:
// - all three sources are reachable in one round-trip and the discriminator
// comes back correct
// - source filter narrows to a single source
// - keyset cursor (ts, id) returns strictly older rows so a follow-up page
// never duplicates the seam row
// - search ILIKE filter survives a "%" character in the input (escape works)
func TestAuditService_UnionFilterCursor(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()
userID := uuid.New()
projectID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.reminder_log WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.caldav_sync_log WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events 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 = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'audit-test@hlc.com')`,
userID); 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, 'audit-test@hlc.com', 'Audit Test', 'munich', 'associate', 'de')`,
userID); 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, 'Audit Test Project', '2026/9999', 'active', $2)`,
projectID, userID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
now := time.Now().UTC()
// Three project_events at well-separated timestamps so keyset pagination
// is unambiguous regardless of insert ordering.
for i, offset := range []time.Duration{-3 * time.Minute, -2 * time.Minute, -1 * time.Minute} {
_, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'project_type_changed', $3, $4, $5, $6, '{}'::jsonb, $5, $5)`,
uuid.New(), projectID, "Project type changed",
"case → litigation 100% sure",
now.Add(offset), userID)
if err != nil {
t.Fatalf("seed project_events[%d]: %v", i, err)
}
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.caldav_sync_log
(id, user_id, occurred_at, direction, items_pushed, items_pulled, error, duration_ms)
VALUES ($1, $2, $3, 'both', 1, 0, NULL, 50)`,
uuid.New(), userID, now.Add(-30*time.Second)); err != nil {
t.Fatalf("seed caldav_sync_log: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.reminder_log
(id, user_id, reminder_type, sent_at, slot, slot_date)
VALUES ($1, $2, 'morning_digest', $3, 'morning', $3::date)`,
uuid.New(), userID, now); err != nil {
t.Fatalf("seed reminder_log: %v", err)
}
svc := NewAuditService(pool)
// All sources, last few minutes — should see at least 5 rows (3 project,
// 1 caldav, 1 reminder), newest first.
rows, err := svc.ListEntries(ctx, AuditFilter{
From: now.Add(-5 * time.Minute),
Limit: 10,
})
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(rows) < 5 {
t.Fatalf("want >=5 rows, got %d", len(rows))
}
if !rows[0].Timestamp.After(rows[len(rows)-1].Timestamp) &&
!rows[0].Timestamp.Equal(rows[len(rows)-1].Timestamp) {
t.Errorf("rows not in DESC order: first=%v last=%v", rows[0].Timestamp, rows[len(rows)-1].Timestamp)
}
sources := map[string]int{}
for _, r := range rows {
sources[r.Source]++
}
if sources[AuditSourceProjectEvents] < 3 {
t.Errorf("missing project_events rows: %v", sources)
}
if sources[AuditSourceCalDAVLog] < 1 {
t.Errorf("missing caldav_sync_log rows: %v", sources)
}
if sources[AuditSourceReminderLog] < 1 {
t.Errorf("missing reminder_log rows: %v", sources)
}
// Source filter — only project_events.
pe, err := svc.ListEntries(ctx, AuditFilter{
Source: AuditSourceProjectEvents,
From: now.Add(-5 * time.Minute),
Limit: 10,
})
if err != nil {
t.Fatalf("list project_events: %v", err)
}
for _, r := range pe {
if r.Source != AuditSourceProjectEvents {
t.Errorf("source filter leaked %q", r.Source)
}
}
// Cursor pagination — first call with limit=2, then continue.
page1, err := svc.ListEntries(ctx, AuditFilter{
Source: AuditSourceProjectEvents,
From: now.Add(-5 * time.Minute),
Limit: 2,
})
if err != nil {
t.Fatalf("list page1: %v", err)
}
if len(page1) != 2 {
t.Fatalf("want 2 page1 rows, got %d", len(page1))
}
cursorTS := page1[1].Timestamp
cursorID := page1[1].ID
page2, err := svc.ListEntries(ctx, AuditFilter{
Source: AuditSourceProjectEvents,
From: now.Add(-5 * time.Minute),
BeforeTS: &cursorTS,
BeforeID: &cursorID,
Limit: 10,
})
if err != nil {
t.Fatalf("list page2: %v", err)
}
for _, r := range page2 {
for _, p := range page1 {
if r.ID == p.ID {
t.Errorf("cursor returned duplicate row %v", r.ID)
}
}
}
// Search escape — "100%" must match the seeded description literal, not
// behave as ILIKE wildcard.
hits, err := svc.ListEntries(ctx, AuditFilter{
Source: AuditSourceProjectEvents,
From: now.Add(-5 * time.Minute),
Search: "100%",
Limit: 10,
})
if err != nil {
t.Fatalf("list search: %v", err)
}
if len(hits) == 0 {
t.Error("expected at least one hit for literal '100%'")
}
}