Files
kandev/apps/backend/internal/integrations/alertsource/store_postgres_test.go
T

362 lines
14 KiB
Go

package alertsource
import (
"context"
"errors"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/testutil"
)
// TestPostgresStoreSchemaReplay proves initSchema is idempotent under
// PostgreSQL (D14) and exercises a representative slice of the reservation
// lifecycle and DDL behaviors to prove dialect parity with the SQLite suite
// in store_test.go, rather than schema replay alone.
func TestPostgresStoreSchemaReplay(t *testing.T) {
ctx := context.Background()
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("first alertsource store schema init: %v", err)
}
if _, err := NewStore(db, db); err != nil {
t.Fatalf("second alertsource store schema init: %v", err)
}
now := time.Now().UTC()
if _, err := db.Exec(`
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
VALUES ('src-1', 'ws-1', 'datadog', 's1', $1, $1)`, now); err != nil {
t.Fatalf("insert source: %v", err)
}
if _, err := db.Exec(`
INSERT INTO alert_watches (id, workspace_id, source_id, workflow_id, workflow_step_id, created_at, updated_at)
VALUES ('watch-1', 'ws-1', 'src-1', 'wf-1', 'step-1', $1, $1)`, now); err != nil {
t.Fatalf("insert watch: %v", err)
}
fp := Fingerprint(map[string]string{"seed": "pg-lifecycle"}, []string{"seed"})
reservationID, reserved, err := store.ReserveFingerprint(ctx, "watch-1", fp, []byte(`{"a":1}`))
if err != nil || !reserved {
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
}
_, reserved, err = store.ReserveFingerprint(ctx, "watch-1", fp, nil)
if err != nil || reserved {
t.Fatalf("duplicate reserve: expected reserved=false err=nil, got reserved=%v err=%v", reserved, err)
}
if err := store.AttachReservationTaskID(ctx, reservationID, "task-1"); err != nil {
t.Fatalf("attach: %v", err)
}
if err := store.AttachReservationTaskID(ctx, reservationID, "task-1"); err != nil {
t.Fatalf("idempotent re-attach: %v", err)
}
if err := store.AttachReservationTaskID(ctx, reservationID, "task-2"); !errors.Is(err, ErrReservationAttachedElsewhere) {
t.Fatalf("expected ErrReservationAttachedElsewhere, got %v", err)
}
if err := store.ReleaseReservationForTask(ctx, "task-1"); err != nil {
t.Fatalf("release: %v", err)
}
if err := store.AttachReservationTaskID(ctx, reservationID, "task-3"); !errors.Is(err, ErrReservationGone) {
t.Fatalf("expected ErrReservationGone after release, got %v", err)
}
fp2 := Fingerprint(map[string]string{"seed": "pg-orphan"}, []string{"seed"})
reservationID2, reserved, err := store.ReserveFingerprint(ctx, "watch-1", fp2, nil)
if err != nil || !reserved {
t.Fatalf("reserve fp2: id=%q reserved=%v err=%v", reservationID2, reserved, err)
}
if err := store.DeleteReservation(ctx, reservationID2); err != nil {
t.Fatalf("delete fp2: %v", err)
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM alert_reservations WHERE watch_id = $1 AND fingerprint = $2`,
"watch-1", fp2); err != nil {
t.Fatalf("count fp2: %v", err)
}
if count != 0 {
t.Fatalf("expected fp2 deleted, got %d rows", count)
}
fp3 := Fingerprint(map[string]string{"seed": "pg-release-orphan"}, []string{"seed"})
reservationID3, reserved, err := store.ReserveFingerprint(ctx, "watch-1", fp3, nil)
if err != nil || !reserved {
t.Fatalf("reserve fp3: id=%q reserved=%v err=%v", reservationID3, reserved, err)
}
if err := store.ReleaseOrphanedReservation(ctx, reservationID3); err != nil {
t.Fatalf("release orphaned fp3: %v", err)
}
var releasedAt *time.Time
if err := db.Get(&releasedAt, `SELECT released_at FROM alert_reservations WHERE watch_id = $1 AND fingerprint = $2`,
"watch-1", fp3); err != nil {
t.Fatalf("read released_at: %v", err)
}
if releasedAt == nil {
t.Fatal("expected fp3 to be released")
}
fp4 := Fingerprint(map[string]string{"seed": "pg-replacement"}, []string{"seed"})
firstID, reserved, err := store.ReserveFingerprint(ctx, "watch-1", fp4, nil)
if err != nil || !reserved {
t.Fatalf("reserve first fp4: id=%q reserved=%v err=%v", firstID, reserved, err)
}
if err := store.AttachReservationTaskID(ctx, firstID, "task-replacement-1"); err != nil {
t.Fatalf("attach first fp4: %v", err)
}
if err := store.ReleaseReservationForTask(ctx, "task-replacement-1"); err != nil {
t.Fatalf("release first fp4: %v", err)
}
secondID, reserved, err := store.ReserveFingerprint(ctx, "watch-1", fp4, nil)
if err != nil || !reserved || secondID == firstID {
t.Fatalf("reserve replacement fp4: id=%q reserved=%v err=%v", secondID, reserved, err)
}
if err := store.AttachReservationTaskID(ctx, firstID, "stale-task"); !errors.Is(err, ErrReservationGone) {
t.Fatalf("stale attach fp4: expected ErrReservationGone, got %v", err)
}
var taskID string
if err := db.Get(&taskID, `SELECT task_id FROM alert_reservations WHERE id = $1`, secondID); err != nil {
t.Fatalf("read replacement task_id: %v", err)
}
if taskID != "" {
t.Fatalf("stale attach mutated replacement reservation: task_id=%q", taskID)
}
if err := store.ReleaseOrphanedReservation(ctx, firstID); err != nil {
t.Fatalf("stale orphan release fp4: %v", err)
}
if err := store.DeleteReservation(ctx, firstID); err != nil {
t.Fatalf("stale delete fp4: %v", err)
}
var replacementReleasedAt *time.Time
if err := db.Get(&replacementReleasedAt, `SELECT released_at FROM alert_reservations WHERE id = $1`, secondID); err != nil {
t.Fatalf("read replacement released_at: %v", err)
}
if replacementReleasedAt != nil {
t.Fatal("stale cleanup mutated replacement reservation")
}
}
// TestPostgresStore_SchemaBehaviors covers the DDL-level constraints that
// are most likely to diverge silently between SQLite and PostgreSQL: FK
// actions, CHECK constraints, the UNIQUE constraint, and — per D16.3 —
// case-sensitive fingerprint collation, since Postgres's default collation
// is case-sensitive already but the explicit COLLATE "C" must still be
// present and correct for byte-order comparisons.
func TestPostgresStore_SchemaBehaviors(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
if _, err := NewStore(db, db); err != nil {
t.Fatalf("schema init: %v", err)
}
now := time.Now().UTC()
if _, err := db.Exec(`
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
VALUES ('src-1', 'ws-1', 'datadog', 's1', $1, $1)`, now); err != nil {
t.Fatalf("insert source: %v", err)
}
if _, err := db.Exec(`
INSERT INTO alert_watches (id, workspace_id, source_id, workflow_id, workflow_step_id, created_at, updated_at)
VALUES ('watch-1', 'ws-1', 'src-1', 'wf-1', 'step-1', $1, $1)`, now); err != nil {
t.Fatalf("insert watch: %v", err)
}
t.Run("RestrictBlocksSourceDeleteWithWatch", func(t *testing.T) {
if _, err := db.Exec(`DELETE FROM alert_sources WHERE id = 'src-1'`); err == nil {
t.Fatal("expected ON DELETE RESTRICT to reject the delete")
}
})
t.Run("CascadeRemovesReservationsOnWatchDelete", func(t *testing.T) {
fp := Fingerprint(map[string]string{"seed": "pg-cascade"}, []string{"seed"})
if _, err := db.Exec(`
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
VALUES ('res-cascade', 'watch-1', $1, $2)`, fp, now); err != nil {
t.Fatalf("insert reservation: %v", err)
}
if _, err := db.Exec(`DELETE FROM alert_watches WHERE id = 'watch-1'`); err != nil {
t.Fatalf("delete watch: %v", err)
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM alert_reservations WHERE watch_id = 'watch-1'`); err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatal("expected CASCADE to remove the watch's reservations")
}
// Restore the watch for subsequent subtests.
if _, err := db.Exec(`
INSERT INTO alert_watches (id, workspace_id, source_id, workflow_id, workflow_step_id, created_at, updated_at)
VALUES ('watch-1', 'ws-1', 'src-1', 'wf-1', 'step-1', $1, $1)`, now); err != nil {
t.Fatalf("reinsert watch: %v", err)
}
})
t.Run("HealthStatusCheckRejectsBogusValue", func(t *testing.T) {
if _, err := db.Exec(`
INSERT INTO alert_sources (id, workspace_id, type, name, health_status, created_at, updated_at)
VALUES ('src-bogus', 'ws-1', 'datadog', 's-bogus', 'bogus', $1, $1)`, now); err == nil {
t.Fatal("expected the health_status CHECK to reject 'bogus'")
}
})
t.Run("FingerprintWidthCheckRejectsShortValue", func(t *testing.T) {
full := Fingerprint(map[string]string{"seed": "pg-width"}, []string{"seed"})
short := full[:63]
if _, err := db.Exec(`
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
VALUES ('res-short', 'watch-1', $1, $2)`, short, now); err == nil {
t.Fatal("expected the fingerprint width CHECK to reject a 63-character value")
}
})
t.Run("UniqueWorkspaceAndName", func(t *testing.T) {
if _, err := db.Exec(`
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
VALUES ('src-dup', 'ws-1', 'datadog', 's1', $1, $1)`, now); err == nil {
t.Fatal("expected UNIQUE(workspace_id, name) to reject a duplicate")
}
})
t.Run("CaseSensitiveCollation", func(t *testing.T) {
lower := Fingerprint(map[string]string{"seed": "pg-collation"}, []string{"seed"})
upper := make([]byte, len(lower))
for i := 0; i < len(lower); i++ {
c := lower[i]
if c >= 'a' && c <= 'f' {
c -= 'a' - 'A'
}
upper[i] = c
}
if _, err := db.Exec(`
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
VALUES ('res-lower', 'watch-1', $1, $2)`, lower, now); err != nil {
t.Fatalf("insert lowercase: %v", err)
}
if _, err := db.Exec(`
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
VALUES ('res-upper', 'watch-1', $1, $2)`, string(upper), now); err != nil {
t.Fatalf("insert uppercase: %v", err)
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM alert_reservations WHERE released_at IS NULL AND watch_id = 'watch-1'`); err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 distinct live rows under case-sensitive collation, got %d", count)
}
})
}
// --- AC3: introspective exact column list (PostgreSQL) ---
//
// Mirrors store_test.go's PRAGMA table_info assertions under SQLite: D16
// requires DDL introspection for exact column lists "under both dialects",
// and information_schema.columns is PostgreSQL's equivalent catalog.
type postgresColumn struct {
Name string `db:"column_name"`
DataType string `db:"data_type"`
Nullable string `db:"is_nullable"`
}
func postgresTableColumns(t *testing.T, db *sqlx.DB, table string) []postgresColumn {
t.Helper()
var cols []postgresColumn
if err := db.Select(&cols, `
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = $1
ORDER BY ordinal_position`, table); err != nil {
t.Fatalf("information_schema.columns(%s): %v", table, err)
}
return cols
}
func assertPostgresColumnsEqual(t *testing.T, table string, want, got []postgresColumn) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: expected %d columns, got %d: %+v", table, len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s: column %d mismatch: want %+v, got %+v", table, i, want[i], got[i])
}
}
}
func TestPostgresStore_Schema_ExactColumnLists(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
if _, err := NewStore(db, db); err != nil {
t.Fatalf("schema init: %v", err)
}
t.Run("alert_sources", func(t *testing.T) {
got := postgresTableColumns(t, db, "alert_sources")
want := []postgresColumn{
{Name: "id", DataType: "text", Nullable: "NO"},
{Name: "workspace_id", DataType: "text", Nullable: "NO"},
{Name: "type", DataType: "text", Nullable: "NO"},
{Name: "name", DataType: "text", Nullable: "NO"},
{Name: "config_json", DataType: "text", Nullable: "NO"},
{Name: "enabled", DataType: "boolean", Nullable: "NO"},
{Name: "health_status", DataType: "text", Nullable: "NO"},
{Name: "last_error", DataType: "text", Nullable: "NO"},
{Name: "last_error_at", DataType: "timestamp with time zone", Nullable: "YES"},
{Name: "created_at", DataType: "timestamp with time zone", Nullable: "NO"},
{Name: "updated_at", DataType: "timestamp with time zone", Nullable: "NO"},
}
assertPostgresColumnsEqual(t, "alert_sources", want, got)
})
t.Run("alert_watches", func(t *testing.T) {
got := postgresTableColumns(t, db, "alert_watches")
want := []postgresColumn{
{Name: "id", DataType: "text", Nullable: "NO"},
{Name: "workspace_id", DataType: "text", Nullable: "NO"},
{Name: "source_id", DataType: "text", Nullable: "NO"},
{Name: "workflow_id", DataType: "text", Nullable: "NO"},
{Name: "workflow_step_id", DataType: "text", Nullable: "NO"},
{Name: "repository_id", DataType: "text", Nullable: "NO"},
{Name: "base_branch", DataType: "text", Nullable: "NO"},
{Name: "filter_json", DataType: "text", Nullable: "NO"},
{Name: "agent_profile_id", DataType: "text", Nullable: "NO"},
{Name: "executor_profile_id", DataType: "text", Nullable: "NO"},
{Name: "prompt", DataType: "text", Nullable: "NO"},
{Name: "enabled", DataType: "boolean", Nullable: "NO"},
{Name: "poll_interval_seconds", DataType: "integer", Nullable: "NO"},
{Name: "max_inflight_tasks", DataType: "integer", Nullable: "YES"},
{Name: "last_polled_at", DataType: "timestamp with time zone", Nullable: "YES"},
{Name: "last_error", DataType: "text", Nullable: "NO"},
{Name: "last_error_at", DataType: "timestamp with time zone", Nullable: "YES"},
{Name: "created_at", DataType: "timestamp with time zone", Nullable: "NO"},
{Name: "updated_at", DataType: "timestamp with time zone", Nullable: "NO"},
}
assertPostgresColumnsEqual(t, "alert_watches", want, got)
})
t.Run("alert_reservations", func(t *testing.T) {
got := postgresTableColumns(t, db, "alert_reservations")
want := []postgresColumn{
{Name: "id", DataType: "text", Nullable: "NO"},
{Name: "watch_id", DataType: "text", Nullable: "NO"},
{Name: "fingerprint", DataType: "text", Nullable: "NO"},
{Name: "task_id", DataType: "text", Nullable: "NO"},
{Name: "alert_raw", DataType: "text", Nullable: "NO"},
{Name: "created_at", DataType: "timestamp with time zone", Nullable: "NO"},
{Name: "released_at", DataType: "timestamp with time zone", Nullable: "YES"},
}
assertPostgresColumnsEqual(t, "alert_reservations", want, got)
for _, c := range got {
if c.Name == "updated_at" {
t.Fatal("alert_reservations must not have an updated_at column (D14)")
}
}
})
}