602 lines
15 KiB
Go
602 lines
15 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestReadIssueIDsFromFile(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-test-delete-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
t.Run("read valid IDs from file", func(t *testing.T) {
|
|
testFile := filepath.Join(tmpDir, "ids.txt")
|
|
content := "bd-1\nbd-2\nbd-3\n"
|
|
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
|
t.Fatalf("Failed to write test file: %v", err)
|
|
}
|
|
|
|
ids, err := readIssueIDsFromFile(testFile)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if len(ids) != 3 {
|
|
t.Errorf("Expected 3 IDs, got %d", len(ids))
|
|
}
|
|
|
|
expected := []string{"bd-1", "bd-2", "bd-3"}
|
|
for i, id := range ids {
|
|
if id != expected[i] {
|
|
t.Errorf("Expected ID %s at position %d, got %s", expected[i], i, id)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("skip empty lines and comments", func(t *testing.T) {
|
|
testFile := filepath.Join(tmpDir, "ids_with_comments.txt")
|
|
content := "bd-1\n\n# This is a comment\nbd-2\n \nbd-3\n"
|
|
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
|
t.Fatalf("Failed to write test file: %v", err)
|
|
}
|
|
|
|
ids, err := readIssueIDsFromFile(testFile)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if len(ids) != 3 {
|
|
t.Errorf("Expected 3 IDs (skipping comments/empty), got %d", len(ids))
|
|
}
|
|
})
|
|
|
|
t.Run("handle non-existent file", func(t *testing.T) {
|
|
_, err := readIssueIDsFromFile(filepath.Join(tmpDir, "nonexistent.txt"))
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent file")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUniqueStrings(t *testing.T) {
|
|
t.Run("remove duplicates", func(t *testing.T) {
|
|
input := []string{"a", "b", "a", "c", "b", "d"}
|
|
result := uniqueStrings(input)
|
|
|
|
if len(result) != 4 {
|
|
t.Errorf("Expected 4 unique strings, got %d", len(result))
|
|
}
|
|
|
|
// Verify all unique values are present
|
|
seen := make(map[string]bool)
|
|
for _, s := range result {
|
|
if seen[s] {
|
|
t.Errorf("Duplicate found in result: %s", s)
|
|
}
|
|
seen[s] = true
|
|
}
|
|
})
|
|
|
|
t.Run("handle empty input", func(t *testing.T) {
|
|
result := uniqueStrings([]string{})
|
|
if len(result) != 0 {
|
|
t.Errorf("Expected empty result, got %d items", len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("handle all unique", func(t *testing.T) {
|
|
input := []string{"a", "b", "c"}
|
|
result := uniqueStrings(input)
|
|
|
|
if len(result) != 3 {
|
|
t.Errorf("Expected 3 items, got %d", len(result))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBulkDeleteNoResurrection(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
testDB := filepath.Join(beadsDir, "beads.db")
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
testGitInit(t, tmpDir)
|
|
|
|
s := newTestStore(t, testDB)
|
|
ctx := context.Background()
|
|
|
|
totalIssues := 20
|
|
toDeleteCount := 10
|
|
var toDelete []string
|
|
|
|
for i := 1; i <= totalIssues; i++ {
|
|
issue := &types.Issue{
|
|
Title: "Issue " + string(rune('A'+i-1)),
|
|
Description: "Test issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue %d: %v", i, err)
|
|
}
|
|
if i <= toDeleteCount {
|
|
toDelete = append(toDelete, issue.ID)
|
|
}
|
|
}
|
|
|
|
exportToJSONLTest(t, s, jsonlPath)
|
|
testGitCommit(t, tmpDir, jsonlPath, "Add issues")
|
|
|
|
oldStore := store
|
|
oldDbPath := dbPath
|
|
oldAutoImportEnabled := autoImportEnabled
|
|
defer func() {
|
|
store = oldStore
|
|
dbPath = oldDbPath
|
|
autoImportEnabled = oldAutoImportEnabled
|
|
}()
|
|
|
|
store = s
|
|
dbPath = testDB
|
|
autoImportEnabled = true
|
|
|
|
result, err := s.DeleteIssues(ctx, toDelete, false, true, false)
|
|
if err != nil {
|
|
t.Fatalf("DeleteIssues failed: %v", err)
|
|
}
|
|
|
|
if result.DeletedCount != toDeleteCount {
|
|
t.Errorf("Expected %d deletions, got %d", toDeleteCount, result.DeletedCount)
|
|
}
|
|
|
|
for _, id := range toDelete {
|
|
if err := removeIssueFromJSONL(id); err != nil {
|
|
t.Fatalf("removeIssueFromJSONL failed for %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
stats, err := s.GetStatistics(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatistics failed: %v", err)
|
|
}
|
|
|
|
expectedRemaining := totalIssues - toDeleteCount
|
|
if stats.TotalIssues != expectedRemaining {
|
|
t.Errorf("After delete: expected %d issues in DB, got %d", expectedRemaining, stats.TotalIssues)
|
|
}
|
|
|
|
jsonlIssues := countJSONLIssuesTest(t, jsonlPath)
|
|
if jsonlIssues != expectedRemaining {
|
|
t.Errorf("After delete: expected %d issues in JSONL, got %d", expectedRemaining, jsonlIssues)
|
|
}
|
|
|
|
for _, id := range toDelete {
|
|
issue, err := s.GetIssue(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed for %s: %v", id, err)
|
|
}
|
|
if issue != nil {
|
|
t.Errorf("Deleted issue %s was resurrected!", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func exportToJSONLTest(t *testing.T, s *sqlite.SQLiteStorage, jsonlPath string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
issues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("SearchIssues failed: %v", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(jsonlPath), 0755); err != nil {
|
|
t.Fatalf("Failed to create JSONL dir: %v", err)
|
|
}
|
|
|
|
f, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create JSONL: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
enc := json.NewEncoder(f)
|
|
for _, iss := range issues {
|
|
if err := enc.Encode(iss); err != nil {
|
|
t.Fatalf("Failed to encode issue: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testGitInit(t *testing.T, dir string) {
|
|
t.Helper()
|
|
testGitCmd(t, dir, "init")
|
|
testGitCmd(t, dir, "config", "user.email", "test@example.com")
|
|
testGitCmd(t, dir, "config", "user.name", "Test User")
|
|
}
|
|
|
|
func testGitCommit(t *testing.T, dir, file, msg string) {
|
|
t.Helper()
|
|
testGitCmd(t, dir, "add", file)
|
|
testGitCmd(t, dir, "commit", "-m", msg)
|
|
}
|
|
|
|
func testGitCmd(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v failed: %v\nOutput: %s", args, err, output)
|
|
}
|
|
}
|
|
|
|
func countJSONLIssuesTest(t *testing.T, jsonlPath string) int {
|
|
t.Helper()
|
|
data, err := os.ReadFile(jsonlPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0
|
|
}
|
|
t.Fatalf("Failed to read JSONL: %v", err)
|
|
}
|
|
|
|
count := 0
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if len(bytes.TrimSpace([]byte(line))) > 0 {
|
|
count++
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
t.Fatalf("Scanner error: %v", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
// TestCreateTombstoneWrapper tests the createTombstone wrapper function
|
|
func TestCreateTombstoneWrapper(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
testDB := filepath.Join(beadsDir, "beads.db")
|
|
|
|
s := newTestStore(t, testDB)
|
|
ctx := context.Background()
|
|
|
|
// Save and restore global store
|
|
oldStore := store
|
|
defer func() { store = oldStore }()
|
|
store = s
|
|
|
|
t.Run("successful tombstone creation", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Test Issue",
|
|
Description: "Issue to be tombstoned",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
err := createTombstone(ctx, issue.ID, "test-actor", "Test deletion reason")
|
|
if err != nil {
|
|
t.Fatalf("createTombstone failed: %v", err)
|
|
}
|
|
|
|
// Verify tombstone status
|
|
updated, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if updated == nil {
|
|
t.Fatal("Issue should still exist as tombstone")
|
|
}
|
|
if updated.Status != types.StatusTombstone {
|
|
t.Errorf("Expected status %s, got %s", types.StatusTombstone, updated.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("tombstone with actor and reason tracking", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Issue with tracking",
|
|
Description: "Check actor/reason",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "bug",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
actor := "admin-user"
|
|
reason := "Duplicate issue"
|
|
err := createTombstone(ctx, issue.ID, actor, reason)
|
|
if err != nil {
|
|
t.Fatalf("createTombstone failed: %v", err)
|
|
}
|
|
|
|
// Verify actor and reason were recorded
|
|
updated, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if updated.DeletedBy != actor {
|
|
t.Errorf("Expected DeletedBy %q, got %q", actor, updated.DeletedBy)
|
|
}
|
|
if updated.DeleteReason != reason {
|
|
t.Errorf("Expected DeleteReason %q, got %q", reason, updated.DeleteReason)
|
|
}
|
|
})
|
|
|
|
t.Run("error when issue does not exist", func(t *testing.T) {
|
|
err := createTombstone(ctx, "nonexistent-issue-id", "actor", "reason")
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent issue")
|
|
}
|
|
})
|
|
|
|
t.Run("verify tombstone preserves original type", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Feature issue",
|
|
Description: "Should preserve type",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeFeature,
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
err := createTombstone(ctx, issue.ID, "actor", "reason")
|
|
if err != nil {
|
|
t.Fatalf("createTombstone failed: %v", err)
|
|
}
|
|
|
|
updated, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if updated.OriginalType != string(types.TypeFeature) {
|
|
t.Errorf("Expected OriginalType %q, got %q", types.TypeFeature, updated.OriginalType)
|
|
}
|
|
})
|
|
|
|
t.Run("verify audit trail recorded", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Issue for audit",
|
|
Description: "Check event recording",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
err := createTombstone(ctx, issue.ID, "audit-actor", "audit-reason")
|
|
if err != nil {
|
|
t.Fatalf("createTombstone failed: %v", err)
|
|
}
|
|
|
|
// Verify an event was recorded
|
|
events, err := s.GetEvents(ctx, issue.ID, 100)
|
|
if err != nil {
|
|
t.Fatalf("GetEvents failed: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, e := range events {
|
|
if e.EventType == "deleted" && e.Actor == "audit-actor" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Expected 'deleted' event in audit trail")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDeleteIssueWrapper tests the deleteIssue wrapper function
|
|
func TestDeleteIssueWrapper(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
testDB := filepath.Join(beadsDir, "beads.db")
|
|
|
|
s := newTestStore(t, testDB)
|
|
ctx := context.Background()
|
|
|
|
// Save and restore global store
|
|
oldStore := store
|
|
defer func() { store = oldStore }()
|
|
store = s
|
|
|
|
t.Run("successful issue deletion", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Issue to delete",
|
|
Description: "Will be permanently deleted",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
err := deleteIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("deleteIssue failed: %v", err)
|
|
}
|
|
|
|
// Verify issue is gone
|
|
deleted, err := s.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if deleted != nil {
|
|
t.Error("Issue should be completely deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("error on non-existent issue", func(t *testing.T) {
|
|
err := deleteIssue(ctx, "nonexistent-issue-id")
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent issue")
|
|
}
|
|
})
|
|
|
|
t.Run("verify dependencies are removed", func(t *testing.T) {
|
|
// Create two issues with a dependency
|
|
issue1 := &types.Issue{
|
|
Title: "Blocker issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: "task",
|
|
}
|
|
issue2 := &types.Issue{
|
|
Title: "Dependent issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue1, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue1: %v", err)
|
|
}
|
|
if err := s.CreateIssue(ctx, issue2, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue2: %v", err)
|
|
}
|
|
|
|
// Add dependency: issue2 depends on issue1
|
|
dep := &types.Dependency{
|
|
IssueID: issue2.ID,
|
|
DependsOnID: issue1.ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := s.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
// Delete issue1 (the blocker)
|
|
err := deleteIssue(ctx, issue1.ID)
|
|
if err != nil {
|
|
t.Fatalf("deleteIssue failed: %v", err)
|
|
}
|
|
|
|
// Verify issue2 no longer has dependencies
|
|
deps, err := s.GetDependencies(ctx, issue2.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependencies failed: %v", err)
|
|
}
|
|
if len(deps) > 0 {
|
|
t.Errorf("Expected no dependencies after deleting blocker, got %d", len(deps))
|
|
}
|
|
})
|
|
|
|
t.Run("verify issue removed from database", func(t *testing.T) {
|
|
issue := &types.Issue{
|
|
Title: "Verify removal",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "task",
|
|
}
|
|
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
// Get statistics before delete
|
|
statsBefore, err := s.GetStatistics(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatistics failed: %v", err)
|
|
}
|
|
|
|
err = deleteIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("deleteIssue failed: %v", err)
|
|
}
|
|
|
|
// Get statistics after delete
|
|
statsAfter, err := s.GetStatistics(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatistics failed: %v", err)
|
|
}
|
|
|
|
if statsAfter.TotalIssues != statsBefore.TotalIssues-1 {
|
|
t.Errorf("Expected total issues to decrease by 1, was %d now %d",
|
|
statsBefore.TotalIssues, statsAfter.TotalIssues)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCreateTombstoneUnsupportedStorage(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
oldStore := store
|
|
defer func() { store = oldStore }()
|
|
|
|
// Set store to nil - the type assertion will fail
|
|
store = nil
|
|
|
|
ctx := context.Background()
|
|
err := createTombstone(ctx, "any-id", "actor", "reason")
|
|
if err == nil {
|
|
t.Error("Expected error when storage is nil")
|
|
}
|
|
expectedMsg := "tombstone operation not supported by this storage backend"
|
|
if err.Error() != expectedMsg {
|
|
t.Errorf("Expected error %q, got %q", expectedMsg, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDeleteIssueUnsupportedStorage(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
oldStore := store
|
|
defer func() { store = oldStore }()
|
|
|
|
// Set store to nil - the type assertion will fail
|
|
store = nil
|
|
|
|
ctx := context.Background()
|
|
err := deleteIssue(ctx, "any-id")
|
|
if err == nil {
|
|
t.Error("Expected error when storage is nil")
|
|
}
|
|
expectedMsg := "delete operation not supported by this storage backend"
|
|
if err.Error() != expectedMsg {
|
|
t.Errorf("Expected error %q, got %q", expectedMsg, err.Error())
|
|
}
|
|
}
|