Files
beads/cmd/bd/main_test.go
Steve Yegge d5f2d91d04 fix(ci): more changes to fix failing CI (#415)
Fixes from maphew including:
- Remove test for deleted isPathWithinDir function
- Add gosec nolint directives for safe file operations
- Add rm -rf .beads before init in CI workflow
- Simplify panic handling and file operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: maphew <maphew@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 22:24:29 -08:00

1007 lines
27 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
// TestAutoFlushOnExit tests that PersistentPostRun performs final flush before exit
func TestAutoFlushOnExit(t *testing.T) {
// FIX: Initialize rootCtx for flush operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
// Create temp directory for test database
tmpDir, err := os.MkdirTemp("", "bd-test-exit-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Warning: cleanup failed: %v", err)
}
}()
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create store
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// Reset auto-flush state
autoFlushEnabled = true
isDirty = false
if flushTimer != nil {
flushTimer.Stop()
flushTimer = nil
}
// ctx already declared above for rootCtx initialization
// Create test issue
issue := &types.Issue{
ID: "test-exit-1",
Title: "Exit test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Mark dirty (simulating CRUD operation)
markDirtyAndScheduleFlush()
// Simulate PersistentPostRun (exit behavior)
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
flushMutex.Lock()
needsFlush := isDirty && autoFlushEnabled
if needsFlush {
if flushTimer != nil {
flushTimer.Stop()
flushTimer = nil
}
isDirty = false
}
flushMutex.Unlock()
if needsFlush {
// Manually perform flush logic (simulating PersistentPostRun)
storeMutex.Lock()
storeActive = true // Temporarily re-enable for this test
storeMutex.Unlock()
issues, err := testStore.SearchIssues(ctx, "", types.IssueFilter{})
if err == nil {
allDeps, _ := testStore.GetAllDependencyRecords(ctx)
for _, iss := range issues {
iss.Dependencies = allDeps[iss.ID]
}
tempPath := jsonlPath + ".tmp"
f, err := os.Create(tempPath)
if err == nil {
encoder := json.NewEncoder(f)
for _, iss := range issues {
encoder.Encode(iss)
}
f.Close()
os.Rename(tempPath, jsonlPath)
}
}
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}
testStore.Close()
// Verify JSONL file was created
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
t.Error("Expected JSONL file to be created on exit")
}
// Verify content
f, err := os.Open(jsonlPath)
if err != nil {
t.Fatalf("Failed to open JSONL file: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
found := false
for scanner.Scan() {
var exported types.Issue
if err := json.Unmarshal(scanner.Bytes(), &exported); err != nil {
t.Fatalf("Failed to parse JSONL: %v", err)
}
if exported.ID == "test-exit-1" {
found = true
break
}
}
if !found {
t.Error("Expected to find test-exit-1 in JSONL after exit flush")
}
}
// TestAutoFlushConcurrency tests that concurrent operations don't cause races
// TestAutoFlushStoreInactive tests that flush doesn't run when store is inactive
// TestAutoFlushJSONLContent tests that flushed JSONL has correct content
func TestAutoFlushJSONLContent(t *testing.T) {
// FIX: Initialize rootCtx for flush operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
// Create temp directory for test database
tmpDir, err := os.MkdirTemp("", "bd-test-content-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Warning: cleanup failed: %v", err)
}
}()
dbPath = filepath.Join(tmpDir, "test.db")
// The actual JSONL path - findJSONLPath() will determine this
// bd-6xd: Default is now issues.jsonl (canonical name)
expectedJSONLPath := filepath.Join(tmpDir, "issues.jsonl")
// Create store
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// ctx already declared above for rootCtx initialization
// Create multiple test issues
issues := []*types.Issue{
{
ID: "test-content-1",
Title: "First issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "test-content-2",
Title: "Second issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
for _, issue := range issues {
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Mark each issue as dirty in the database so flushToJSONL will export them
if err := testStore.MarkIssueDirty(ctx, issue.ID); err != nil {
t.Fatalf("Failed to mark issue dirty: %v", err)
}
}
// Mark dirty and flush immediately
flushMutex.Lock()
isDirty = true
flushMutex.Unlock()
flushToJSONL()
// Verify JSONL file exists
if _, err := os.Stat(expectedJSONLPath); os.IsNotExist(err) {
// Debug: list all files in tmpDir to see what was actually created
entries, _ := os.ReadDir(tmpDir)
t.Logf("Contents of %s:", tmpDir)
for _, entry := range entries {
t.Logf(" - %s (isDir: %v)", entry.Name(), entry.IsDir())
if entry.IsDir() && entry.Name() == ".beads" {
beadsEntries, _ := os.ReadDir(filepath.Join(tmpDir, ".beads"))
for _, be := range beadsEntries {
t.Logf(" - .beads/%s", be.Name())
}
}
}
t.Fatalf("Expected JSONL file to be created at %s", expectedJSONLPath)
}
// Read and verify content
f, err := os.Open(expectedJSONLPath)
if err != nil {
t.Fatalf("Failed to open JSONL file: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
foundIssues := make(map[string]*types.Issue)
for scanner.Scan() {
var issue types.Issue
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
t.Fatalf("Failed to parse JSONL: %v", err)
}
foundIssues[issue.ID] = &issue
}
// Verify all issues are present
if len(foundIssues) != 2 {
t.Errorf("Expected 2 issues in JSONL, got %d", len(foundIssues))
}
// Verify content
for _, original := range issues {
found, ok := foundIssues[original.ID]
if !ok {
t.Errorf("Issue %s not found in JSONL", original.ID)
continue
}
if found.Title != original.Title {
t.Errorf("Issue %s: Title = %s, want %s", original.ID, found.Title, original.Title)
}
if found.Status != original.Status {
t.Errorf("Issue %s: Status = %s, want %s", original.ID, found.Status, original.Status)
}
}
// Clean up
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}
// TestAutoImportIfNewer tests that auto-import triggers when JSONL is newer than DB
func TestAutoImportIfNewer(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
// Create temp directory for test database
tmpDir, err := os.MkdirTemp("", "bd-test-autoimport-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Warning: cleanup failed: %v", err)
}
}()
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create store
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// ctx already declared above for rootCtx initialization
// Create an initial issue in the database
dbIssue := &types.Issue{
ID: "test-autoimport-1",
Title: "Original DB issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Wait a moment to ensure different timestamps
time.Sleep(10 * time.Millisecond) // 10x faster
// Create a JSONL file with different content (simulating a git pull)
jsonlIssue := &types.Issue{
ID: "test-autoimport-2",
Title: "New JSONL issue",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL file: %v", err)
}
encoder := json.NewEncoder(f)
if err := encoder.Encode(dbIssue); err != nil {
t.Fatalf("Failed to encode first issue: %v", err)
}
if err := encoder.Encode(jsonlIssue); err != nil {
t.Fatalf("Failed to encode second issue: %v", err)
}
f.Close()
// Touch the JSONL file to make it newer than DB
futureTime := time.Now().Add(1 * time.Second)
if err := os.Chtimes(jsonlPath, futureTime, futureTime); err != nil {
t.Fatalf("Failed to update JSONL timestamp: %v", err)
}
// Call autoImportIfNewer
autoImportIfNewer()
// Verify that the new issue from JSONL was imported
imported, err := testStore.GetIssue(ctx, "test-autoimport-2")
if err != nil {
t.Fatalf("Failed to get imported issue: %v", err)
}
if imported == nil {
t.Error("Expected issue test-autoimport-2 to be imported from JSONL")
} else {
if imported.Title != "New JSONL issue" {
t.Errorf("Expected title 'New JSONL issue', got '%s'", imported.Title)
}
}
// Clean up
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}
// TestAutoImportDisabled tests that --no-auto-import flag disables auto-import
func TestAutoImportDisabled(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
// Create temp directory for test database
tmpDir, err := os.MkdirTemp("", "bd-test-noimport-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Warning: cleanup failed: %v", err)
}
}()
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create store
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
// ctx already declared above for rootCtx initialization
// Create a JSONL file with an issue
jsonlIssue := &types.Issue{
ID: "test-noimport-1",
Title: "Should not import",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL file: %v", err)
}
encoder := json.NewEncoder(f)
if err := encoder.Encode(jsonlIssue); err != nil {
t.Fatalf("Failed to encode issue: %v", err)
}
f.Close()
// Make JSONL newer than DB
futureTime := time.Now().Add(1 * time.Second)
if err := os.Chtimes(jsonlPath, futureTime, futureTime); err != nil {
t.Fatalf("Failed to update JSONL timestamp: %v", err)
}
// Disable auto-import (this would normally be set via --no-auto-import flag)
oldAutoImport := autoImportEnabled
autoImportEnabled = false
defer func() { autoImportEnabled = oldAutoImport }()
// Call autoImportIfNewer (should do nothing)
if autoImportEnabled {
autoImportIfNewer()
}
// Verify that the issue was NOT imported
imported, err := testStore.GetIssue(ctx, "test-noimport-1")
if err != nil {
t.Fatalf("Failed to check for issue: %v", err)
}
if imported != nil {
t.Error("Expected issue test-noimport-1 to NOT be imported when auto-import is disabled")
}
// Clean up
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}
// TestAutoImportWithUpdate tests that auto-import detects same-ID updates and applies them
func TestAutoImportWithUpdate(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
tmpDir, err := os.MkdirTemp("", "bd-test-update-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
// ctx already declared above for rootCtx initialization
// Create issue in DB with status=closed
closedTime := time.Now().UTC()
dbIssue := &types.Issue{
ID: "test-col-1",
Title: "Local version",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: &closedTime,
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with same ID but different title (update scenario)
// The import should update the title since status=closed is preserved
jsonlIssue := &types.Issue{
ID: "test-col-1",
Title: "Remote version",
Status: types.StatusClosed, // Match DB status to avoid spurious update
Priority: 1, // Match DB priority
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: &closedTime,
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(jsonlIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify import updated the title from JSONL
result, err := testStore.GetIssue(ctx, "test-col-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result.Status != types.StatusClosed {
t.Errorf("Expected status=closed, got %s", result.Status)
}
if result.Title != "Remote version" {
t.Errorf("Expected title='Remote version' (from JSONL), got '%s'", result.Title)
}
}
// TestAutoImportNoUpdate tests happy path with no updates needed
func TestAutoImportNoUpdate(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
tmpDir, err := os.MkdirTemp("", "bd-test-noupdate-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
// ctx already declared above for rootCtx initialization
// Create issue in DB
dbIssue := &types.Issue{
ID: "test-noc-1",
Title: "Same version",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with exact match + new issue
newIssue := &types.Issue{
ID: "test-noc-2",
Title: "Brand new issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(dbIssue)
json.NewEncoder(f).Encode(newIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify new issue imported
result, err := testStore.GetIssue(ctx, "test-noc-2")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result == nil {
t.Fatal("Expected new issue to be imported")
}
if result.Title != "Brand new issue" {
t.Errorf("Expected title='Brand new issue', got '%s'", result.Title)
}
}
// TestAutoImportMergeConflict tests that auto-import detects Git merge conflicts (bd-270)
func TestAutoImportMergeConflict(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
tmpDir, err := os.MkdirTemp("", "bd-test-conflict-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
// ctx already declared above for rootCtx initialization
// Create an initial issue in database
dbIssue := &types.Issue{
ID: "test-conflict-1",
Title: "Original issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, dbIssue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with merge conflict markers
conflictContent := `<<<<<<< HEAD
{"id":"test-conflict-1","title":"HEAD version","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-16T00:00:00Z","updated_at":"2025-10-16T00:00:00Z"}
=======
{"id":"test-conflict-1","title":"Incoming version","status":"in_progress","priority":2,"issue_type":"bug","created_at":"2025-10-16T00:00:00Z","updated_at":"2025-10-16T00:00:00Z"}
>>>>>>> incoming-branch
`
if err := os.WriteFile(jsonlPath, []byte(conflictContent), 0644); err != nil {
t.Fatalf("Failed to create conflicted JSONL: %v", err)
}
// Capture stderr to check for merge conflict message
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
// Run auto-import - should detect conflict and abort
autoImportIfNewer()
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, r)
stderrOutput := buf.String()
// Verify merge conflict was detected
if !strings.Contains(stderrOutput, "Git merge conflict detected") {
t.Errorf("Expected 'Git merge conflict detected' in stderr, got: %s", stderrOutput)
}
// Verify the database was not modified (original issue unchanged)
result, err := testStore.GetIssue(ctx, "test-conflict-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result.Title != "Original issue" {
t.Errorf("Expected title 'Original issue' (unchanged), got '%s'", result.Title)
}
}
// TestAutoImportConflictMarkerFalsePositive tests that conflict marker detection
// doesn't trigger on JSON-encoded conflict markers in issue content (bd-17d5)
func TestAutoImportConflictMarkerFalsePositive(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
tmpDir, err := os.MkdirTemp("", "bd-test-false-positive-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
testStore.Close()
}()
// ctx already declared above for rootCtx initialization
// Create a JSONL file with an issue that has conflict markers in the description
// The conflict markers are JSON-encoded (as \u003c\u003c\u003c...) which should NOT trigger detection
now := time.Now().Format(time.RFC3339Nano)
jsonlContent := fmt.Sprintf(`{"id":"test-fp-1","title":"Test false positive","description":"This issue documents git conflict markers:\n\u003c\u003c\u003c\u003c\u003c\u003c\u003c HEAD\n=======\n\u003e\u003e\u003e\u003e\u003e\u003e\u003e branch","status":"open","priority":1,"issue_type":"task","created_at":"%s","updated_at":"%s"}`, now, now)
if err := os.WriteFile(jsonlPath, []byte(jsonlContent+"\n"), 0644); err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
// Verify the JSONL contains JSON-encoded conflict markers (not literal ones)
jsonlData, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("Failed to read JSONL: %v", err)
}
jsonlStr := string(jsonlData)
if !strings.Contains(jsonlStr, `\u003c\u003c\u003c`) {
t.Logf("JSONL content: %s", jsonlStr)
t.Fatalf("Expected JSON-encoded conflict markers in JSONL")
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
// Run auto-import - should succeed without conflict detection
autoImportIfNewer()
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, r)
stderrOutput := buf.String()
// Verify NO conflict was detected
if strings.Contains(stderrOutput, "conflict") {
t.Errorf("False positive: conflict detection triggered on JSON-encoded markers. stderr: %s", stderrOutput)
}
// Verify the issue was successfully imported
result, err := testStore.GetIssue(ctx, "test-fp-1")
if err != nil {
t.Fatalf("Failed to get issue (import failed): %v", err)
}
expectedDesc := "This issue documents git conflict markers:\n<<<<<<< HEAD\n=======\n>>>>>>> branch"
if result.Description != expectedDesc {
t.Errorf("Expected description with conflict markers, got: %s", result.Description)
}
}
// TestAutoImportClosedAtInvariant tests that auto-import enforces status/closed_at invariant
func TestAutoImportClosedAtInvariant(t *testing.T) {
// FIX: Initialize rootCtx for auto-import operations
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
tmpDir, err := os.MkdirTemp("", "bd-test-invariant-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath = filepath.Join(tmpDir, "test.db")
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
testStore := newTestStore(t, dbPath)
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
// ctx already declared above for rootCtx initialization
// Create JSONL with closed issue but missing closed_at
closedIssue := &types.Issue{
ID: "test-inv-1",
Title: "Closed without timestamp",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: nil, // Missing!
}
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
json.NewEncoder(f).Encode(closedIssue)
f.Close()
// Run auto-import
autoImportIfNewer()
// Verify closed_at was set
result, err := testStore.GetIssue(ctx, "test-inv-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if result == nil {
t.Fatal("Expected issue to be created")
}
if result.ClosedAt == nil {
t.Error("Expected closed_at to be set for closed issue")
}
}
// bd-206: Test updating open issue to closed preserves closed_at
func TestImportOpenToClosedTransition(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-open-to-closed-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
testStore := newTestStoreWithPrefix(t, dbPath, "bd")
ctx := context.Background()
// Step 1: Create an open issue in the database
openIssue := &types.Issue{
ID: "bd-transition-1",
Title: "Test transition",
Description: "This will be closed",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: nil,
}
err = testStore.CreateIssue(ctx, openIssue, "test")
if err != nil {
t.Fatalf("Failed to create open issue: %v", err)
}
// Step 2: Update via UpdateIssue with closed status (closed_at managed automatically)
updates := map[string]interface{}{
"status": types.StatusClosed,
}
err = testStore.UpdateIssue(ctx, "bd-transition-1", updates, "test")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Step 3: Verify the issue is now closed with correct closed_at
updated, err := testStore.GetIssue(ctx, "bd-transition-1")
if err != nil {
t.Fatalf("Failed to get updated issue: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Expected status to be closed, got %s", updated.Status)
}
if updated.ClosedAt == nil {
t.Fatal("Expected closed_at to be set after transition to closed")
}
}
// bd-206: Test updating closed issue to open clears closed_at
func TestImportClosedToOpenTransition(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-closed-to-open-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
testStore := newTestStoreWithPrefix(t, dbPath, "bd")
ctx := context.Background()
// Step 1: Create a closed issue in the database
closedTime := time.Now()
closedIssue := &types.Issue{
ID: "bd-transition-2",
Title: "Test reopening",
Description: "This will be reopened",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: closedTime,
ClosedAt: &closedTime,
}
err = testStore.CreateIssue(ctx, closedIssue, "test")
if err != nil {
t.Fatalf("Failed to create closed issue: %v", err)
}
// Step 2: Update via UpdateIssue with open status (closed_at managed automatically)
updates := map[string]interface{}{
"status": types.StatusOpen,
}
err = testStore.UpdateIssue(ctx, "bd-transition-2", updates, "test")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Step 3: Verify the issue is now open with null closed_at
updated, err := testStore.GetIssue(ctx, "bd-transition-2")
if err != nil {
t.Fatalf("Failed to get updated issue: %v", err)
}
if updated.Status != types.StatusOpen {
t.Errorf("Expected status to be open, got %s", updated.Status)
}
if updated.ClosedAt != nil {
t.Errorf("Expected closed_at to be nil after reopening, got %v", updated.ClosedAt)
}
}