diff --git a/cmd/bd/daemon_sync_test.go b/cmd/bd/daemon_sync_test.go index aab0c849..251a1f12 100644 --- a/cmd/bd/daemon_sync_test.go +++ b/cmd/bd/daemon_sync_test.go @@ -3,8 +3,10 @@ package main import ( "context" "encoding/json" + "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -495,3 +497,249 @@ func TestUpdateExportMetadataMultiRepo(t *testing.T) { t.Errorf("expected %s to be set", mtime2Key) } } + +// TestExportWithMultiRepoConfigUpdatesAllMetadata verifies that export with multi-repo +// config correctly updates metadata for ALL JSONL files with proper keySuffix (bd-ar2.8) +func TestExportWithMultiRepoConfigUpdatesAllMetadata(t *testing.T) { + tmpDir := t.TempDir() + primaryDir := filepath.Join(tmpDir, "primary") + additionalDir := filepath.Join(tmpDir, "additional") + + // Set up directory structure + for _, dir := range []string{primaryDir, additionalDir} { + beadsDir := filepath.Join(dir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("failed to create %s: %v", beadsDir, err) + } + } + + dbPath := filepath.Join(primaryDir, ".beads", "beads.db") + primaryJSONL := filepath.Join(primaryDir, ".beads", "issues.jsonl") + additionalJSONL := filepath.Join(additionalDir, ".beads", "issues.jsonl") + + // Create storage + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue_prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + + // Create test issues + issue1 := &types.Issue{ + ID: "test-1", + Title: "Primary Issue", + Description: "Issue in primary repo", + IssueType: types.TypeBug, + Priority: 1, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SourceRepo: primaryDir, + } + issue2 := &types.Issue{ + ID: "test-2", + Title: "Additional Issue", + Description: "Issue in additional repo", + IssueType: types.TypeFeature, + Priority: 2, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SourceRepo: additionalDir, + } + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("failed to create issue2: %v", err) + } + + // Export to both JSONL files + if err := exportToJSONLWithStore(ctx, store, primaryJSONL); err != nil { + t.Fatalf("failed to export to primary JSONL: %v", err) + } + if err := exportToJSONLWithStore(ctx, store, additionalJSONL); err != nil { + t.Fatalf("failed to export to additional JSONL: %v", err) + } + + // Simulate multi-repo export flow (as in createExportFunc) + // This tests the full integration: getMultiRepoJSONLPaths -> getRepoKeyForPath -> updateExportMetadata + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + // Simulate multi-repo mode with stable keys + multiRepoPaths := []string{primaryJSONL, additionalJSONL} + repoKeys := []string{primaryDir, additionalDir} + + for i, path := range multiRepoPaths { + repoKey := repoKeys[i] + updateExportMetadata(ctx, store, path, mockLogger, repoKey) + } + + // Verify metadata for primary repo + primaryHashKey := "last_import_hash:" + primaryDir + primaryHash, err := store.GetMetadata(ctx, primaryHashKey) + if err != nil { + t.Fatalf("failed to get %s: %v", primaryHashKey, err) + } + if primaryHash == "" { + t.Errorf("expected %s to be set after export", primaryHashKey) + } + + primaryTimeKey := "last_import_time:" + primaryDir + primaryTime, err := store.GetMetadata(ctx, primaryTimeKey) + if err != nil { + t.Fatalf("failed to get %s: %v", primaryTimeKey, err) + } + if primaryTime == "" { + t.Errorf("expected %s to be set after export", primaryTimeKey) + } + + primaryMtimeKey := "last_import_mtime:" + primaryDir + primaryMtime, err := store.GetMetadata(ctx, primaryMtimeKey) + if err != nil { + t.Fatalf("failed to get %s: %v", primaryMtimeKey, err) + } + if primaryMtime == "" { + t.Errorf("expected %s to be set after export", primaryMtimeKey) + } + + // Verify metadata for additional repo + additionalHashKey := "last_import_hash:" + additionalDir + additionalHash, err := store.GetMetadata(ctx, additionalHashKey) + if err != nil { + t.Fatalf("failed to get %s: %v", additionalHashKey, err) + } + if additionalHash == "" { + t.Errorf("expected %s to be set after export", additionalHashKey) + } + + additionalTimeKey := "last_import_time:" + additionalDir + additionalTime, err := store.GetMetadata(ctx, additionalTimeKey) + if err != nil { + t.Fatalf("failed to get %s: %v", additionalTimeKey, err) + } + if additionalTime == "" { + t.Errorf("expected %s to be set after export", additionalTimeKey) + } + + additionalMtimeKey := "last_import_mtime:" + additionalDir + additionalMtime, err := store.GetMetadata(ctx, additionalMtimeKey) + if err != nil { + t.Fatalf("failed to get %s: %v", additionalMtimeKey, err) + } + if additionalMtime == "" { + t.Errorf("expected %s to be set after export", additionalMtimeKey) + } + + // Note: In this test both JSONL files have the same content (all issues), + // so hashes will be identical. In real multi-repo mode, ExportToMultiRepo + // filters by SourceRepo, so hashes would differ. What matters here is that + // metadata is set with correct per-repo keys. + + // Verify global metadata keys are NOT set (multi-repo mode uses suffixed keys) + globalHash, err := store.GetMetadata(ctx, "last_import_hash") + if err != nil { + t.Fatalf("failed to get last_import_hash: %v", err) + } + if globalHash != "" { + t.Error("expected global last_import_hash to not be set in multi-repo mode") + } + + // Test that subsequent exports don't fail with "content has changed" error + if err := exportToJSONLWithStore(ctx, store, primaryJSONL); err != nil { + t.Errorf("second export to primary JSONL failed (metadata not updated properly): %v", err) + } + if err := exportToJSONLWithStore(ctx, store, additionalJSONL); err != nil { + t.Errorf("second export to additional JSONL failed (metadata not updated properly): %v", err) + } +} + +// TestUpdateExportMetadataInvalidKeySuffix verifies that invalid keySuffix is rejected (bd-ar2.12) +func TestUpdateExportMetadataInvalidKeySuffix(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, ".beads", "beads.db") + jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl") + + // Create storage + store, err := sqlite.New(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue_prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + + // Create test issue + issue := &types.Issue{ + ID: "test-1", + Title: "Test Issue", + Description: "Test description", + IssueType: types.TypeBug, + Priority: 1, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Export to JSONL + if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil { + t.Fatalf("export failed: %v", err) + } + + // Create mock logger that captures error messages + var logMessages []string + mockLogger := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + logMessages = append(logMessages, msg) + t.Logf("%s", msg) + }, + } + + // Try to update metadata with invalid keySuffix containing ':' + invalidKeySuffix := "repo:path" + updateExportMetadata(ctx, store, jsonlPath, mockLogger, invalidKeySuffix) + + // Verify that error was logged + var foundError bool + for _, msg := range logMessages { + if strings.Contains(msg, "Error: invalid keySuffix") && strings.Contains(msg, invalidKeySuffix) { + foundError = true + break + } + } + if !foundError { + t.Error("expected error log for invalid keySuffix containing ':'") + } + + // Verify metadata was NOT set (update should have been rejected) + invalidKey := "last_import_hash:" + invalidKeySuffix + hash, err := store.GetMetadata(ctx, invalidKey) + if err != nil { + t.Fatalf("failed to get metadata: %v", err) + } + if hash != "" { + t.Errorf("expected no metadata to be set with invalid key, but got: %s", hash) + } +}