Files
beads/cmd/bd/export_mtime_test.go
2025-12-24 00:06:41 -08:00

258 lines
7.5 KiB
Go

//go:build integration
// +build integration
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestExportUpdatesDatabaseMtime verifies that export updates database mtime
// to be >= JSONL mtime, fixing issues #278, #301, #321
func TestExportUpdatesDatabaseMtime(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Create a test issue
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Wait a bit to ensure mtime difference
time.Sleep(1 * time.Second)
// Export to JSONL (simulates daemon export)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Export failed: %v", err)
}
// Update metadata after export (bd-ymj fix)
mockLogger := newTestLogger()
updateExportMetadata(ctx, store, jsonlPath, mockLogger, "")
// Get JSONL mtime
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
t.Fatalf("Failed to stat JSONL after export: %v", err)
}
// WITHOUT the fix, JSONL would be newer than DB here
// Simulating the old buggy behavior before calling TouchDatabaseFile
dbInfoAfterExport, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after export: %v", err)
}
// In old buggy behavior, JSONL mtime > DB mtime
t.Logf("Before TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterExport.ModTime(), jsonlInfo.ModTime())
// Now apply the fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Get final database mtime
dbInfoAfterTouch, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after touch: %v", err)
}
t.Logf("After TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
// VERIFY: Database mtime should be >= JSONL mtime
if dbInfoAfterTouch.ModTime().Before(jsonlInfo.ModTime()) {
t.Errorf("Database mtime should be >= JSONL mtime after export")
t.Errorf("DB mtime: %v, JSONL mtime: %v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
}
// VERIFY: validatePreExport should now pass (not block on next export)
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("validatePreExport should pass after TouchDatabaseFile, but got error: %v", err)
}
}
// TestDaemonExportScenario simulates the full daemon auto-export workflow
// that was causing issue #278 (daemon shutting down after export)
func TestDaemonExportScenario(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Step 1: User creates an issue (e.g., bd close bd-123)
now := time.Now()
issue := &types.Issue{
ID: "bd-123",
Title: "User created issue",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
ClosedAt: &now,
}
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Database is now newer than JSONL (JSONL doesn't exist yet)
time.Sleep(1 * time.Second)
// Step 2: Daemon auto-exports after delay (30s-4min in real scenario)
// This simulates the daemon's export cycle
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Daemon export failed: %v", err)
}
// Daemon updates metadata after export (bd-ymj fix)
mockLogger := newTestLogger()
updateExportMetadata(ctx, store, jsonlPath, mockLogger, "")
// THIS IS THE FIX: daemon now calls TouchDatabaseFile after export
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Step 3: User runs bd sync shortly after
// WITHOUT the fix, this would fail with "JSONL is newer than database"
// WITH the fix, this should succeed
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Daemon export scenario failed: validatePreExport blocked after daemon export")
t.Errorf("This is the bug from issue #278/#301/#321: %v", err)
}
// Verify we can export again (simulates bd sync)
jsonlPathTemp := jsonlPath + ".sync"
if err := exportToJSONLWithStore(ctx, store, jsonlPathTemp); err != nil {
t.Errorf("Second export (bd sync) failed: %v", err)
}
os.Remove(jsonlPathTemp)
}
// TestMultipleExportCycles verifies repeated export cycles don't cause issues
func TestMultipleExportCycles(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Run multiple export cycles
for i := 0; i < 5; i++ {
// Add an issue
issue := &types.Issue{
ID: "test-" + string(rune('a'+i)),
Title: "Test Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Cycle %d: Failed to create issue: %v", i, err)
}
time.Sleep(100 * time.Millisecond)
// Export (with fix)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Cycle %d: Export failed: %v", i, err)
}
// Update metadata after export (bd-ymj fix)
mockLogger := newTestLogger()
updateExportMetadata(ctx, store, jsonlPath, mockLogger, "")
// Apply fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("Cycle %d: TouchDatabaseFile failed: %v", i, err)
}
// Verify validation passes
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Cycle %d: validatePreExport failed: %v", i, err)
}
}
}