package main import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) // setupTestStore creates a test storage with issue_prefix configured func setupTestStore(t *testing.T, dbPath string) *sqlite.SQLiteStorage { t.Helper() store, err := sqlite.New(context.Background(), dbPath) if err != nil { t.Fatalf("Failed to create store: %v", err) } ctx := context.Background() if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { store.Close() t.Fatalf("Failed to set issue_prefix: %v", err) } return store } // TestDBNeedsExport_InSync verifies dbNeedsExport returns false when DB and JSONL are in sync func TestDBNeedsExport_InSync(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "beads.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") store := setupTestStore(t, dbPath) defer store.Close() ctx := context.Background() // Create an issue in DB issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, } err := store.CreateIssue(ctx, issue, "test-user") if err != nil { t.Fatalf("Failed to create issue: %v", err) } // Export to JSONL if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } // Wait a moment to ensure DB mtime isn't newer time.Sleep(10 * time.Millisecond) // Touch JSONL to make it newer than DB now := time.Now() if err := os.Chtimes(jsonlPath, now, now); err != nil { t.Fatalf("Failed to touch JSONL: %v", err) } // DB and JSONL should be in sync needsExport, err := dbNeedsExport(ctx, store, jsonlPath) if err != nil { t.Fatalf("dbNeedsExport failed: %v", err) } if needsExport { t.Errorf("Expected needsExport=false (DB and JSONL in sync), got true") } } // TestDBNeedsExport_DBNewer verifies dbNeedsExport returns true when DB is modified func TestDBNeedsExport_DBNewer(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "beads.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") store := setupTestStore(t, dbPath) defer store.Close() ctx := context.Background() // Create and export issue issue1 := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, } err := store.CreateIssue(ctx, issue1, "test-user") if err != nil { t.Fatalf("Failed to create issue: %v", err) } if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } // Wait and modify DB time.Sleep(10 * time.Millisecond) issue2 := &types.Issue{ Title: "Another Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } err = store.CreateIssue(ctx, issue2, "test-user") if err != nil { t.Fatalf("Failed to create second issue: %v", err) } // DB is newer, should need export needsExport, err := dbNeedsExport(ctx, store, jsonlPath) if err != nil { t.Fatalf("dbNeedsExport failed: %v", err) } if !needsExport { t.Errorf("Expected needsExport=true (DB modified), got false") } } // TestDBNeedsExport_CountMismatch verifies dbNeedsExport returns true when counts differ func TestDBNeedsExport_CountMismatch(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "beads.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") store := setupTestStore(t, dbPath) defer store.Close() ctx := context.Background() // Create and export issue issue1 := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, } err := store.CreateIssue(ctx, issue1, "test-user") if err != nil { t.Fatalf("Failed to create issue: %v", err) } if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil { t.Fatalf("Failed to export: %v", err) } // Add another issue to DB but don't export issue2 := &types.Issue{ Title: "Another Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } err = store.CreateIssue(ctx, issue2, "test-user") if err != nil { t.Fatalf("Failed to create second issue: %v", err) } // Make JSONL appear newer (but counts differ) time.Sleep(10 * time.Millisecond) now := time.Now().Add(1 * time.Hour) // Way in the future if err := os.Chtimes(jsonlPath, now, now); err != nil { t.Fatalf("Failed to touch JSONL: %v", err) } // Counts mismatch, should need export needsExport, err := dbNeedsExport(ctx, store, jsonlPath) if err != nil { t.Fatalf("dbNeedsExport failed: %v", err) } if !needsExport { t.Errorf("Expected needsExport=true (count mismatch), got false") } } // TestDBNeedsExport_NoJSONL verifies dbNeedsExport returns true when JSONL doesn't exist func TestDBNeedsExport_NoJSONL(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "beads.db") jsonlPath := filepath.Join(tmpDir, "issues.jsonl") store := setupTestStore(t, dbPath) defer store.Close() ctx := context.Background() // Create issue but don't export issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, } err := store.CreateIssue(ctx, issue, "test-user") if err != nil { t.Fatalf("Failed to create issue: %v", err) } // JSONL doesn't exist, should need export needsExport, err := dbNeedsExport(ctx, store, jsonlPath) if err != nil { t.Fatalf("dbNeedsExport failed: %v", err) } if !needsExport { t.Fatalf("Expected needsExport=true (JSONL missing), got false") } } // ============================================================================= // 3-Way Merge Tests (Phase 2) // ============================================================================= // makeTestIssue creates a test issue with specified fields func makeTestIssue(id, title string, status types.Status, priority int, updatedAt time.Time) *types.Issue { return &types.Issue{ ID: id, Title: title, Status: status, Priority: priority, IssueType: types.TypeTask, UpdatedAt: updatedAt, CreatedAt: updatedAt.Add(-time.Hour), // Created 1 hour before update } } // TestMergeIssue_NoBase_LocalOnly tests first sync with only local issue func TestMergeIssue_NoBase_LocalOnly(t *testing.T) { local := makeTestIssue("bd-1234", "Local Issue", types.StatusOpen, 1, time.Now()) merged, strategy := MergeIssue(nil, local, nil) if strategy != StrategyLocal { t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.ID != "bd-1234" { t.Errorf("Expected ID=bd-1234, got %s", merged.ID) } } // TestMergeIssue_NoBase_RemoteOnly tests first sync with only remote issue func TestMergeIssue_NoBase_RemoteOnly(t *testing.T) { remote := makeTestIssue("bd-5678", "Remote Issue", types.StatusOpen, 2, time.Now()) merged, strategy := MergeIssue(nil, nil, remote) if strategy != StrategyRemote { t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.ID != "bd-5678" { t.Errorf("Expected ID=bd-5678, got %s", merged.ID) } } // TestMergeIssue_NoBase_BothExist_LocalNewer tests first sync where both have same issue, local is newer func TestMergeIssue_NoBase_BothExist_LocalNewer(t *testing.T) { now := time.Now() local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now.Add(time.Hour)) remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now) merged, strategy := MergeIssue(nil, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.Title != "Local Title" { t.Errorf("Expected local title (newer), got %s", merged.Title) } } // TestMergeIssue_NoBase_BothExist_RemoteNewer tests first sync where both have same issue, remote is newer func TestMergeIssue_NoBase_BothExist_RemoteNewer(t *testing.T) { now := time.Now() local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now.Add(time.Hour)) merged, strategy := MergeIssue(nil, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.Title != "Remote Title" { t.Errorf("Expected remote title (newer), got %s", merged.Title) } } // TestMergeIssue_NoBase_BothExist_SameTime tests first sync where both have same timestamp (remote wins) func TestMergeIssue_NoBase_BothExist_SameTime(t *testing.T) { now := time.Now() local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now) merged, strategy := MergeIssue(nil, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Remote wins on tie (per design.md Decision 3) if merged.Title != "Remote Title" { t.Errorf("Expected remote title (tie goes to remote), got %s", merged.Title) } } // TestMergeIssue_NoChanges tests 3-way merge with no changes anywhere func TestMergeIssue_NoChanges(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategySame { t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } } // TestMergeIssue_OnlyLocalChanged tests 3-way merge where only local changed func TestMergeIssue_OnlyLocalChanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour)) remote := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyLocal { t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.Title != "Updated Title" { t.Errorf("Expected updated title, got %s", merged.Title) } } // TestMergeIssue_OnlyRemoteChanged tests 3-way merge where only remote changed func TestMergeIssue_OnlyRemoteChanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyRemote { t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.Title != "Updated Title" { t.Errorf("Expected updated title, got %s", merged.Title) } } // TestMergeIssue_BothMadeSameChange tests 3-way merge where both made identical change func TestMergeIssue_BothMadeSameChange(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour)) remote := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategySame { t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } if merged.Title != "Same Update" { t.Errorf("Expected 'Same Update', got %s", merged.Title) } } // TestMergeIssue_TrueConflict_LocalNewer tests true conflict where local is newer func TestMergeIssue_TrueConflict_LocalNewer(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(2*time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Local is newer, should win if merged.Title != "Local Update" { t.Errorf("Expected local title (newer), got %s", merged.Title) } if merged.Status != types.StatusInProgress { t.Errorf("Expected local status, got %s", merged.Status) } } // TestMergeIssue_TrueConflict_RemoteNewer tests true conflict where remote is newer func TestMergeIssue_TrueConflict_RemoteNewer(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(2*time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Remote is newer, should win if merged.Title != "Remote Update" { t.Errorf("Expected remote title (newer), got %s", merged.Title) } if merged.Status != types.StatusClosed { t.Errorf("Expected remote status, got %s", merged.Status) } } // TestMergeIssue_LocalDeleted_RemoteUnchanged tests local deletion when remote unchanged func TestMergeIssue_LocalDeleted_RemoteUnchanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now) merged, strategy := MergeIssue(base, nil, remote) if strategy != StrategyLocal { t.Errorf("Expected strategy=%s (honor local deletion), got %s", StrategyLocal, strategy) } if merged != nil { t.Errorf("Expected nil (deleted), got issue %s", merged.ID) } } // TestMergeIssue_LocalDeleted_RemoteChanged tests local deletion but remote changed func TestMergeIssue_LocalDeleted_RemoteChanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) remote := makeTestIssue("bd-1234", "Remote Updated", types.StatusClosed, 2, now.Add(time.Hour)) merged, strategy := MergeIssue(base, nil, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s (conflict: deleted vs updated), got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue (remote changed), got nil") } if merged.Title != "Remote Updated" { t.Errorf("Expected remote title (changed wins over delete), got %s", merged.Title) } } // TestMergeIssue_RemoteDeleted_LocalUnchanged tests remote deletion when local unchanged func TestMergeIssue_RemoteDeleted_LocalUnchanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now) merged, strategy := MergeIssue(base, local, nil) if strategy != StrategyRemote { t.Errorf("Expected strategy=%s (honor remote deletion), got %s", StrategyRemote, strategy) } if merged != nil { t.Errorf("Expected nil (deleted), got issue %s", merged.ID) } } // TestMergeIssue_RemoteDeleted_LocalChanged tests remote deletion but local changed func TestMergeIssue_RemoteDeleted_LocalChanged(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Updated", types.StatusClosed, 2, now.Add(time.Hour)) merged, strategy := MergeIssue(base, local, nil) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s (conflict: updated vs deleted), got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue (local changed), got nil") } if merged.Title != "Local Updated" { t.Errorf("Expected local title (changed wins over delete), got %s", merged.Title) } } // TestMergeIssues_Empty tests merging empty sets func TestMergeIssues_Empty(t *testing.T) { result := MergeIssues(nil, nil, nil) if len(result.Merged) != 0 { t.Errorf("Expected 0 merged issues, got %d", len(result.Merged)) } if result.Conflicts != 0 { t.Errorf("Expected 0 conflicts, got %d", result.Conflicts) } } // TestMergeIssues_MultipleIssues tests merging multiple issues with different scenarios func TestMergeIssues_MultipleIssues(t *testing.T) { now := time.Now() // Base state base := []*types.Issue{ makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now), makeTestIssue("bd-0002", "Will change locally", types.StatusOpen, 1, now), makeTestIssue("bd-0003", "Will change remotely", types.StatusOpen, 1, now), makeTestIssue("bd-0004", "To delete locally", types.StatusOpen, 1, now), } // Local state local := []*types.Issue{ makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now), makeTestIssue("bd-0002", "Changed locally", types.StatusInProgress, 1, now.Add(time.Hour)), makeTestIssue("bd-0003", "Will change remotely", types.StatusOpen, 1, now), // bd-0004 deleted locally makeTestIssue("bd-0005", "New local issue", types.StatusOpen, 1, now), // New issue } // Remote state remote := []*types.Issue{ makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now), makeTestIssue("bd-0002", "Will change locally", types.StatusOpen, 1, now), makeTestIssue("bd-0003", "Changed remotely", types.StatusClosed, 2, now.Add(time.Hour)), makeTestIssue("bd-0004", "To delete locally", types.StatusOpen, 1, now), // Unchanged from base makeTestIssue("bd-0006", "New remote issue", types.StatusOpen, 1, now), // New issue } result := MergeIssues(base, local, remote) // Should have 5 issues: // - bd-0001: same // - bd-0002: local changed // - bd-0003: remote changed // - bd-0004: deleted (not in merged) // - bd-0005: new local // - bd-0006: new remote if len(result.Merged) != 5 { t.Errorf("Expected 5 merged issues, got %d", len(result.Merged)) } // Verify strategies expectedStrategies := map[string]string{ "bd-0001": StrategySame, "bd-0002": StrategyLocal, "bd-0003": StrategyRemote, "bd-0004": StrategyLocal, // Deleted locally "bd-0005": StrategyLocal, "bd-0006": StrategyRemote, } for id, expected := range expectedStrategies { if got := result.Strategy[id]; got != expected { t.Errorf("Issue %s: expected strategy=%s, got %s", id, expected, got) } } // Verify bd-0004 is not in merged (deleted) for _, issue := range result.Merged { if issue.ID == "bd-0004" { t.Errorf("bd-0004 should be deleted, but found in merged") } } } // TestBaseState_LoadSave tests loading and saving base state func TestBaseState_LoadSave(t *testing.T) { tmpDir := t.TempDir() now := time.Now().Truncate(time.Second) // Truncate for JSON round-trip issues := []*types.Issue{ { ID: "bd-0001", Title: "Test Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, UpdatedAt: now, CreatedAt: now.Add(-time.Hour), }, { ID: "bd-0002", Title: "Test Issue 2", Status: types.StatusClosed, Priority: 2, IssueType: types.TypeBug, UpdatedAt: now, CreatedAt: now.Add(-time.Hour), }, } // Save base state if err := saveBaseState(tmpDir, issues); err != nil { t.Fatalf("saveBaseState failed: %v", err) } // Verify file exists baseStatePath := filepath.Join(tmpDir, syncBaseFileName) if _, err := os.Stat(baseStatePath); os.IsNotExist(err) { t.Fatalf("Base state file not created") } // Load base state loaded, err := loadBaseState(tmpDir) if err != nil { t.Fatalf("loadBaseState failed: %v", err) } if len(loaded) != 2 { t.Fatalf("Expected 2 issues, got %d", len(loaded)) } // Verify issue content if loaded[0].ID != "bd-0001" || loaded[0].Title != "Test Issue 1" { t.Errorf("First issue mismatch: got ID=%s, Title=%s", loaded[0].ID, loaded[0].Title) } if loaded[1].ID != "bd-0002" || loaded[1].Title != "Test Issue 2" { t.Errorf("Second issue mismatch: got ID=%s, Title=%s", loaded[1].ID, loaded[1].Title) } } // TestBaseState_LoadMissing tests loading when no base state exists func TestBaseState_LoadMissing(t *testing.T) { tmpDir := t.TempDir() loaded, err := loadBaseState(tmpDir) if err != nil { t.Fatalf("loadBaseState failed: %v", err) } if loaded != nil { t.Errorf("Expected nil for missing base state, got %d issues", len(loaded)) } } // TestBaseState_LoadMalformed tests loading sync_base.jsonl with malformed lines func TestBaseState_LoadMalformed(t *testing.T) { tmpDir := t.TempDir() baseStatePath := filepath.Join(tmpDir, syncBaseFileName) // Create file with mix of valid and malformed lines content := `{"id":"bd-0001","title":"Valid Issue","status":"open","priority":1,"issue_type":"task"} not valid json at all {"id":"bd-0002","title":"Another Valid","status":"closed","priority":2,"issue_type":"bug"} {truncated json {"id":"bd-0003","title":"Third Valid","status":"open","priority":3,"issue_type":"task"} ` if err := os.WriteFile(baseStatePath, []byte(content), 0644); err != nil { t.Fatalf("Failed to write test file: %v", err) } // Capture stderr to verify warning is produced oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w loaded, err := loadBaseState(tmpDir) // Restore stderr and read captured output w.Close() os.Stderr = oldStderr var stderrBuf bytes.Buffer stderrBuf.ReadFrom(r) stderrOutput := stderrBuf.String() if err != nil { t.Fatalf("loadBaseState failed: %v", err) } // Should have loaded 3 valid issues, skipping 2 malformed lines if len(loaded) != 3 { t.Errorf("Expected 3 valid issues, got %d", len(loaded)) } // Verify correct issues loaded expectedIDs := []string{"bd-0001", "bd-0002", "bd-0003"} for i, expected := range expectedIDs { if i >= len(loaded) { t.Errorf("Missing issue at index %d", i) continue } if loaded[i].ID != expected { t.Errorf("Issue %d: expected ID=%s, got %s", i, expected, loaded[i].ID) } } // Verify warnings were produced for malformed lines (lines 2 and 4) if !strings.Contains(stderrOutput, "line 2") { t.Errorf("Expected warning for line 2, got: %s", stderrOutput) } if !strings.Contains(stderrOutput, "line 4") { t.Errorf("Expected warning for line 4, got: %s", stderrOutput) } } // TestIssueEqual tests the issueEqual helper function func TestIssueEqual(t *testing.T) { now := time.Now() tests := []struct { name string a, b *types.Issue expected bool }{ { name: "both nil", a: nil, b: nil, expected: true, }, { name: "a nil", a: nil, b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), expected: false, }, { name: "b nil", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: nil, expected: false, }, { name: "identical", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), expected: true, }, { name: "different ID", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: makeTestIssue("bd-5678", "Test", types.StatusOpen, 1, now), expected: false, }, { name: "different title", a: makeTestIssue("bd-1234", "Test A", types.StatusOpen, 1, now), b: makeTestIssue("bd-1234", "Test B", types.StatusOpen, 1, now), expected: false, }, { name: "different status", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: makeTestIssue("bd-1234", "Test", types.StatusClosed, 1, now), expected: false, }, { name: "different priority", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 2, now), expected: false, }, { name: "different updated_at", a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now), b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(time.Hour)), expected: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := issueEqual(tc.a, tc.b) if result != tc.expected { t.Errorf("issueEqual returned %v, expected %v", result, tc.expected) } }) } } // ============================================================================= // Field-Level Merge Tests (Phase 3) // ============================================================================= // makeTestIssueWithLabels creates a test issue with labels func makeTestIssueWithLabels(id, title string, status types.Status, priority int, updatedAt time.Time, labels []string) *types.Issue { issue := makeTestIssue(id, title, status, priority, updatedAt) issue.Labels = labels return issue } // TestFieldMerge_LWW_LocalNewer tests field-level merge where local is newer func TestFieldMerge_LWW_LocalNewer(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(2*time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Local is newer, should have local's scalar values if merged.Title != "Local Update" { t.Errorf("Expected title='Local Update' (local is newer), got %s", merged.Title) } if merged.Status != types.StatusInProgress { t.Errorf("Expected status=in_progress (local is newer), got %s", merged.Status) } if merged.Priority != 2 { t.Errorf("Expected priority=2 (local is newer), got %d", merged.Priority) } } // TestFieldMerge_LWW_RemoteNewer tests field-level merge where remote is newer func TestFieldMerge_LWW_RemoteNewer(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(2*time.Hour)) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Remote is newer, should have remote's scalar values if merged.Title != "Remote Update" { t.Errorf("Expected title='Remote Update' (remote is newer), got %s", merged.Title) } if merged.Status != types.StatusClosed { t.Errorf("Expected status=closed (remote is newer), got %s", merged.Status) } if merged.Priority != 3 { t.Errorf("Expected priority=3 (remote is newer), got %d", merged.Priority) } } // TestFieldMerge_LWW_SameTimestamp tests field-level merge where timestamps are equal (remote wins) func TestFieldMerge_LWW_SameTimestamp(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now.Add(-time.Hour)) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Same timestamp: remote wins (per design.md Decision 3) if merged.Title != "Remote Update" { t.Errorf("Expected title='Remote Update' (remote wins on tie), got %s", merged.Title) } if merged.Status != types.StatusClosed { t.Errorf("Expected status=closed (remote wins on tie), got %s", merged.Status) } } // TestLabelUnion_BothAdd tests label union when both local and remote add different labels func TestLabelUnion_BothAdd(t *testing.T) { now := time.Now() base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"}) local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"}) remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original", "remote-added"}) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Labels should be union of both expectedLabels := []string{"local-added", "original", "remote-added"} if len(merged.Labels) != len(expectedLabels) { t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels) } for _, expected := range expectedLabels { found := false for _, actual := range merged.Labels { if actual == expected { found = true break } } if !found { t.Errorf("Expected label %q in merged labels %v", expected, merged.Labels) } } } // TestLabelUnion_LocalOnly tests label union when only local adds labels func TestLabelUnion_LocalOnly(t *testing.T) { now := time.Now() base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"}) local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"}) remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"}) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Labels should include local-added even though remote is newer for scalars expectedLabels := []string{"local-added", "original"} if len(merged.Labels) != len(expectedLabels) { t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels) } } // TestLabelUnion_RemoteOnly tests label union when only remote adds labels func TestLabelUnion_RemoteOnly(t *testing.T) { now := time.Now() base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"}) local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"}) remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "remote-added"}) merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Labels should include remote-added even though local is newer for scalars expectedLabels := []string{"original", "remote-added"} if len(merged.Labels) != len(expectedLabels) { t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels) } } // TestDependencyUnion tests dependency union when both add different dependencies func TestDependencyUnion(t *testing.T) { now := time.Now() localDep := &types.Dependency{ IssueID: "bd-1234", DependsOnID: "bd-aaaa", Type: types.DepBlocks, CreatedAt: now, } remoteDep := &types.Dependency{ IssueID: "bd-1234", DependsOnID: "bd-bbbb", Type: types.DepBlocks, CreatedAt: now, } base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Dependencies = []*types.Dependency{localDep} remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Dependencies = []*types.Dependency{remoteDep} merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Dependencies should be union of both if len(merged.Dependencies) != 2 { t.Errorf("Expected 2 dependencies, got %d", len(merged.Dependencies)) } // Check both dependencies are present foundAAA := false foundBBB := false for _, dep := range merged.Dependencies { if dep.DependsOnID == "bd-aaaa" { foundAAA = true } if dep.DependsOnID == "bd-bbbb" { foundBBB = true } } if !foundAAA { t.Error("Expected dependency to bd-aaaa in merged") } if !foundBBB { t.Error("Expected dependency to bd-bbbb in merged") } } // TestCommentAppend tests comment append-merge with deduplication func TestCommentAppend(t *testing.T) { now := time.Now() // Common comment (should be deduplicated) commonComment := &types.Comment{ ID: 1, IssueID: "bd-1234", Author: "user1", Text: "Common comment", CreatedAt: now.Add(-time.Hour), } localComment := &types.Comment{ ID: 2, IssueID: "bd-1234", Author: "user2", Text: "Local comment", CreatedAt: now, } remoteComment := &types.Comment{ ID: 3, IssueID: "bd-1234", Author: "user3", Text: "Remote comment", CreatedAt: now.Add(30 * time.Minute), } base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-2*time.Hour)) base.Comments = []*types.Comment{commonComment} local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Comments = []*types.Comment{commonComment, localComment} remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Comments = []*types.Comment{commonComment, remoteComment} merged, strategy := MergeIssue(base, local, remote) if strategy != StrategyMerged { t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy) } if merged == nil { t.Fatal("Expected merged issue, got nil") } // Comments should be union (3 total: common, local, remote) if len(merged.Comments) != 3 { t.Errorf("Expected 3 comments, got %d", len(merged.Comments)) } // Check comments are sorted chronologically for i := 0; i < len(merged.Comments)-1; i++ { if merged.Comments[i].CreatedAt.After(merged.Comments[i+1].CreatedAt) { t.Errorf("Comments not sorted chronologically: %v after %v", merged.Comments[i].CreatedAt, merged.Comments[i+1].CreatedAt) } } } // TestFieldMerge_EdgeCases tests edge cases in field-level merge func TestFieldMerge_EdgeCases(t *testing.T) { t.Run("nil_labels", func(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Labels = nil remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Labels = []string{"remote-label"} merged, _ := MergeIssue(base, local, remote) if merged == nil { t.Fatal("Expected merged issue, got nil") } // Should have remote label (union of nil and ["remote-label"]) if len(merged.Labels) != 1 || merged.Labels[0] != "remote-label" { t.Errorf("Expected ['remote-label'], got %v", merged.Labels) } }) t.Run("empty_labels", func(t *testing.T) { now := time.Now() base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Labels = []string{} remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Labels = []string{"remote-label"} merged, _ := MergeIssue(base, local, remote) if merged == nil { t.Fatal("Expected merged issue, got nil") } // Should have remote label (union of [] and ["remote-label"]) if len(merged.Labels) != 1 || merged.Labels[0] != "remote-label" { t.Errorf("Expected ['remote-label'], got %v", merged.Labels) } }) t.Run("nil_dependencies", func(t *testing.T) { now := time.Now() dep := &types.Dependency{ IssueID: "bd-1234", DependsOnID: "bd-dep", Type: types.DepBlocks, CreatedAt: now, } base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Dependencies = []*types.Dependency{dep} remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Dependencies = nil merged, _ := MergeIssue(base, local, remote) if merged == nil { t.Fatal("Expected merged issue, got nil") } // Should have the dependency from local if len(merged.Dependencies) != 1 { t.Errorf("Expected 1 dependency, got %d", len(merged.Dependencies)) } }) t.Run("nil_comments", func(t *testing.T) { now := time.Now() comment := &types.Comment{ ID: 1, IssueID: "bd-1234", Author: "user", Text: "Test comment", CreatedAt: now, } base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-time.Hour)) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Comments = nil remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Comments = []*types.Comment{comment} merged, _ := MergeIssue(base, local, remote) if merged == nil { t.Fatal("Expected merged issue, got nil") } // Should have the comment from remote if len(merged.Comments) != 1 { t.Errorf("Expected 1 comment, got %d", len(merged.Comments)) } }) t.Run("duplicate_dependencies_newer_wins", func(t *testing.T) { now := time.Now() // Same dependency in both, but with different metadata/timestamps localDep := &types.Dependency{ IssueID: "bd-1234", DependsOnID: "bd-dep", Type: types.DepBlocks, CreatedAt: now, CreatedBy: "local-user", } remoteDep := &types.Dependency{ IssueID: "bd-1234", DependsOnID: "bd-dep", Type: types.DepBlocks, CreatedAt: now.Add(time.Hour), // Newer CreatedBy: "remote-user", } base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-time.Hour)) local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour)) local.Dependencies = []*types.Dependency{localDep} remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour)) remote.Dependencies = []*types.Dependency{remoteDep} merged, _ := MergeIssue(base, local, remote) if merged == nil { t.Fatal("Expected merged issue, got nil") } // Should have only 1 dependency (deduplicated), the newer one if len(merged.Dependencies) != 1 { t.Errorf("Expected 1 dependency, got %d", len(merged.Dependencies)) } if merged.Dependencies[0].CreatedBy != "remote-user" { t.Errorf("Expected newer dependency (remote-user), got %s", merged.Dependencies[0].CreatedBy) } }) } // ============================================================================= // Clock Skew Detection Tests (Phase 2 - PR918) // ============================================================================= // TestMergeClockSkewWarning tests that large timestamp differences produce a warning func TestMergeClockSkewWarning(t *testing.T) { now := time.Now() t.Run("no_warning_under_24h", func(t *testing.T) { base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(23*time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 1, now) // Capture stderr oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w _, _ = MergeIssue(base, local, remote) w.Close() os.Stderr = oldStderr var stderrBuf bytes.Buffer stderrBuf.ReadFrom(r) stderrOutput := stderrBuf.String() // Should NOT produce warning for <24h difference if strings.Contains(stderrOutput, "clock skew") { t.Errorf("Expected no warning for 23h difference, got: %s", stderrOutput) } }) t.Run("warning_over_24h_local_newer", func(t *testing.T) { base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(48*time.Hour)) remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 1, now) // Capture stderr oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w _, _ = MergeIssue(base, local, remote) w.Close() os.Stderr = oldStderr var stderrBuf bytes.Buffer stderrBuf.ReadFrom(r) stderrOutput := stderrBuf.String() // Should produce warning for 48h difference if !strings.Contains(stderrOutput, "clock skew") { t.Errorf("Expected clock skew warning for 48h difference, got: %s", stderrOutput) } if !strings.Contains(stderrOutput, "bd-1234") { t.Errorf("Warning should contain issue ID, got: %s", stderrOutput) } }) t.Run("warning_over_24h_remote_newer", func(t *testing.T) { base := makeTestIssue("bd-5678", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-5678", "Local Update", types.StatusInProgress, 1, now) remote := makeTestIssue("bd-5678", "Remote Update", types.StatusClosed, 1, now.Add(72*time.Hour)) // Capture stderr oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w _, _ = MergeIssue(base, local, remote) w.Close() os.Stderr = oldStderr var stderrBuf bytes.Buffer stderrBuf.ReadFrom(r) stderrOutput := stderrBuf.String() // Should produce warning for 72h difference if !strings.Contains(stderrOutput, "clock skew") { t.Errorf("Expected clock skew warning for 72h difference, got: %s", stderrOutput) } if !strings.Contains(stderrOutput, "bd-5678") { t.Errorf("Warning should contain issue ID, got: %s", stderrOutput) } }) t.Run("warning_exactly_24h", func(t *testing.T) { base := makeTestIssue("bd-exact", "Original", types.StatusOpen, 1, now) local := makeTestIssue("bd-exact", "Local Update", types.StatusInProgress, 1, now.Add(24*time.Hour)) remote := makeTestIssue("bd-exact", "Remote Update", types.StatusClosed, 1, now) // Capture stderr oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w _, _ = MergeIssue(base, local, remote) w.Close() os.Stderr = oldStderr var stderrBuf bytes.Buffer stderrBuf.ReadFrom(r) stderrOutput := stderrBuf.String() // Exactly 24h should NOT trigger warning (> not >=) if strings.Contains(stderrOutput, "clock skew") { t.Errorf("Expected no warning for exactly 24h difference, got: %s", stderrOutput) } }) } // TestMergeLabels tests the mergeLabels helper function directly func TestMergeLabels(t *testing.T) { tests := []struct { name string local []string remote []string expected []string }{ { name: "both_empty", local: nil, remote: nil, expected: nil, }, { name: "local_only", local: []string{"a", "b"}, remote: nil, expected: []string{"a", "b"}, }, { name: "remote_only", local: nil, remote: []string{"x", "y"}, expected: []string{"x", "y"}, }, { name: "no_overlap", local: []string{"a", "b"}, remote: []string{"x", "y"}, expected: []string{"a", "b", "x", "y"}, }, { name: "full_overlap", local: []string{"a", "b"}, remote: []string{"a", "b"}, expected: []string{"a", "b"}, }, { name: "partial_overlap", local: []string{"a", "b", "c"}, remote: []string{"b", "c", "d"}, expected: []string{"a", "b", "c", "d"}, }, { name: "duplicates_in_input", local: []string{"a", "a", "b"}, remote: []string{"b", "b", "c"}, expected: []string{"a", "b", "c"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := mergeLabels(tc.local, tc.remote) // Check length if len(result) != len(tc.expected) { t.Errorf("Expected %d labels, got %d: %v", len(tc.expected), len(result), result) return } // Check contents (result is sorted, so direct comparison works) for i, expected := range tc.expected { if i >= len(result) || result[i] != expected { t.Errorf("Expected %v, got %v", tc.expected, result) return } } }) } }