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%'") } }