Add migration inspection tools for AI agents (bd-627d Phase 2)
Implemented: - bd migrate --inspect --json: Shows migration plan, db state, warnings - bd info --schema --json: Returns schema details for agents - Migration invariants: Validates migrations post-execution - Added ListMigrations() for introspection Phase 1 (invariants) and Phase 2 (inspection) complete. Next: Wire up MCP tools in beads-mcp server. Amp-Thread-ID: https://ampcode.com/threads/T-c4674660-d640-405f-a929-b664e8699a48 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
260
internal/storage/sqlite/migration_invariants_test.go
Normal file
260
internal/storage/sqlite/migration_invariants_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCaptureSnapshot(t *testing.T) {
|
||||
db := setupInvariantTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Create some test data
|
||||
_, err := db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test issue: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('test-1', 'test-1', 'test')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test dependency: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`INSERT INTO labels (issue_id, label) VALUES ('test-1', 'test-label')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test label: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := captureSnapshot(db)
|
||||
if err != nil {
|
||||
t.Fatalf("captureSnapshot failed: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.IssueCount != 1 {
|
||||
t.Errorf("expected IssueCount=1, got %d", snapshot.IssueCount)
|
||||
}
|
||||
|
||||
if snapshot.DependencyCount != 1 {
|
||||
t.Errorf("expected DependencyCount=1, got %d", snapshot.DependencyCount)
|
||||
}
|
||||
|
||||
if snapshot.LabelCount != 1 {
|
||||
t.Errorf("expected LabelCount=1, got %d", snapshot.LabelCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRequiredConfig(t *testing.T) {
|
||||
db := setupInvariantTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Test with no issues - should pass even without issue_prefix
|
||||
snapshot := &Snapshot{IssueCount: 0}
|
||||
err := checkRequiredConfig(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with 0 issues, got: %v", err)
|
||||
}
|
||||
|
||||
// Add an issue to the database
|
||||
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert issue: %v", err)
|
||||
}
|
||||
|
||||
// Delete issue_prefix config
|
||||
_, err = db.Exec(`DELETE FROM config WHERE key = 'issue_prefix'`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete config: %v", err)
|
||||
}
|
||||
|
||||
// Should fail now that we have an issue but no prefix
|
||||
err = checkRequiredConfig(db, snapshot)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing issue_prefix with issues, got nil")
|
||||
}
|
||||
|
||||
// Add required config back
|
||||
_, err = db.Exec(`INSERT INTO config (key, value) VALUES ('issue_prefix', 'test')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert config: %v", err)
|
||||
}
|
||||
|
||||
// Test with required config present
|
||||
err = checkRequiredConfig(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with issue_prefix set, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForeignKeys(t *testing.T) {
|
||||
db := setupInvariantTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
snapshot := &Snapshot{}
|
||||
|
||||
// Test with no data - should pass
|
||||
err := checkForeignKeys(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with empty db, got: %v", err)
|
||||
}
|
||||
|
||||
// Create an issue
|
||||
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test issue: %v", err)
|
||||
}
|
||||
|
||||
// Add valid dependency
|
||||
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('test-1', 'test-1', 'test')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert dependency: %v", err)
|
||||
}
|
||||
|
||||
// Should pass with valid foreign keys
|
||||
err = checkForeignKeys(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with valid dependencies, got: %v", err)
|
||||
}
|
||||
|
||||
// Manually create orphaned dependency (bypassing FK constraints for testing)
|
||||
_, err = db.Exec(`PRAGMA foreign_keys = OFF`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('orphan-1', 'test-1', 'test')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert orphaned dependency: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
// Should fail with orphaned dependency
|
||||
err = checkForeignKeys(db, snapshot)
|
||||
if err == nil {
|
||||
t.Error("expected error for orphaned dependency, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIssueCount(t *testing.T) {
|
||||
db := setupInvariantTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Create initial issue
|
||||
_, err := db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test issue: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := captureSnapshot(db)
|
||||
if err != nil {
|
||||
t.Fatalf("captureSnapshot failed: %v", err)
|
||||
}
|
||||
|
||||
// Same count - should pass
|
||||
err = checkIssueCount(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with same count, got: %v", err)
|
||||
}
|
||||
|
||||
// Add an issue - should pass (count increased)
|
||||
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-2', 'Test Issue 2')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert second issue: %v", err)
|
||||
}
|
||||
|
||||
err = checkIssueCount(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error with increased count, got: %v", err)
|
||||
}
|
||||
|
||||
// Delete both issues to simulate data loss
|
||||
_, err = db.Exec(`DELETE FROM issues`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete issues: %v", err)
|
||||
}
|
||||
|
||||
// Should fail when count decreased
|
||||
err = checkIssueCount(db, snapshot)
|
||||
if err == nil {
|
||||
t.Error("expected error for decreased issue count, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyInvariants(t *testing.T) {
|
||||
db := setupInvariantTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
snapshot, err := captureSnapshot(db)
|
||||
if err != nil {
|
||||
t.Fatalf("captureSnapshot failed: %v", err)
|
||||
}
|
||||
|
||||
// All invariants should pass with empty database
|
||||
err = verifyInvariants(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no errors with empty db, got: %v", err)
|
||||
}
|
||||
|
||||
// Add an issue (which requires issue_prefix)
|
||||
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert issue: %v", err)
|
||||
}
|
||||
|
||||
// Capture new snapshot with issue
|
||||
snapshot, err = captureSnapshot(db)
|
||||
if err != nil {
|
||||
t.Fatalf("captureSnapshot failed: %v", err)
|
||||
}
|
||||
|
||||
// Should still pass (issue_prefix is set by newTestStore)
|
||||
err = verifyInvariants(db, snapshot)
|
||||
if err != nil {
|
||||
t.Errorf("expected no errors with issue and prefix, got: %v", err)
|
||||
}
|
||||
|
||||
// Remove required config to trigger failure
|
||||
_, err = db.Exec(`DELETE FROM config WHERE key = 'issue_prefix'`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete config: %v", err)
|
||||
}
|
||||
|
||||
err = verifyInvariants(db, snapshot)
|
||||
if err == nil {
|
||||
t.Error("expected error when issue_prefix missing with issues, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInvariantNames(t *testing.T) {
|
||||
names := GetInvariantNames()
|
||||
|
||||
expectedNames := []string{
|
||||
"foreign_keys_valid",
|
||||
"issue_count_stable",
|
||||
"required_config_present",
|
||||
}
|
||||
|
||||
if len(names) != len(expectedNames) {
|
||||
t.Errorf("expected %d invariants, got %d", len(expectedNames), len(names))
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
if name != expectedNames[i] {
|
||||
t.Errorf("expected invariant[%d]=%s, got %s", i, expectedNames[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupInvariantTestDB creates an in-memory test database with schema
|
||||
func setupInvariantTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
store := newTestStore(t, ":memory:")
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
// Return the underlying database connection
|
||||
return store.db
|
||||
}
|
||||
Reference in New Issue
Block a user