a0774f4f1a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
849 lines
32 KiB
Go
849 lines
32 KiB
Go
package alertsource
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// newTestStore opens a single-connection in-memory SQLite Store, mirroring
|
|
// the internal/sentry/store_test.go convention: one connection so writes
|
|
// serialize the same way a real single-writer SQLite deployment does.
|
|
func newTestStore(t *testing.T) (*Store, *sqlx.DB) {
|
|
t.Helper()
|
|
raw, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
raw.SetMaxOpenConns(1)
|
|
raw.SetMaxIdleConns(1)
|
|
db := sqlx.NewDb(raw, "sqlite3")
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
t.Fatalf("enable foreign keys: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
store, err := NewStore(db, db)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
return store, db
|
|
}
|
|
|
|
// newConcurrentTestStore opens a shared-cache in-memory SQLite database with
|
|
// multiple connections and a busy timeout, so AC1(ii)'s concurrent-reserve
|
|
// test exercises genuine multi-connection contention rather than the
|
|
// pool-level serialization newTestStore's single connection would hide.
|
|
func newConcurrentTestStore(t *testing.T) (*Store, *sqlx.DB) {
|
|
t.Helper()
|
|
raw, err := sql.Open("sqlite3", "file::memory:?cache=shared&_busy_timeout=5000")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
raw.SetMaxOpenConns(8)
|
|
db := sqlx.NewDb(raw, "sqlite3")
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
t.Fatalf("enable foreign keys: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
store, err := NewStore(db, db)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
return store, db
|
|
}
|
|
|
|
// insertTestSource and insertTestWatch seed the FK-referenced parent rows
|
|
// directly: source/watch CRUD is T06 (D19), so Store exposes no create
|
|
// method for either and tests populate them with raw SQL.
|
|
func insertTestSource(t *testing.T, db *sqlx.DB, id, workspaceID, name string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
|
|
VALUES (?, ?, 'datadog', ?, ?, ?)`), id, workspaceID, name, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert test source: %v", err)
|
|
}
|
|
}
|
|
|
|
func insertTestWatch(t *testing.T, db *sqlx.DB, id, sourceID, workspaceID string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_watches (id, workspace_id, source_id, workflow_id, workflow_step_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, 'wf-1', 'step-1', ?, ?)`), id, workspaceID, sourceID, now, now)
|
|
if err != nil {
|
|
t.Fatalf("insert test watch: %v", err)
|
|
}
|
|
}
|
|
|
|
// seedSourceAndWatch is the common fixture most reservation tests need: one
|
|
// source, one watch bound to it.
|
|
func seedSourceAndWatch(t *testing.T, db *sqlx.DB) {
|
|
t.Helper()
|
|
insertTestSource(t, db, "src-1", "ws-1", "s1")
|
|
insertTestWatch(t, db, "watch-1", "src-1", "ws-1")
|
|
}
|
|
|
|
func testFingerprint(seed string) string {
|
|
return Fingerprint(map[string]string{"seed": seed}, []string{"seed"})
|
|
}
|
|
|
|
// --- AC1(ii): concurrent ReserveFingerprint race ---
|
|
|
|
func TestStore_ReserveFingerprint_ConcurrentRace_ExactlyOneWins(t *testing.T) {
|
|
store, db := newConcurrentTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("race")
|
|
|
|
const n = 20
|
|
var wg sync.WaitGroup
|
|
results := make([]bool, n)
|
|
errs := make([]error, n)
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
results[i] = reserved
|
|
errs[i] = err
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
wins := 0
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("goroutine %d: unexpected error: %v", i, err)
|
|
}
|
|
if results[i] {
|
|
wins++
|
|
}
|
|
}
|
|
if wins != 1 {
|
|
t.Fatalf("expected exactly 1 winner among %d goroutines, got %d", n, wins)
|
|
}
|
|
|
|
var count int
|
|
if err := db.Get(&count, db.Rebind(
|
|
`SELECT COUNT(*) FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("count reservations: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly 1 live row, got %d", count)
|
|
}
|
|
}
|
|
|
|
// --- (a) ReserveFingerprint ---
|
|
|
|
func TestStore_ReserveFingerprint_EmptyWatchID(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if _, _, err := store.ReserveFingerprint(context.Background(), "", testFingerprint("x"), nil); err == nil {
|
|
t.Fatal("expected error for empty watchID")
|
|
}
|
|
}
|
|
|
|
func TestStore_ReserveFingerprint_InvalidFingerprint(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
for _, fp := range []string{"", "too-short", "UPPERCASEUPPERCASEUPPERCASEUPPERCASEUPPERCASEUPPERCASEUPPERCASE"} {
|
|
if _, _, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil); err == nil {
|
|
t.Fatalf("expected error for fingerprint %q", fp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStore_ReserveFingerprint_FirstReserveWinsSecondLoses(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("dup")
|
|
|
|
firstID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, []byte(`{"a":1}`))
|
|
if err != nil || !reserved || firstID == "" {
|
|
t.Fatalf("first reserve: id=%q reserved=%v err=%v", firstID, reserved, err)
|
|
}
|
|
secondID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || reserved || secondID != "" {
|
|
t.Fatalf("second reserve: expected no id, reserved=false, err=nil, got id=%q reserved=%v err=%v", secondID, reserved, err)
|
|
}
|
|
}
|
|
|
|
func TestStore_ReserveFingerprint_NilAlertRawStoredAsEmptyString(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("nil-raw")
|
|
if _, _, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil); err != nil {
|
|
t.Fatalf("reserve: %v", err)
|
|
}
|
|
var raw sql.NullString
|
|
if err := db.Get(&raw, db.Rebind(`SELECT alert_raw FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("read alert_raw: %v", err)
|
|
}
|
|
if !raw.Valid || raw.String != "" {
|
|
t.Fatalf("expected alert_raw = '', got %+v", raw)
|
|
}
|
|
}
|
|
|
|
// --- (b) AttachReservationTaskID ---
|
|
|
|
func TestStore_AttachReservationTaskID_EmptyArgs(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if err := store.AttachReservationTaskID(context.Background(), "", "task-1"); err == nil {
|
|
t.Fatal("expected error for empty reservationID")
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), "reservation-1", ""); err == nil {
|
|
t.Fatal("expected error for empty taskID")
|
|
}
|
|
}
|
|
|
|
func TestStore_AttachReservationTaskID_FreshAttachSucceeds(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("attach-fresh")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
var taskID string
|
|
if err := db.Get(&taskID, db.Rebind(`SELECT task_id FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("read task_id: %v", err)
|
|
}
|
|
if taskID != "task-1" {
|
|
t.Fatalf("expected task_id = task-1, got %q", taskID)
|
|
}
|
|
}
|
|
|
|
func TestStore_AttachReservationTaskID_IdempotentForOwnTaskID(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("attach-idempotent")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("first attach: %v", err)
|
|
}
|
|
// Simulates a retry after an ambiguous commit: same taskID, must succeed
|
|
// again rather than being classified as AttachedElsewhere.
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("retry attach with same taskID: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_AttachReservationTaskID_DifferentTaskIDReturnsAttachedElsewhere(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("attach-conflict")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("first attach: %v", err)
|
|
}
|
|
err = store.AttachReservationTaskID(context.Background(), reservationID, "task-2")
|
|
if !errors.Is(err, ErrReservationAttachedElsewhere) {
|
|
t.Fatalf("expected ErrReservationAttachedElsewhere, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_AttachReservationTaskID_NeverReservedReturnsGone(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
err := store.AttachReservationTaskID(context.Background(), "reservation-never-existed", "task-1")
|
|
if !errors.Is(err, ErrReservationGone) {
|
|
t.Fatalf("expected ErrReservationGone, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_AttachReservationTaskID_ReleasedReturnsGone(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("attach-released")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.DeleteReservation(context.Background(), reservationID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
err = store.AttachReservationTaskID(context.Background(), reservationID, "task-1")
|
|
if !errors.Is(err, ErrReservationGone) {
|
|
t.Fatalf("expected ErrReservationGone, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStore_AttachReservationTaskID_DiagnosticSelectFailureIsNeverGone
|
|
// covers A5: a store/transport error on the classification SELECT must be
|
|
// returned wrapped, never as ErrReservationGone — T06 retries a plain error
|
|
// but stops (and releases) on the sentinel, so misclassifying a transient
|
|
// failure as Gone would suppress the fingerprint permanently and silently.
|
|
// The diagnostic SELECT runs against the store's read handle; closing it
|
|
// after the write handle has done its job forces exactly that failure.
|
|
func TestStore_AttachReservationTaskID_DiagnosticSelectFailureIsNeverGone(t *testing.T) {
|
|
writerRaw, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open writer: %v", err)
|
|
}
|
|
writer := sqlx.NewDb(writerRaw, "sqlite3")
|
|
t.Cleanup(func() { _ = writer.Close() })
|
|
if _, err := writer.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
t.Fatalf("enable foreign keys: %v", err)
|
|
}
|
|
|
|
readerRaw, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open reader: %v", err)
|
|
}
|
|
reader := sqlx.NewDb(readerRaw, "sqlite3")
|
|
t.Cleanup(func() { _ = reader.Close() })
|
|
|
|
store, err := NewStore(writer, reader)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
seedSourceAndWatch(t, writer)
|
|
// Break the read handle only. The UPDATE (on writer) affects zero rows
|
|
// because nothing was ever reserved, so AttachReservationTaskID reaches
|
|
// classifyAttachFailure's SELECT, which must now fail.
|
|
if err := reader.Close(); err != nil {
|
|
t.Fatalf("close reader: %v", err)
|
|
}
|
|
|
|
err = store.AttachReservationTaskID(context.Background(), "reservation-never-existed", "task-1")
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if errors.Is(err, ErrReservationGone) {
|
|
t.Fatalf("a store error on the diagnostic SELECT must never be reported as ErrReservationGone, got %v", err)
|
|
}
|
|
if errors.Is(err, ErrReservationAttachedElsewhere) {
|
|
t.Fatalf("a store error on the diagnostic SELECT must never be reported as ErrReservationAttachedElsewhere, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- (c) DeleteReservation ---
|
|
|
|
func TestStore_DeleteReservation_EmptyArgs(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if err := store.DeleteReservation(context.Background(), ""); err == nil {
|
|
t.Fatal("expected error for empty reservationID")
|
|
}
|
|
}
|
|
|
|
func TestStore_DeleteReservation_DeletesLiveReservation(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("delete-live")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.DeleteReservation(context.Background(), reservationID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
var count int
|
|
if err := db.Get(&count, db.Rebind(`SELECT COUNT(*) FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected 0 rows after delete, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestStore_DeleteReservation_NoOpOnMissingRow(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if err := store.DeleteReservation(context.Background(), "reservation-never-existed"); err != nil {
|
|
t.Fatalf("expected nil (no-op), got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStore_DeleteReservation_NoOpOnAttachedReservation guards the
|
|
// task_id = ” condition against a concurrent AttachReservationTaskID: once a
|
|
// reservation is attached to a task, DeleteReservation must leave it in
|
|
// place rather than deleting the record a task now depends on.
|
|
func TestStore_DeleteReservation_NoOpOnAttachedReservation(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("delete-attached")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
if err := store.DeleteReservation(context.Background(), reservationID); err != nil {
|
|
t.Fatalf("expected nil (no-op), got %v", err)
|
|
}
|
|
var count int
|
|
if err := db.Get(&count, db.Rebind(`SELECT COUNT(*) FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected the attached reservation to survive DeleteReservation, got %d rows", count)
|
|
}
|
|
}
|
|
|
|
// --- (d) ReleaseReservationForTask ---
|
|
|
|
func TestStore_ReleaseReservationForTask_EmptyTaskIDDoesNotReleaseUnattachedRows(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("release-empty-taskid")
|
|
if _, _, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil); err != nil {
|
|
t.Fatalf("reserve: %v", err)
|
|
}
|
|
if err := store.ReleaseReservationForTask(context.Background(), ""); err == nil {
|
|
t.Fatal("expected error for empty taskID")
|
|
}
|
|
var releasedAt sql.NullTime
|
|
if err := db.Get(&releasedAt, db.Rebind(`SELECT released_at FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("read released_at: %v", err)
|
|
}
|
|
if releasedAt.Valid {
|
|
t.Fatal("expected the unattached reservation to remain live")
|
|
}
|
|
}
|
|
|
|
func TestStore_ReleaseReservationForTask_ReleasesAttachedReservation(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("release-attached")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
if err := store.ReleaseReservationForTask(context.Background(), "task-1"); err != nil {
|
|
t.Fatalf("release: %v", err)
|
|
}
|
|
var releasedAt sql.NullTime
|
|
if err := db.Get(&releasedAt, db.Rebind(`SELECT released_at FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("read released_at: %v", err)
|
|
}
|
|
if !releasedAt.Valid {
|
|
t.Fatal("expected released_at to be set")
|
|
}
|
|
}
|
|
|
|
func TestStore_ReleaseReservationForTask_NoOpWhenNoMatch(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if err := store.ReleaseReservationForTask(context.Background(), "task-does-not-exist"); err != nil {
|
|
t.Fatalf("expected nil (no-op), got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_ReleaseReservationForTask_IdempotentAgainstRedeliveredClose(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("release-idempotent")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
if err := store.ReleaseReservationForTask(context.Background(), "task-1"); err != nil {
|
|
t.Fatalf("first release: %v", err)
|
|
}
|
|
if err := store.ReleaseReservationForTask(context.Background(), "task-1"); err != nil {
|
|
t.Fatalf("redelivered release: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- (e) ReleaseOrphanedReservation ---
|
|
|
|
func TestStore_ReleaseOrphanedReservation_EmptyArgs(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
if err := store.ReleaseOrphanedReservation(context.Background(), ""); err == nil {
|
|
t.Fatal("expected error for empty reservationID")
|
|
}
|
|
}
|
|
|
|
func TestStore_ReleaseOrphanedReservation_ReleasesOnlyUnattachedRow(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("release-orphan")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.ReleaseOrphanedReservation(context.Background(), reservationID); err != nil {
|
|
t.Fatalf("release orphaned: %v", err)
|
|
}
|
|
var releasedAt sql.NullTime
|
|
var taskID string
|
|
if err := db.QueryRow(db.Rebind(`SELECT released_at, task_id FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp).Scan(&releasedAt, &taskID); err != nil {
|
|
t.Fatalf("read row: %v", err)
|
|
}
|
|
if !releasedAt.Valid || taskID != "" {
|
|
t.Fatalf("expected released_at set and task_id empty, got released_at.Valid=%v task_id=%q", releasedAt.Valid, taskID)
|
|
}
|
|
}
|
|
|
|
func TestStore_ReleaseOrphanedReservation_CannotReleaseAttachedReservation(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("release-orphan-guard")
|
|
reservationID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve: id=%q reserved=%v err=%v", reservationID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), reservationID, "task-1"); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
if err := store.ReleaseOrphanedReservation(context.Background(), reservationID); err != nil {
|
|
t.Fatalf("release orphaned: %v", err)
|
|
}
|
|
var releasedAt sql.NullTime
|
|
if err := db.Get(&releasedAt, db.Rebind(`SELECT released_at FROM alert_reservations WHERE watch_id = ? AND fingerprint = ?`),
|
|
"watch-1", fp); err != nil {
|
|
t.Fatalf("read released_at: %v", err)
|
|
}
|
|
if releasedAt.Valid {
|
|
t.Fatal("expected the attached reservation to remain live")
|
|
}
|
|
}
|
|
|
|
func TestStore_ReservationIdentityPreventsStaleOperations(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("replacement")
|
|
|
|
firstID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved || firstID == "" {
|
|
t.Fatalf("first reserve: id=%q reserved=%v err=%v", firstID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), firstID, "task-1"); err != nil {
|
|
t.Fatalf("attach first reservation: %v", err)
|
|
}
|
|
if err := store.ReleaseReservationForTask(context.Background(), "task-1"); err != nil {
|
|
t.Fatalf("release first reservation: %v", err)
|
|
}
|
|
|
|
secondID, reserved, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil)
|
|
if err != nil || !reserved || secondID == "" || secondID == firstID {
|
|
t.Fatalf("replacement reserve: id=%q reserved=%v err=%v", secondID, reserved, err)
|
|
}
|
|
if err := store.AttachReservationTaskID(context.Background(), firstID, "stale-task"); !errors.Is(err, ErrReservationGone) {
|
|
t.Fatalf("stale attach: expected ErrReservationGone, got %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := db.Get(&taskID, db.Rebind(`SELECT task_id FROM alert_reservations WHERE id = ?`), 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(context.Background(), firstID); err != nil {
|
|
t.Fatalf("stale orphan release: %v", err)
|
|
}
|
|
if err := store.DeleteReservation(context.Background(), firstID); err != nil {
|
|
t.Fatalf("stale delete: %v", err)
|
|
}
|
|
|
|
var releasedAt sql.NullTime
|
|
if err := db.Get(&releasedAt, db.Rebind(`SELECT released_at FROM alert_reservations WHERE id = ?`), secondID); err != nil {
|
|
t.Fatalf("read replacement released_at: %v", err)
|
|
}
|
|
if releasedAt.Valid {
|
|
t.Fatal("stale cleanup mutated replacement reservation")
|
|
}
|
|
}
|
|
|
|
// --- AC3: introspective exact column list ---
|
|
|
|
type sqliteColumn struct {
|
|
Cid int `db:"cid"`
|
|
Name string `db:"name"`
|
|
Type string `db:"type"`
|
|
NotNull int `db:"notnull"`
|
|
DfltValue sql.NullString `db:"dflt_value"`
|
|
PK int `db:"pk"`
|
|
}
|
|
|
|
func tableColumns(t *testing.T, db *sqlx.DB, table string) []sqliteColumn {
|
|
t.Helper()
|
|
var cols []sqliteColumn
|
|
if err := db.Select(&cols, `PRAGMA table_info(`+table+`)`); err != nil {
|
|
t.Fatalf("table_info(%s): %v", table, err)
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func TestStore_Schema_AlertSourcesColumns(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
got := tableColumns(t, db, "alert_sources")
|
|
want := []sqliteColumn{
|
|
{Name: "id", Type: "TEXT", NotNull: 0, PK: 1},
|
|
{Name: "workspace_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "type", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "name", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "config_json", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "enabled", Type: "BOOLEAN", NotNull: 1, PK: 0},
|
|
{Name: "health_status", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "last_error", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "last_error_at", Type: "DATETIME", NotNull: 0, PK: 0},
|
|
{Name: "created_at", Type: "DATETIME", NotNull: 1, PK: 0},
|
|
{Name: "updated_at", Type: "DATETIME", NotNull: 1, PK: 0},
|
|
}
|
|
assertColumnsEqual(t, "alert_sources", want, got)
|
|
}
|
|
|
|
func TestStore_Schema_AlertWatchesColumns(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
got := tableColumns(t, db, "alert_watches")
|
|
want := []sqliteColumn{
|
|
{Name: "id", Type: "TEXT", NotNull: 0, PK: 1},
|
|
{Name: "workspace_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "source_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "workflow_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "workflow_step_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "repository_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "base_branch", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "filter_json", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "agent_profile_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "executor_profile_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "prompt", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "enabled", Type: "BOOLEAN", NotNull: 1, PK: 0},
|
|
{Name: "poll_interval_seconds", Type: "INTEGER", NotNull: 1, PK: 0},
|
|
{Name: "max_inflight_tasks", Type: "INTEGER", NotNull: 0, PK: 0},
|
|
{Name: "last_polled_at", Type: "DATETIME", NotNull: 0, PK: 0},
|
|
{Name: "last_error", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "last_error_at", Type: "DATETIME", NotNull: 0, PK: 0},
|
|
{Name: "created_at", Type: "DATETIME", NotNull: 1, PK: 0},
|
|
{Name: "updated_at", Type: "DATETIME", NotNull: 1, PK: 0},
|
|
}
|
|
assertColumnsEqual(t, "alert_watches", want, got)
|
|
}
|
|
|
|
func TestStore_Schema_AlertReservationsColumns(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
got := tableColumns(t, db, "alert_reservations")
|
|
want := []sqliteColumn{
|
|
{Name: "id", Type: "TEXT", NotNull: 0, PK: 1},
|
|
{Name: "watch_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "fingerprint", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "task_id", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "alert_raw", Type: "TEXT", NotNull: 1, PK: 0},
|
|
{Name: "created_at", Type: "DATETIME", NotNull: 1, PK: 0},
|
|
{Name: "released_at", Type: "DATETIME", NotNull: 0, PK: 0},
|
|
}
|
|
assertColumnsEqual(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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertColumnsEqual(t *testing.T, table string, want, got []sqliteColumn) {
|
|
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 {
|
|
g, w := got[i], want[i]
|
|
if g.Name != w.Name || g.Type != w.Type || g.NotNull != w.NotNull || g.PK != w.PK {
|
|
t.Fatalf("%s: column %d mismatch: want {Name:%s Type:%s NotNull:%d PK:%d}, got {Name:%s Type:%s NotNull:%d PK:%d}",
|
|
table, i, w.Name, w.Type, w.NotNull, w.PK, g.Name, g.Type, g.NotNull, g.PK)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- AC3: behavioral DDL assertions ---
|
|
|
|
func TestStore_Schema_SourceDeleteRestrictedWhileWatchExists(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
_ = store
|
|
seedSourceAndWatch(t, db)
|
|
if _, err := db.Exec(db.Rebind(`DELETE FROM alert_sources WHERE id = ?`), "src-1"); err == nil {
|
|
t.Fatal("expected the delete to be rejected by ON DELETE RESTRICT")
|
|
}
|
|
var count int
|
|
if err := db.Get(&count, db.Rebind(`SELECT COUNT(*) FROM alert_sources WHERE id = ?`), "src-1"); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatal("expected the source row to still be present")
|
|
}
|
|
}
|
|
|
|
func TestStore_Schema_WatchDeleteCascadesToReservations(t *testing.T) {
|
|
store, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
fp := testFingerprint("cascade")
|
|
if _, _, err := store.ReserveFingerprint(context.Background(), "watch-1", fp, nil); err != nil {
|
|
t.Fatalf("reserve: %v", err)
|
|
}
|
|
if _, err := db.Exec(db.Rebind(`DELETE FROM alert_watches WHERE id = ?`), "watch-1"); err != nil {
|
|
t.Fatalf("delete watch: %v", err)
|
|
}
|
|
var count int
|
|
if err := db.Get(&count, db.Rebind(`SELECT COUNT(*) FROM alert_reservations WHERE watch_id = ?`), "watch-1"); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatal("expected the watch's reservations to be removed by ON DELETE CASCADE")
|
|
}
|
|
}
|
|
|
|
func TestStore_Schema_HealthStatusCheckRejectsBogusValue(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_sources (id, workspace_id, type, name, health_status, created_at, updated_at)
|
|
VALUES (?, ?, 'datadog', 's-bogus', 'bogus', ?, ?)`), "src-bogus", "ws-1", now, now)
|
|
if err == nil {
|
|
t.Fatal("expected the health_status CHECK to reject 'bogus'")
|
|
}
|
|
}
|
|
|
|
func TestStore_Schema_FingerprintWidthCheckRejectsShortValue(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
now := time.Now().UTC()
|
|
full := testFingerprint("width")
|
|
short := full[:63]
|
|
_, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_reservations (id, watch_id, fingerprint, task_id, alert_raw, created_at)
|
|
VALUES (?, ?, ?, '', '', ?)`), "res-short", "watch-1", short, now)
|
|
if err == nil {
|
|
t.Fatal("expected the fingerprint width CHECK to reject a 63-character value")
|
|
}
|
|
}
|
|
|
|
func TestStore_Schema_UniqueWorkspaceAndName(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
insertTestSource(t, db, "src-a", "ws-1", "dup-name")
|
|
now := time.Now().UTC()
|
|
_, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
|
|
VALUES (?, ?, 'datadog', ?, ?, ?)`), "src-b", "ws-1", "dup-name", now, now)
|
|
if err == nil {
|
|
t.Fatal("expected UNIQUE(workspace_id, name) to reject a duplicate")
|
|
}
|
|
}
|
|
|
|
func TestStore_Schema_Defaults(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
now := time.Now().UTC()
|
|
if _, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_sources (id, workspace_id, type, name, created_at, updated_at)
|
|
VALUES (?, ?, 'datadog', 's1', ?, ?)`), "src-defaults", "ws-1", now, now); err != nil {
|
|
t.Fatalf("insert source: %v", err)
|
|
}
|
|
var configJSON, healthStatus string
|
|
var enabled bool
|
|
if err := db.QueryRow(db.Rebind(`SELECT config_json, health_status, enabled FROM alert_sources WHERE id = ?`),
|
|
"src-defaults").Scan(&configJSON, &healthStatus, &enabled); err != nil {
|
|
t.Fatalf("read source defaults: %v", err)
|
|
}
|
|
if configJSON != "{}" || healthStatus != "unknown" || !enabled {
|
|
t.Fatalf("source defaults: config_json=%q health_status=%q enabled=%v", configJSON, healthStatus, enabled)
|
|
}
|
|
|
|
if _, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_watches (id, workspace_id, source_id, workflow_id, workflow_step_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, 'wf-1', 'step-1', ?, ?)`), "watch-defaults", "ws-1", "src-defaults", now, now); err != nil {
|
|
t.Fatalf("insert watch: %v", err)
|
|
}
|
|
var pollInterval, maxInflight sql.NullInt64
|
|
var watchEnabled bool
|
|
if err := db.QueryRow(db.Rebind(`SELECT poll_interval_seconds, max_inflight_tasks, enabled FROM alert_watches WHERE id = ?`),
|
|
"watch-defaults").Scan(&pollInterval, &maxInflight, &watchEnabled); err != nil {
|
|
t.Fatalf("read watch defaults: %v", err)
|
|
}
|
|
if !pollInterval.Valid || pollInterval.Int64 != 300 {
|
|
t.Fatalf("expected poll_interval_seconds default 300, got %+v", pollInterval)
|
|
}
|
|
if !maxInflight.Valid || maxInflight.Int64 != 5 {
|
|
t.Fatalf("expected max_inflight_tasks default 5, got %+v", maxInflight)
|
|
}
|
|
if !watchEnabled {
|
|
t.Fatal("expected watch enabled default true")
|
|
}
|
|
|
|
if _, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
|
|
VALUES (?, ?, ?, ?)`), "res-defaults", "watch-defaults", testFingerprint("defaults"), now); err != nil {
|
|
t.Fatalf("insert reservation: %v", err)
|
|
}
|
|
var taskID, alertRaw string
|
|
if err := db.QueryRow(db.Rebind(`SELECT task_id, alert_raw FROM alert_reservations WHERE id = ?`),
|
|
"res-defaults").Scan(&taskID, &alertRaw); err != nil {
|
|
t.Fatalf("read reservation defaults: %v", err)
|
|
}
|
|
if taskID != "" || alertRaw != "" {
|
|
t.Fatalf("expected task_id and alert_raw to default to '', got task_id=%q alert_raw=%q", taskID, alertRaw)
|
|
}
|
|
}
|
|
|
|
// TestStore_Schema_CaseSensitiveCollation inserts directly below the store
|
|
// API (isFingerprint would reject the uppercase value at the Go boundary)
|
|
// to assert that two fingerprints differing only in case are admitted as
|
|
// two distinct live rows under the partial unique index — a
|
|
// case-insensitive default collation would collapse them (D14, D16.3).
|
|
func TestStore_Schema_CaseSensitiveCollation(t *testing.T) {
|
|
_, db := newTestStore(t)
|
|
seedSourceAndWatch(t, db)
|
|
now := time.Now().UTC()
|
|
lower := testFingerprint("collation")
|
|
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(db.Rebind(`
|
|
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
|
|
VALUES (?, ?, ?, ?)`), "res-lower", "watch-1", lower, now); err != nil {
|
|
t.Fatalf("insert lowercase: %v", err)
|
|
}
|
|
if _, err := db.Exec(db.Rebind(`
|
|
INSERT INTO alert_reservations (id, watch_id, fingerprint, created_at)
|
|
VALUES (?, ?, ?, ?)`), "res-upper", "watch-1", 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`); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Fatalf("expected 2 distinct live rows under case-sensitive collation, got %d", count)
|
|
}
|
|
}
|