- TestOrphanHandling_Strict: Verifies import fails on missing parent - TestOrphanHandling_Resurrect: Verifies parent tombstone creation - TestOrphanHandling_Skip: Verifies orphans are skipped with warning - TestOrphanHandling_Allow: Verifies orphans import without validation - TestOrphanHandling_Config: Tests config reading with all modes + defaults - TestOrphanHandling_NonHierarchical: Verifies flat IDs work in all modes Also fixes batch_ops_test.go to pass OrphanHandling parameter to generateBatchIDs. All tests pass. Closes bd-968f Amp-Thread-ID: https://ampcode.com/threads/T-fd18d4a5-06b3-4400-9073-194d570846d8 Co-authored-by: Amp <amp@ampcode.com>
465 lines
12 KiB
Go
465 lines
12 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestValidateBatchIssues(t *testing.T) {
|
|
t.Run("validates all issues in batch", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Valid issue 1", Priority: 1, IssueType: "task", Status: "open"},
|
|
{Title: "Valid issue 2", Priority: 2, IssueType: "bug", Status: "open"},
|
|
}
|
|
|
|
err := validateBatchIssues(issues)
|
|
if err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
|
|
// Verify timestamps were set
|
|
for i, issue := range issues {
|
|
if issue.CreatedAt.IsZero() {
|
|
t.Errorf("issue %d CreatedAt should be set", i)
|
|
}
|
|
if issue.UpdatedAt.IsZero() {
|
|
t.Errorf("issue %d UpdatedAt should be set", i)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("preserves provided timestamps", func(t *testing.T) {
|
|
now := time.Now()
|
|
pastTime := now.Add(-24 * time.Hour)
|
|
|
|
issues := []*types.Issue{
|
|
{
|
|
Title: "Issue with timestamp",
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
Status: "open",
|
|
CreatedAt: pastTime,
|
|
UpdatedAt: pastTime,
|
|
},
|
|
}
|
|
|
|
err := validateBatchIssues(issues)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !issues[0].CreatedAt.Equal(pastTime) {
|
|
t.Error("CreatedAt should be preserved")
|
|
}
|
|
if !issues[0].UpdatedAt.Equal(pastTime) {
|
|
t.Error("UpdatedAt should be preserved")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects nil issue", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Valid issue", Priority: 1, IssueType: "task", Status: "open"},
|
|
nil,
|
|
}
|
|
|
|
err := validateBatchIssues(issues)
|
|
if err == nil {
|
|
t.Error("expected error for nil issue")
|
|
}
|
|
if !strings.Contains(err.Error(), "issue 1 is nil") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects invalid issue", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "", Priority: 1, IssueType: "task", Status: "open"}, // invalid: empty title
|
|
}
|
|
|
|
err := validateBatchIssues(issues)
|
|
if err == nil {
|
|
t.Error("expected validation error")
|
|
}
|
|
if !strings.Contains(err.Error(), "validation failed") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBatchCreateIssues(t *testing.T) {
|
|
s, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
t.Run("creates multiple issues atomically", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Batch issue 1", Priority: 1, IssueType: "task", Status: "open", Description: "First issue"},
|
|
{Title: "Batch issue 2", Priority: 2, IssueType: "bug", Status: "open", Description: "Second issue"},
|
|
{Title: "Batch issue 3", Priority: 1, IssueType: "feature", Status: "open", Description: "Third issue"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issues: %v", err)
|
|
}
|
|
|
|
// Verify all issues were created
|
|
for i, issue := range issues {
|
|
if issue.ID == "" {
|
|
t.Errorf("issue %d ID should be generated", i)
|
|
}
|
|
|
|
got, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Errorf("failed to get issue %d: %v", i, err)
|
|
}
|
|
if got.Title != issue.Title {
|
|
t.Errorf("issue %d title mismatch: want %q, got %q", i, issue.Title, got.Title)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("rolls back on validation error", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Valid issue", Priority: 1, IssueType: "task", Status: "open"},
|
|
{Title: "", Priority: 1, IssueType: "task", Status: "open"}, // invalid: empty title
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err == nil {
|
|
t.Fatal("expected validation error")
|
|
}
|
|
|
|
// Verify no issues were created
|
|
if issues[0].ID != "" {
|
|
_, err := s.GetIssue(ctx, issues[0].ID)
|
|
if err == nil {
|
|
t.Error("first issue should not have been created (transaction rollback)")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("handles empty batch", func(t *testing.T) {
|
|
var issues []*types.Issue
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Errorf("empty batch should succeed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("handles explicit IDs", func(t *testing.T) {
|
|
prefix := "bd"
|
|
issues := []*types.Issue{
|
|
{ID: prefix + "-explicit1", Title: "Explicit ID 1", Priority: 1, IssueType: "task", Status: "open"},
|
|
{ID: prefix + "-explicit2", Title: "Explicit ID 2", Priority: 1, IssueType: "task", Status: "open"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issues with explicit IDs: %v", err)
|
|
}
|
|
|
|
// Verify IDs were preserved
|
|
for i, issue := range issues {
|
|
got, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue %d: %v", i, err)
|
|
}
|
|
if got.ID != issue.ID {
|
|
t.Errorf("issue %d ID mismatch: want %q, got %q", i, issue.ID, got.ID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("handles mix of explicit and generated IDs", func(t *testing.T) {
|
|
prefix := "bd"
|
|
issues := []*types.Issue{
|
|
{ID: prefix + "-mixed1", Title: "Explicit ID", Priority: 1, IssueType: "task", Status: "open"},
|
|
{Title: "Generated ID", Priority: 1, IssueType: "task", Status: "open"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issues: %v", err)
|
|
}
|
|
|
|
// Verify both IDs are valid
|
|
if issues[0].ID != prefix+"-mixed1" {
|
|
t.Errorf("explicit ID should be preserved, got %q", issues[0].ID)
|
|
}
|
|
if issues[1].ID == "" || !strings.HasPrefix(issues[1].ID, prefix+"-") {
|
|
t.Errorf("ID should be generated with correct prefix, got %q", issues[1].ID)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects wrong prefix", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "wrong-prefix-123", Title: "Wrong prefix", Priority: 1, IssueType: "task", Status: "open"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong prefix")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not match configured prefix") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("marks issues dirty", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Dirty test", Priority: 1, IssueType: "task", Status: "open"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
|
|
// Verify issue is marked dirty
|
|
var count int
|
|
err = s.db.QueryRow(`SELECT COUNT(*) FROM dirty_issues WHERE issue_id = ?`, issues[0].ID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check dirty status: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Error("issue should be marked dirty")
|
|
}
|
|
})
|
|
|
|
t.Run("sets content hash", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{Title: "Hash test", Description: "Test content hash", Priority: 1, IssueType: "task", Status: "open"},
|
|
}
|
|
|
|
err := s.CreateIssues(ctx, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
|
|
// Verify content hash was set
|
|
got, err := s.GetIssue(ctx, issues[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue: %v", err)
|
|
}
|
|
if got.ContentHash == "" {
|
|
t.Error("content hash should be set")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGenerateBatchIDs(t *testing.T) {
|
|
s, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
t.Run("generates unique IDs for batch", func(t *testing.T) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
issues := []*types.Issue{
|
|
{Title: "Issue 1", Description: "First", CreatedAt: time.Now()},
|
|
{Title: "Issue 2", Description: "Second", CreatedAt: time.Now()},
|
|
{Title: "Issue 3", Description: "Third", CreatedAt: time.Now()},
|
|
}
|
|
|
|
err = s.generateBatchIDs(ctx, conn, issues, "test-actor", OrphanAllow)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate IDs: %v", err)
|
|
}
|
|
|
|
// Verify all IDs are unique
|
|
seen := make(map[string]bool)
|
|
for i, issue := range issues {
|
|
if issue.ID == "" {
|
|
t.Errorf("issue %d ID should be generated", i)
|
|
}
|
|
if seen[issue.ID] {
|
|
t.Errorf("duplicate ID generated: %s", issue.ID)
|
|
}
|
|
seen[issue.ID] = true
|
|
}
|
|
})
|
|
|
|
t.Run("validates explicit IDs match prefix", func(t *testing.T) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
issues := []*types.Issue{
|
|
{ID: "wrong-prefix-123", Title: "Wrong", CreatedAt: time.Now()},
|
|
}
|
|
|
|
err = s.generateBatchIDs(ctx, conn, issues, "test-actor", OrphanAllow)
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong prefix")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBulkOperations(t *testing.T) {
|
|
s, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
t.Run("bulkInsertIssues", func(t *testing.T) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
prefix := "bd"
|
|
now := time.Now()
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: prefix + "-bulk1",
|
|
ContentHash: "hash1",
|
|
Title: "Bulk 1",
|
|
Description: "First",
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
{
|
|
ID: prefix + "-bulk2",
|
|
ContentHash: "hash2",
|
|
Title: "Bulk 2",
|
|
Description: "Second",
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
}
|
|
|
|
if _, err := conn.ExecContext(ctx, "BEGIN"); err != nil {
|
|
t.Fatalf("failed to begin transaction: %v", err)
|
|
}
|
|
defer conn.ExecContext(context.Background(), "ROLLBACK")
|
|
|
|
err = bulkInsertIssues(ctx, conn, issues)
|
|
if err != nil {
|
|
t.Fatalf("failed to bulk insert: %v", err)
|
|
}
|
|
|
|
conn.ExecContext(ctx, "COMMIT")
|
|
|
|
// Verify issues were inserted
|
|
for _, issue := range issues {
|
|
got, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Errorf("failed to get issue %s: %v", issue.ID, err)
|
|
}
|
|
if got.Title != issue.Title {
|
|
t.Errorf("title mismatch for %s", issue.ID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("bulkRecordEvents", func(t *testing.T) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Create test issues first
|
|
issue1 := &types.Issue{Title: "event-test-1", Priority: 1, IssueType: "task", Status: "open"}
|
|
err = s.CreateIssue(ctx, issue1, "test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue1: %v", err)
|
|
}
|
|
issue2 := &types.Issue{Title: "event-test-2", Priority: 1, IssueType: "task", Status: "open"}
|
|
err = s.CreateIssue(ctx, issue2, "test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue2: %v", err)
|
|
}
|
|
|
|
issues := []*types.Issue{issue1, issue2}
|
|
|
|
if _, err := conn.ExecContext(ctx, "BEGIN"); err != nil {
|
|
t.Fatalf("failed to begin transaction: %v", err)
|
|
}
|
|
defer conn.ExecContext(context.Background(), "ROLLBACK")
|
|
|
|
err = bulkRecordEvents(ctx, conn, issues, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("failed to bulk record events: %v", err)
|
|
}
|
|
|
|
conn.ExecContext(ctx, "COMMIT")
|
|
|
|
// Verify events were recorded
|
|
for _, issue := range issues {
|
|
var count int
|
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM events WHERE issue_id = ? AND event_type = ?`,
|
|
issue.ID, types.EventCreated).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check events: %v", err)
|
|
}
|
|
if count < 1 {
|
|
t.Errorf("no creation event found for %s", issue.ID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("bulkMarkDirty", func(t *testing.T) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to get connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Create test issues
|
|
issue1 := &types.Issue{Title: "dirty-test-1", Priority: 1, IssueType: "task", Status: "open"}
|
|
err = s.CreateIssue(ctx, issue1, "test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue1: %v", err)
|
|
}
|
|
issue2 := &types.Issue{Title: "dirty-test-2", Priority: 1, IssueType: "task", Status: "open"}
|
|
err = s.CreateIssue(ctx, issue2, "test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create issue2: %v", err)
|
|
}
|
|
|
|
issues := []*types.Issue{issue1, issue2}
|
|
|
|
if _, err := conn.ExecContext(ctx, "BEGIN"); err != nil {
|
|
t.Fatalf("failed to begin transaction: %v", err)
|
|
}
|
|
defer conn.ExecContext(context.Background(), "ROLLBACK")
|
|
|
|
err = bulkMarkDirty(ctx, conn, issues)
|
|
if err != nil {
|
|
t.Fatalf("failed to bulk mark dirty: %v", err)
|
|
}
|
|
|
|
conn.ExecContext(ctx, "COMMIT")
|
|
|
|
// Verify issues are marked dirty
|
|
for _, issue := range issues {
|
|
var count int
|
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM dirty_issues WHERE issue_id = ?`, issue.ID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check dirty status: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("issue %s should be marked dirty", issue.ID)
|
|
}
|
|
}
|
|
})
|
|
}
|