Files
beads/internal/storage/sqlite/migrations_test.go
Steve Yegge 528f27c053 Add orphan detection migration (bd-3852)
Creates migration to detect orphaned child issues and logs them for user
action. Orphaned children are issues with hierarchical IDs (e.g., "parent.child")
where the parent issue no longer exists in the database.

The migration:
- Queries for issues with IDs like '%.%' where parent doesn't exist
- Logs detected orphans with suggested actions (delete, convert, or restore)
- Does NOT automatically delete or convert orphans
- Is idempotent and safe to run multiple times

Test coverage:
- Detects orphaned child issues correctly
- Handles clean databases with no orphans
- Verifies idempotency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 00:34:26 -08:00

619 lines
18 KiB
Go

package sqlite
import (
"context"
"database/sql"
"strings"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite/migrations"
"github.com/steveyegge/beads/internal/types"
)
func TestMigrateDirtyIssuesTable(t *testing.T) {
t.Run("creates dirty_issues table if not exists", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop table if exists
_, _ = db.Exec("DROP TABLE IF EXISTS dirty_issues")
// Run migration
if err := migrations.MigrateDirtyIssuesTable(db); err != nil {
t.Fatalf("failed to migrate dirty_issues table: %v", err)
}
// Verify table exists
var tableName string
err := db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='dirty_issues'
`).Scan(&tableName)
if err != nil {
t.Fatalf("dirty_issues table not found: %v", err)
}
})
t.Run("adds content_hash column to existing table", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop and create table without content_hash
_, _ = db.Exec("DROP TABLE IF EXISTS dirty_issues")
_, err := db.Exec(`
CREATE TABLE dirty_issues (
issue_id TEXT PRIMARY KEY,
marked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
t.Fatalf("failed to create table: %v", err)
}
// Run migration
if err := migrations.MigrateDirtyIssuesTable(db); err != nil {
t.Fatalf("failed to migrate dirty_issues table: %v", err)
}
// Verify content_hash column exists
var hasContentHash bool
err = db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('dirty_issues')
WHERE name = 'content_hash'
`).Scan(&hasContentHash)
if err != nil {
t.Fatalf("failed to check for content_hash column: %v", err)
}
if !hasContentHash {
t.Error("content_hash column was not added")
}
})
}
func TestMigrateExternalRefColumn(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Run migration
if err := migrations.MigrateExternalRefColumn(db); err != nil {
t.Fatalf("failed to migrate external_ref column: %v", err)
}
// Verify column exists
rows, err := db.Query("PRAGMA table_info(issues)")
if err != nil {
t.Fatalf("failed to query table info: %v", err)
}
defer rows.Close()
found := false
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt *string
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
t.Fatalf("failed to scan column info: %v", err)
}
if name == "external_ref" {
found = true
break
}
}
if !found {
t.Error("external_ref column was not added")
}
}
func TestMigrateCompositeIndexes(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop index if exists
_, _ = db.Exec("DROP INDEX IF EXISTS idx_dependencies_depends_on_type")
// Run migration
if err := migrations.MigrateCompositeIndexes(db); err != nil {
t.Fatalf("failed to migrate composite indexes: %v", err)
}
// Verify index exists
var indexName string
err := db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_dependencies_depends_on_type'
`).Scan(&indexName)
if err != nil {
t.Fatalf("composite index not found: %v", err)
}
}
func TestMigrateClosedAtConstraint(t *testing.T) {
s, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// The constraint is now enforced in schema, so we can't easily create inconsistent state.
// Instead, just verify the migration runs successfully on clean data.
issue := &types.Issue{Title: "test-migrate", Priority: 1, IssueType: "task", Status: "open"}
err := s.CreateIssue(ctx, issue, "test")
if err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Run migration (should succeed with no inconsistent data)
if err := migrations.MigrateClosedAtConstraint(s.db); err != nil {
t.Fatalf("failed to migrate closed_at constraint: %v", err)
}
// Verify issue is still valid
got, err := s.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if got.ClosedAt != nil {
t.Error("closed_at should be nil for open issue")
}
}
func TestMigrateCompactionColumns(t *testing.T) {
s, cleanup := setupTestDB(t)
defer cleanup()
// Remove compaction columns if they exist
_, _ = s.db.Exec(`ALTER TABLE issues DROP COLUMN compaction_level`)
_, _ = s.db.Exec(`ALTER TABLE issues DROP COLUMN compacted_at`)
_, _ = s.db.Exec(`ALTER TABLE issues DROP COLUMN original_size`)
// Run migration (will fail since columns don't exist, but that's okay for this test)
// The migration should handle this gracefully
_ = migrations.MigrateCompactionColumns(s.db)
// Verify at least one column exists by querying
var exists bool
err := s.db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'compaction_level'
`).Scan(&exists)
if err != nil {
t.Fatalf("failed to check compaction columns: %v", err)
}
// It's okay if the columns don't exist in test schema
// The migration is idempotent and will add them when needed
}
func TestMigrateSnapshotsTable(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop table if exists
_, _ = db.Exec("DROP TABLE IF EXISTS issue_snapshots")
// Run migration
if err := migrations.MigrateSnapshotsTable(db); err != nil {
t.Fatalf("failed to migrate snapshots table: %v", err)
}
// Verify table exists
var tableExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM sqlite_master
WHERE type='table' AND name='issue_snapshots'
`).Scan(&tableExists)
if err != nil {
t.Fatalf("failed to check snapshots table: %v", err)
}
if !tableExists {
t.Error("issue_snapshots table was not created")
}
}
func TestMigrateCompactionConfig(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Clear config table
_, _ = db.Exec("DELETE FROM config WHERE key LIKE 'compact%'")
// Run migration
if err := migrations.MigrateCompactionConfig(db); err != nil {
t.Fatalf("failed to migrate compaction config: %v", err)
}
// Verify config values exist
var value string
err := db.QueryRow(`SELECT value FROM config WHERE key = 'compaction_enabled'`).Scan(&value)
if err != nil {
t.Fatalf("compaction config not found: %v", err)
}
if value != "false" {
t.Errorf("expected compaction_enabled='false', got %q", value)
}
}
func TestMigrateCompactedAtCommitColumn(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Run migration
if err := migrations.MigrateCompactedAtCommitColumn(db); err != nil {
t.Fatalf("failed to migrate compacted_at_commit column: %v", err)
}
// Verify column exists
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'compacted_at_commit'
`).Scan(&columnExists)
if err != nil {
t.Fatalf("failed to check compacted_at_commit column: %v", err)
}
if !columnExists {
t.Error("compacted_at_commit column was not added")
}
}
func TestMigrateExportHashesTable(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop table if exists
_, _ = db.Exec("DROP TABLE IF EXISTS export_hashes")
// Run migration
if err := migrations.MigrateExportHashesTable(db); err != nil {
t.Fatalf("failed to migrate export_hashes table: %v", err)
}
// Verify table exists
var tableName string
err := db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='export_hashes'
`).Scan(&tableName)
if err != nil {
t.Fatalf("export_hashes table not found: %v", err)
}
}
func TestMigrateExternalRefUnique(t *testing.T) {
t.Run("creates unique index on external_ref", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
externalRef1 := "JIRA-1"
externalRef2 := "JIRA-2"
issue1 := types.Issue{ID: "bd-1", Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, ExternalRef: &externalRef1}
if err := store.CreateIssue(context.Background(), &issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
issue2 := types.Issue{ID: "bd-2", Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, ExternalRef: &externalRef2}
if err := store.CreateIssue(context.Background(), &issue2, "test"); err != nil {
t.Fatalf("failed to create issue2: %v", err)
}
if err := migrations.MigrateExternalRefUnique(db); err != nil {
t.Fatalf("failed to migrate external_ref unique constraint: %v", err)
}
issue3 := types.Issue{ID: "bd-3", Title: "Issue 3", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, ExternalRef: &externalRef1}
err := store.CreateIssue(context.Background(), &issue3, "test")
if err == nil {
t.Error("Expected error when creating issue with duplicate external_ref, got nil")
}
})
t.Run("fails if duplicates exist", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
_, err := db.Exec(`DROP INDEX IF EXISTS idx_issues_external_ref_unique`)
if err != nil {
t.Fatalf("failed to drop index: %v", err)
}
externalRef := "JIRA-1"
issue1 := types.Issue{ID: "bd-100", Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, ExternalRef: &externalRef}
if err := store.CreateIssue(context.Background(), &issue1, "test"); err != nil {
t.Fatalf("failed to create issue1: %v", err)
}
_, err = db.Exec(`INSERT INTO issues (id, title, status, priority, issue_type, external_ref, created_at, updated_at) VALUES (?, ?, 'open', 1, 'task', ?, datetime('now'), datetime('now'))`, "bd-101", "Issue 2", externalRef)
if err != nil {
t.Fatalf("failed to create duplicate: %v", err)
}
err = migrations.MigrateExternalRefUnique(db)
if err == nil {
t.Error("Expected migration to fail with duplicates present")
}
if !strings.Contains(err.Error(), "duplicate external_ref values") {
t.Errorf("Expected error about duplicates, got: %v", err)
}
})
}
func TestMigrateRepoMtimesTable(t *testing.T) {
t.Run("creates repo_mtimes table if not exists", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Drop table if exists
_, _ = db.Exec("DROP TABLE IF EXISTS repo_mtimes")
// Run migration
if err := migrations.MigrateRepoMtimesTable(db); err != nil {
t.Fatalf("failed to migrate repo_mtimes table: %v", err)
}
// Verify table exists
var tableName string
err := db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='repo_mtimes'
`).Scan(&tableName)
if err != nil {
t.Fatalf("repo_mtimes table not found: %v", err)
}
})
t.Run("is idempotent", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Run migration twice
if err := migrations.MigrateRepoMtimesTable(db); err != nil {
t.Fatalf("first migration failed: %v", err)
}
if err := migrations.MigrateRepoMtimesTable(db); err != nil {
t.Fatalf("second migration failed: %v", err)
}
// Verify table still exists and is correct
var tableName string
err := db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='repo_mtimes'
`).Scan(&tableName)
if err != nil {
t.Fatalf("repo_mtimes table not found after idempotent migration: %v", err)
}
})
}
func TestMigrateContentHashColumn(t *testing.T) {
t.Run("adds content_hash column if missing", func(t *testing.T) {
s, cleanup := setupTestDB(t)
defer cleanup()
// Run migration (should be idempotent)
if err := migrations.MigrateContentHashColumn(s.db); err != nil {
t.Fatalf("failed to migrate content_hash column: %v", err)
}
// Verify column exists
var colName string
err := s.db.QueryRow(`
SELECT name FROM pragma_table_info('issues')
WHERE name = 'content_hash'
`).Scan(&colName)
if err == sql.ErrNoRows {
t.Error("content_hash column was not added")
} else if err != nil {
t.Fatalf("failed to check content_hash column: %v", err)
}
})
t.Run("populates content_hash for existing issues", func(t *testing.T) {
s, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a test issue
issue := &types.Issue{Title: "test-hash", Priority: 1, IssueType: "task", Status: "open"}
err := s.CreateIssue(ctx, issue, "test")
if err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Clear its content_hash directly in DB
_, err = s.db.Exec(`UPDATE issues SET content_hash = NULL WHERE id = ?`, issue.ID)
if err != nil {
t.Fatalf("failed to clear content_hash: %v", err)
}
// Verify it's cleared
var hash sql.NullString
err = s.db.QueryRow(`SELECT content_hash FROM issues WHERE id = ?`, issue.ID).Scan(&hash)
if err != nil {
t.Fatalf("failed to verify cleared hash: %v", err)
}
if hash.Valid {
t.Error("content_hash should be NULL before migration")
}
// Drop the column to simulate fresh migration
_, err = s.db.Exec(`
CREATE TABLE issues_backup AS SELECT * FROM issues;
DROP TABLE issues;
CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'blocked', 'closed')),
priority INTEGER NOT NULL,
issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore')),
assignee TEXT,
estimated_minutes INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
closed_at DATETIME,
external_ref TEXT,
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,
original_size INTEGER,
compacted_at_commit TEXT,
source_repo TEXT DEFAULT '.',
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo FROM issues_backup;
DROP TABLE issues_backup;
`)
if err != nil {
t.Fatalf("failed to drop content_hash column: %v", err)
}
// Run migration - this should add the column and populate it
if err := migrations.MigrateContentHashColumn(s.db); err != nil {
t.Fatalf("failed to migrate content_hash column: %v", err)
}
// Verify content_hash is now populated
got, err := s.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if got.ContentHash == "" {
t.Error("content_hash should be populated after migration")
}
})
}
func TestMigrateOrphanDetection(t *testing.T) {
t.Run("detects orphaned child issues", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
ctx := context.Background()
// Create a parent issue
parent := &types.Issue{
ID: "bd-parent",
Title: "Parent Issue",
Priority: 1,
IssueType: "task",
Status: "open",
}
if err := store.CreateIssue(ctx, parent, "test"); err != nil {
t.Fatalf("failed to create parent issue: %v", err)
}
// Create a valid child issue
validChild := &types.Issue{
ID: "bd-parent.1",
Title: "Valid Child",
Priority: 1,
IssueType: "task",
Status: "open",
}
if err := store.CreateIssue(ctx, validChild, "test"); err != nil {
t.Fatalf("failed to create valid child: %v", err)
}
// Create an orphaned child by directly inserting it into the database
// (bypassing CreateIssue validation which checks for parent existence)
_, err := db.Exec(`
INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, "bd-missing.1", "Orphaned Child", "open", 1, "task")
if err != nil {
t.Fatalf("failed to create orphan: %v", err)
}
// Run migration - it should detect the orphan and log it
if err := migrations.MigrateOrphanDetection(db); err != nil {
t.Fatalf("failed to run orphan detection migration: %v", err)
}
// Verify the orphan still exists (migration doesn't delete)
got, err := store.GetIssue(ctx, "bd-missing.1")
if err != nil {
t.Fatalf("orphan should still exist after migration: %v", err)
}
if got.ID != "bd-missing.1" {
t.Errorf("expected orphan ID bd-missing.1, got %s", got.ID)
}
// Verify valid child still exists
got, err = store.GetIssue(ctx, "bd-parent.1")
if err != nil {
t.Fatalf("valid child should still exist: %v", err)
}
if got.ID != "bd-parent.1" {
t.Errorf("expected valid child ID bd-parent.1, got %s", got.ID)
}
})
t.Run("no orphans found in clean database", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
ctx := context.Background()
// Create a parent with valid children
parent := &types.Issue{
ID: "bd-p1",
Title: "Parent",
Priority: 1,
IssueType: "task",
Status: "open",
}
if err := store.CreateIssue(ctx, parent, "test"); err != nil {
t.Fatalf("failed to create parent: %v", err)
}
child := &types.Issue{
ID: "bd-p1.1",
Title: "Child",
Priority: 1,
IssueType: "task",
Status: "open",
}
if err := store.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("failed to create child: %v", err)
}
// Run migration - should succeed with no orphans
if err := migrations.MigrateOrphanDetection(db); err != nil {
t.Fatalf("migration should succeed with clean data: %v", err)
}
})
t.Run("is idempotent", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Run migration multiple times
for i := 0; i < 3; i++ {
if err := migrations.MigrateOrphanDetection(db); err != nil {
t.Fatalf("migration run %d failed: %v", i+1, err)
}
}
})
}