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>
619 lines
18 KiB
Go
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, ¬null, &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)
|
|
}
|
|
}
|
|
})
|
|
}
|