- Created migrations/ subdirectory with 14 individual migration files - Reduced migrations.go from 680 to 98 lines (orchestration only) - Updated test imports to use migrations package - Updated MULTI_REPO_HYDRATION.md documentation - All tests passing
505 lines
14 KiB
Go
505 lines
14 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")
|
|
}
|
|
})
|
|
}
|