Files
beads/cmd/bd/migrate_hash_ids_test.go
Charles P. Cross 8c1f865e23 Fix doctor incorrectly diagnosing hash IDs as sequential (issue #322)
- Enhanced checkIDFormat to sample multiple issues instead of just one
- Added detectHashBasedIDs function with robust multi-heuristic detection:
  * Checks for child_counters table (hash ID schema indicator)
  * Detects letters in IDs (base36 encoding)
  * Identifies leading zeros (common in hash IDs, rare in sequential)
  * Analyzes variable length patterns (adaptive hash IDs)
  * Checks for non-sequential numeric ordering
- Added comprehensive test coverage (16 new test cases)
- Fixes false positives for numeric-only hash IDs like 'pf-0088'

Closes #322
2025-11-18 05:20:11 -05:00

317 lines
7.6 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
func TestMigrateHashIDs(t *testing.T) {
// Create temporary directory for test database
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Create test database with sequential IDs
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
ctx := context.Background()
// Set ID prefix config
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create test issues with sequential IDs
issue1 := &types.Issue{
ID: "bd-1",
Title: "First issue",
Description: "This is issue bd-1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue 1: %v", err)
}
issue2 := &types.Issue{
ID: "bd-2",
Title: "Second issue",
Description: "This is issue bd-2 which references bd-1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create issue 2: %v", err)
}
// Create a dependency
dep := &types.Dependency{
IssueID: "bd-2",
DependsOnID: "bd-1",
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Close store before migration
store.Close()
// Test dry run
store, err = sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to reopen database: %v", err)
}
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to get issues: %v", err)
}
mapping, err := migrateToHashIDs(ctx, store, issues, true)
if err != nil {
t.Fatalf("Dry run failed: %v", err)
}
if len(mapping) != 2 {
t.Errorf("Expected 2 issues in mapping, got %d", len(mapping))
}
// Check mapping contains both IDs
if _, ok := mapping["bd-1"]; !ok {
t.Error("Mapping missing bd-1")
}
if _, ok := mapping["bd-2"]; !ok {
t.Error("Mapping missing bd-2")
}
// Verify new IDs are hash-based
for old, new := range mapping {
if !isHashID(new) {
t.Errorf("New ID %s for %s is not a hash ID", new, old)
}
}
store.Close()
// Test actual migration
store, err = sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to reopen database: %v", err)
}
defer store.Close()
issues, err = store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to get issues: %v", err)
}
mapping, err = migrateToHashIDs(ctx, store, issues, false)
if err != nil {
t.Fatalf("Migration failed: %v", err)
}
// Verify migration
newID1 := mapping["bd-1"]
newID2 := mapping["bd-2"]
// Get migrated issues
migratedIssue1, err := store.GetIssue(ctx, newID1)
if err != nil {
t.Fatalf("Failed to get migrated issue 1: %v", err)
}
migratedIssue2, err := store.GetIssue(ctx, newID2)
if err != nil {
t.Fatalf("Failed to get migrated issue 2: %v", err)
}
// Verify content is preserved
if migratedIssue1.Title != "First issue" {
t.Errorf("Issue 1 title changed: %s", migratedIssue1.Title)
}
if migratedIssue2.Title != "Second issue" {
t.Errorf("Issue 2 title changed: %s", migratedIssue2.Title)
}
// Verify text reference was updated
if migratedIssue2.Description != "This is issue "+newID2+" which references "+newID1 {
t.Errorf("Text references not updated: %s", migratedIssue2.Description)
}
// Verify dependency was updated
deps, err := store.GetDependencyRecords(ctx, newID2)
if err != nil {
t.Fatalf("Failed to get dependencies: %v", err)
}
if len(deps) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps))
}
if deps[0].IssueID != newID2 {
t.Errorf("Dependency issue_id not updated: %s", deps[0].IssueID)
}
if deps[0].DependsOnID != newID1 {
t.Errorf("Dependency depends_on_id not updated: %s", deps[0].DependsOnID)
}
}
func TestMigrateHashIDsWithParentChild(t *testing.T) {
// Create temporary directory for test database
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Create test database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer store.Close()
ctx := context.Background()
// Set ID prefix config
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create epic (parent)
epic := &types.Issue{
ID: "bd-1",
Title: "Epic issue",
Description: "This is an epic",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
if err := store.CreateIssue(ctx, epic, "test"); err != nil {
t.Fatalf("Failed to create epic: %v", err)
}
// Create child issue
child := &types.Issue{
ID: "bd-2",
Title: "Child issue",
Description: "This is a child of bd-1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create child: %v", err)
}
// Create parent-child dependency
dep := &types.Dependency{
IssueID: "bd-2",
DependsOnID: "bd-1",
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Migrate
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to get issues: %v", err)
}
mapping, err := migrateToHashIDs(ctx, store, issues, false)
if err != nil {
t.Fatalf("Migration failed: %v", err)
}
// Verify parent got hash ID
newEpicID := mapping["bd-1"]
if !isHashID(newEpicID) {
t.Errorf("Epic ID is not a hash ID: %s", newEpicID)
}
// Verify child got hierarchical ID (parent.1)
newChildID := mapping["bd-2"]
expectedChildID := newEpicID + ".1"
if newChildID != expectedChildID {
t.Errorf("Child ID should be %s, got %s", expectedChildID, newChildID)
}
}
func TestIsHashID(t *testing.T) {
tests := []struct {
id string
expected bool
}{
// Sequential IDs (numeric only, short)
{"bd-1", false},
{"bd-123", false},
{"bd-9999", false},
// Hash IDs with letters
{"bd-a3f8e9a2", true},
{"bd-abc123", true},
{"bd-123abc", true},
{"bd-a3f8e9a2.1", true},
{"bd-a3f8e9a2.1.2", true},
// Hash IDs that are numeric but 5+ characters (likely hash)
{"bd-12345", true},
{"bd-0088", false}, // 4 chars, all numeric - ambiguous, defaults to false
{"bd-00880", true}, // 5+ chars, likely hash
// Base36 hash IDs with letters
{"bd-5n3", true},
{"bd-65w", true},
{"bd-jmx", true},
{"bd-4rt", true},
// Edge cases
{"bd-", false}, // Empty suffix
{"invalid", false}, // No dash
{"bd-0", false}, // Single digit
}
for _, tt := range tests {
result := isHashID(tt.id)
if result != tt.expected {
t.Errorf("isHashID(%s) = %v, want %v", tt.id, result, tt.expected)
}
}
}
func TestCopyFile(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "source.txt")
dst := filepath.Join(tmpDir, "dest.txt")
// Write test file
content := []byte("test content")
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatalf("Failed to write source file: %v", err)
}
// Copy file
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile failed: %v", err)
}
// Verify copy
copied, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("Failed to read destination file: %v", err)
}
if string(copied) != string(content) {
t.Errorf("Content mismatch: got %s, want %s", copied, content)
}
}