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.
202 lines
6.3 KiB
Go
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%'")
|
|
}
|
|
}
|