- 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
317 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|