Files
beads/cmd/bd/import_idempotent_test.go
Steve Yegge 9892e45e71 Fix bd-84: Prevent timestamp churn on idempotent imports
- Add issueDataChanged() to detect when issue content actually changes
- Only call UpdateIssue when data differs from existing issue
- Unchanged issues are skipped to avoid updating timestamps
- Add tests for idempotent import behavior
- Fixes perpetually dirty JSONL after every bd command

Amp-Thread-ID: https://ampcode.com/threads/T-225f8c56-0710-46e9-9db2-dbf90cf91911
Co-authored-by: Amp <amp@ampcode.com>
2025-10-23 17:50:10 -07:00

206 lines
5.2 KiB
Go

package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestIdempotentImportNoTimestampChurn verifies that importing unchanged issues
// does not update their timestamps (bd-84)
func TestIdempotentImportNoTimestampChurn(t *testing.T) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "bd-test-idempotent-*")
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")
// Create store
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
ID: "bd-1",
Title: "Test Issue",
Description: "Test description",
Status: types.StatusOpen,
Priority: 2,
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)
}
// Get initial timestamp
issue1, err := testStore.GetIssue(ctx, "bd-1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
initialUpdatedAt := issue1.UpdatedAt
// Export to JSONL
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
encoder := json.NewEncoder(f)
if err := encoder.Encode(issue1); err != nil {
t.Fatalf("Failed to encode issue: %v", err)
}
f.Close()
// Wait a bit to ensure timestamps would be different if updated
time.Sleep(100 * time.Millisecond)
// Import the same JSONL (should be idempotent)
autoImportIfNewer()
// Get issue again
issue2, err := testStore.GetIssue(ctx, "bd-1")
if err != nil {
t.Fatalf("Failed to get issue after import: %v", err)
}
// Verify timestamp was NOT updated
if !issue2.UpdatedAt.Equal(initialUpdatedAt) {
t.Errorf("Import updated timestamp even though data unchanged!\n"+
"Before: %v\nAfter: %v",
initialUpdatedAt, issue2.UpdatedAt)
}
}
// TestImportMultipleUnchangedIssues verifies that importing multiple unchanged issues
// does not update any of their timestamps (bd-84)
func TestImportMultipleUnchangedIssues(t *testing.T) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "bd-test-changed-*")
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")
// Create store
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
store = testStore
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
}()
ctx := context.Background()
// Create two issues
issue1 := &types.Issue{
ID: "bd-1",
Title: "Unchanged Issue",
Description: "Will not change",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issue2 := &types.Issue{
ID: "bd-2",
Title: "Changed Issue",
Description: "Will change",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue 1: %v", err)
}
if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create issue 2: %v", err)
}
// Get initial timestamps
unchanged, _ := testStore.GetIssue(ctx, "bd-1")
changed, _ := testStore.GetIssue(ctx, "bd-2")
unchangedInitialTS := unchanged.UpdatedAt
changedInitialTS := changed.UpdatedAt
// Export both issues to JSONL (unchanged)
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create JSONL: %v", err)
}
encoder := json.NewEncoder(f)
if err := encoder.Encode(unchanged); err != nil {
t.Fatalf("Failed to encode issue 1: %v", err)
}
if err := encoder.Encode(changed); err != nil {
t.Fatalf("Failed to encode issue 2: %v", err)
}
f.Close()
// Wait to ensure timestamps would differ if updated
time.Sleep(100 * time.Millisecond)
// Import same JSONL (both issues unchanged - should be idempotent)
autoImportIfNewer()
// Check timestamps - neither should have changed
issue1After, _ := testStore.GetIssue(ctx, "bd-1")
issue2After, _ := testStore.GetIssue(ctx, "bd-2")
// bd-1 should have same timestamp
if !issue1After.UpdatedAt.Equal(unchangedInitialTS) {
t.Errorf("bd-1 timestamp changed even though issue unchanged!\n"+
"Before: %v\nAfter: %v",
unchangedInitialTS, issue1After.UpdatedAt)
}
// bd-2 should also have same timestamp
if !issue2After.UpdatedAt.Equal(changedInitialTS) {
t.Errorf("bd-2 timestamp changed even though issue unchanged!\n"+
"Before: %v\nAfter: %v",
changedInitialTS, issue2After.UpdatedAt)
}
}