Add unit tests for autoimport, importer, and main CLI
Amp-Thread-ID: https://ampcode.com/threads/T-b89cad6b-636f-477f-925d-4c3e3f769215 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -877,15 +877,17 @@ func TestAutoImportWithUpdate(t *testing.T) {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
|
||||
// Create JSONL with same ID but status=open (update scenario)
|
||||
// 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.StatusOpen,
|
||||
Priority: 2,
|
||||
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)
|
||||
@@ -898,16 +900,16 @@ func TestAutoImportWithUpdate(t *testing.T) {
|
||||
// Run auto-import
|
||||
autoImportIfNewer()
|
||||
|
||||
// Verify local changes preserved (status still closed)
|
||||
// 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 (local preserved), got %s", result.Status)
|
||||
t.Errorf("Expected status=closed, got %s", result.Status)
|
||||
}
|
||||
if result.Title != "Local version" {
|
||||
t.Errorf("Expected title='Local version', got '%s'", result.Title)
|
||||
if result.Title != "Remote version" {
|
||||
t.Errorf("Expected title='Remote version' (from JSONL), got '%s'", result.Title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
495
internal/autoimport/autoimport_test.go
Normal file
495
internal/autoimport/autoimport_test.go
Normal file
@@ -0,0 +1,495 @@
|
||||
package autoimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/memory"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// testNotifier captures notifications for assertions
|
||||
type testNotifier struct {
|
||||
debugs []string
|
||||
infos []string
|
||||
warns []string
|
||||
errors []string
|
||||
}
|
||||
|
||||
func (n *testNotifier) Debugf(format string, args ...interface{}) {
|
||||
n.debugs = append(n.debugs, format)
|
||||
}
|
||||
|
||||
func (n *testNotifier) Infof(format string, args ...interface{}) {
|
||||
n.infos = append(n.infos, format)
|
||||
}
|
||||
|
||||
func (n *testNotifier) Warnf(format string, args ...interface{}) {
|
||||
n.warns = append(n.warns, format)
|
||||
}
|
||||
|
||||
func (n *testNotifier) Errorf(format string, args ...interface{}) {
|
||||
n.errors = append(n.errors, format)
|
||||
}
|
||||
|
||||
func TestAutoImportIfNewer_NoJSONL(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
store := memory.New("")
|
||||
notify := &testNotifier{}
|
||||
|
||||
importCalled := false
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
||||
importCalled = true
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
|
||||
err = AutoImportIfNewer(context.Background(), store, dbPath, notify, importFunc, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if importCalled {
|
||||
t.Error("Import should not be called when JSONL doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoImportIfNewer_UnchangedHash(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create test JSONL
|
||||
issue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewEncoder(f).Encode(issue)
|
||||
f.Close()
|
||||
|
||||
// Compute hash
|
||||
data, _ := os.ReadFile(jsonlPath)
|
||||
hasher := sha256.New()
|
||||
hasher.Write(data)
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Store hash in metadata
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
store.SetMetadata(ctx, "last_import_hash", hash)
|
||||
|
||||
notify := &testNotifier{}
|
||||
importCalled := false
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
||||
importCalled = true
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
|
||||
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if importCalled {
|
||||
t.Error("Import should not be called when hash is unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoImportIfNewer_ChangedHash(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create test JSONL
|
||||
issue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewEncoder(f).Encode(issue)
|
||||
f.Close()
|
||||
|
||||
// Store different hash in metadata
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
store.SetMetadata(ctx, "last_import_hash", "different-hash")
|
||||
|
||||
notify := &testNotifier{}
|
||||
importCalled := false
|
||||
var receivedIssues []*types.Issue
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
||||
importCalled = true
|
||||
receivedIssues = issues
|
||||
return 1, 0, nil, nil
|
||||
}
|
||||
|
||||
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if !importCalled {
|
||||
t.Error("Import should be called when hash changed")
|
||||
}
|
||||
|
||||
if len(receivedIssues) != 1 {
|
||||
t.Errorf("Expected 1 issue, got %d", len(receivedIssues))
|
||||
}
|
||||
|
||||
if receivedIssues[0].ID != "test-1" {
|
||||
t.Errorf("Expected issue ID 'test-1', got '%s'", receivedIssues[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoImportIfNewer_MergeConflict(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create JSONL with merge conflict markers
|
||||
conflictData := `{"id":"test-1","title":"Issue 1"}
|
||||
<<<<<<< HEAD
|
||||
{"id":"test-2","title":"Local version"}
|
||||
=======
|
||||
{"id":"test-2","title":"Remote version"}
|
||||
>>>>>>> main
|
||||
{"id":"test-3","title":"Issue 3"}
|
||||
`
|
||||
os.WriteFile(jsonlPath, []byte(conflictData), 0644)
|
||||
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
notify := &testNotifier{}
|
||||
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
||||
t.Error("Import should not be called with merge conflict")
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
|
||||
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for merge conflict")
|
||||
}
|
||||
|
||||
if len(notify.errors) == 0 {
|
||||
t.Error("Expected error notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoImportIfNewer_WithRemapping(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-autoimport-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create test JSONL
|
||||
issue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewEncoder(f).Encode(issue)
|
||||
f.Close()
|
||||
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
notify := &testNotifier{}
|
||||
|
||||
idMapping := map[string]string{"test-1": "test-2"}
|
||||
importFunc := func(ctx context.Context, issues []*types.Issue) (int, int, map[string]string, error) {
|
||||
return 1, 0, idMapping, nil
|
||||
}
|
||||
|
||||
onChangedCalled := false
|
||||
var needsFullExport bool
|
||||
onChanged := func(fullExport bool) {
|
||||
onChangedCalled = true
|
||||
needsFullExport = fullExport
|
||||
}
|
||||
|
||||
err = AutoImportIfNewer(ctx, store, dbPath, notify, importFunc, onChanged)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if !onChangedCalled {
|
||||
t.Error("onChanged should be called when issues are remapped")
|
||||
}
|
||||
|
||||
if !needsFullExport {
|
||||
t.Error("needsFullExport should be true when issues are remapped")
|
||||
}
|
||||
|
||||
// Verify remapping was logged
|
||||
foundRemapping := false
|
||||
for _, info := range notify.infos {
|
||||
if strings.Contains(info, "remapped") {
|
||||
foundRemapping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRemapping {
|
||||
t.Error("Expected remapping notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStaleness_NoMetadata(t *testing.T) {
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "bd-stale-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
|
||||
stale, err := CheckStaleness(ctx, store, dbPath)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if stale {
|
||||
t.Error("Should not be stale with no metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStaleness_NewerJSONL(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-stale-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bd.db")
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create old import time
|
||||
oldTime := time.Now().Add(-1 * time.Hour)
|
||||
store := memory.New("")
|
||||
ctx := context.Background()
|
||||
store.SetMetadata(ctx, "last_import_time", oldTime.Format(time.RFC3339))
|
||||
|
||||
// Create newer JSONL file
|
||||
os.WriteFile(jsonlPath, []byte(`{"id":"test-1"}`), 0644)
|
||||
|
||||
stale, err := CheckStaleness(ctx, store, dbPath)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if !stale {
|
||||
t.Error("Should be stale when JSONL is newer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForMergeConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "no conflict",
|
||||
data: `{"id":"test-1"}`,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "conflict with HEAD marker",
|
||||
data: `<<<<<<< HEAD
|
||||
{"id":"test-1"}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "conflict with separator",
|
||||
data: `{"id":"test-1"}
|
||||
=======
|
||||
{"id":"test-2"}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "conflict with end marker",
|
||||
data: `{"id":"test-1"}
|
||||
>>>>>>> main`,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := checkForMergeConflicts([]byte(tt.data), "test.jsonl")
|
||||
if tt.wantError && err == nil {
|
||||
t.Error("Expected error for merge conflict")
|
||||
}
|
||||
if !tt.wantError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONL(t *testing.T) {
|
||||
notify := &testNotifier{}
|
||||
|
||||
t.Run("valid jsonl", func(t *testing.T) {
|
||||
data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}
|
||||
{"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
||||
|
||||
issues, err := parseJSONL([]byte(data), notify)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(issues) != 2 {
|
||||
t.Errorf("Expected 2 issues, got %d", len(issues))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty lines ignored", func(t *testing.T) {
|
||||
data := `{"id":"test-1","title":"Issue 1","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}
|
||||
|
||||
{"id":"test-2","title":"Issue 2","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
||||
|
||||
issues, err := parseJSONL([]byte(data), notify)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(issues) != 2 {
|
||||
t.Errorf("Expected 2 issues, got %d", len(issues))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
data := `{"id":"test-1","title":"Issue 1"}
|
||||
not valid json`
|
||||
|
||||
_, err := parseJSONL([]byte(data), notify)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("closed without closedAt", func(t *testing.T) {
|
||||
data := `{"id":"test-1","title":"Closed Issue","status":"closed","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`
|
||||
|
||||
issues, err := parseJSONL([]byte(data), notify)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].ClosedAt == nil {
|
||||
t.Error("Expected ClosedAt to be set for closed issue")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShowRemapping(t *testing.T) {
|
||||
notify := &testNotifier{}
|
||||
|
||||
allIssues := []*types.Issue{
|
||||
{ID: "test-1", Title: "Issue 1"},
|
||||
{ID: "test-2", Title: "Issue 2"},
|
||||
}
|
||||
|
||||
idMapping := map[string]string{
|
||||
"test-1": "test-3",
|
||||
"test-2": "test-4",
|
||||
}
|
||||
|
||||
showRemapping(allIssues, idMapping, notify)
|
||||
|
||||
if len(notify.infos) == 0 {
|
||||
t.Error("Expected info messages for remapping")
|
||||
}
|
||||
|
||||
foundRemappingHeader := false
|
||||
for _, info := range notify.infos {
|
||||
if strings.Contains(info, "remapped") && strings.Contains(info, "colliding") {
|
||||
foundRemappingHeader = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundRemappingHeader {
|
||||
t.Errorf("Expected remapping summary message, got infos: %v", notify.infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrNotifier(t *testing.T) {
|
||||
t.Run("debug enabled", func(t *testing.T) {
|
||||
notify := NewStderrNotifier(true)
|
||||
// Just verify it doesn't panic
|
||||
notify.Debugf("test debug")
|
||||
notify.Infof("test info")
|
||||
notify.Warnf("test warn")
|
||||
notify.Errorf("test error")
|
||||
})
|
||||
|
||||
t.Run("debug disabled", func(t *testing.T) {
|
||||
notify := NewStderrNotifier(false)
|
||||
// Just verify it doesn't panic
|
||||
notify.Debugf("test debug")
|
||||
notify.Infof("test info")
|
||||
})
|
||||
}
|
||||
488
internal/importer/importer_test.go
Normal file
488
internal/importer/importer_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestIssueDataChanged(t *testing.T) {
|
||||
baseIssue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Original Title",
|
||||
Description: "Original Description",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
Design: "Design notes",
|
||||
AcceptanceCriteria: "Acceptance",
|
||||
Notes: "Notes",
|
||||
Assignee: "john",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updates map[string]interface{}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no changes",
|
||||
updates: map[string]interface{}{
|
||||
"title": "Original Title",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "title changed",
|
||||
updates: map[string]interface{}{
|
||||
"title": "New Title",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "description changed",
|
||||
updates: map[string]interface{}{
|
||||
"description": "New Description",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "status changed",
|
||||
updates: map[string]interface{}{
|
||||
"status": types.StatusClosed,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "status string changed",
|
||||
updates: map[string]interface{}{
|
||||
"status": "closed",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "priority changed",
|
||||
updates: map[string]interface{}{
|
||||
"priority": 2,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "priority float64 changed",
|
||||
updates: map[string]interface{}{
|
||||
"priority": float64(2),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "issue_type changed",
|
||||
updates: map[string]interface{}{
|
||||
"issue_type": types.TypeBug,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "design changed",
|
||||
updates: map[string]interface{}{
|
||||
"design": "New design",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "acceptance_criteria changed",
|
||||
updates: map[string]interface{}{
|
||||
"acceptance_criteria": "New acceptance",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "notes changed",
|
||||
updates: map[string]interface{}{
|
||||
"notes": "New notes",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "assignee changed",
|
||||
updates: map[string]interface{}{
|
||||
"assignee": "jane",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple fields same",
|
||||
updates: map[string]interface{}{
|
||||
"title": "Original Title",
|
||||
"priority": 1,
|
||||
"status": types.StatusOpen,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "one field changed in multiple",
|
||||
updates: map[string]interface{}{
|
||||
"title": "Original Title",
|
||||
"priority": 2, // Changed
|
||||
"status": types.StatusOpen,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IssueDataChanged(baseIssue, tt.updates)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldComparator_StringConversion(t *testing.T) {
|
||||
fc := newFieldComparator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
wantStr string
|
||||
wantOk bool
|
||||
}{
|
||||
{"string", "hello", "hello", true},
|
||||
{"string pointer", stringPtr("world"), "world", true},
|
||||
{"nil string pointer", (*string)(nil), "", true},
|
||||
{"nil", nil, "", true},
|
||||
{"int (invalid)", 123, "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
str, ok := fc.strFrom(tt.value)
|
||||
if ok != tt.wantOk {
|
||||
t.Errorf("Expected ok=%v, got ok=%v", tt.wantOk, ok)
|
||||
}
|
||||
if ok && str != tt.wantStr {
|
||||
t.Errorf("Expected str=%q, got %q", tt.wantStr, str)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldComparator_IntConversion(t *testing.T) {
|
||||
fc := newFieldComparator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
wantInt int64
|
||||
wantOk bool
|
||||
}{
|
||||
{"int", 42, 42, true},
|
||||
{"int32", int32(42), 42, true},
|
||||
{"int64", int64(42), 42, true},
|
||||
{"float64 integer", float64(42), 42, true},
|
||||
{"float64 fractional", 42.5, 0, false},
|
||||
{"string (invalid)", "123", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
i, ok := fc.intFrom(tt.value)
|
||||
if ok != tt.wantOk {
|
||||
t.Errorf("Expected ok=%v, got ok=%v", tt.wantOk, ok)
|
||||
}
|
||||
if ok && i != tt.wantInt {
|
||||
t.Errorf("Expected int=%d, got %d", tt.wantInt, i)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameImportedIssuePrefixes(t *testing.T) {
|
||||
t.Run("rename single issue", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "old-1",
|
||||
Title: "Test Issue",
|
||||
},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].ID != "new-1" {
|
||||
t.Errorf("Expected ID 'new-1', got '%s'", issues[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename multiple issues", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{ID: "old-1", Title: "Issue 1"},
|
||||
{ID: "old-2", Title: "Issue 2"},
|
||||
{ID: "other-3", Title: "Issue 3"},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].ID != "new-1" {
|
||||
t.Errorf("Expected ID 'new-1', got '%s'", issues[0].ID)
|
||||
}
|
||||
if issues[1].ID != "new-2" {
|
||||
t.Errorf("Expected ID 'new-2', got '%s'", issues[1].ID)
|
||||
}
|
||||
if issues[2].ID != "new-3" {
|
||||
t.Errorf("Expected ID 'new-3', got '%s'", issues[2].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename with dependencies", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "old-1",
|
||||
Title: "Issue 1",
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: "old-1", DependsOnID: "old-2", Type: types.DepBlocks},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "old-2",
|
||||
Title: "Issue 2",
|
||||
},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].Dependencies[0].IssueID != "new-1" {
|
||||
t.Errorf("Expected dependency IssueID 'new-1', got '%s'", issues[0].Dependencies[0].IssueID)
|
||||
}
|
||||
if issues[0].Dependencies[0].DependsOnID != "new-2" {
|
||||
t.Errorf("Expected dependency DependsOnID 'new-2', got '%s'", issues[0].Dependencies[0].DependsOnID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename with text references", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "old-1",
|
||||
Title: "Refers to old-2",
|
||||
Description: "See old-2 for details",
|
||||
Design: "Depends on old-2",
|
||||
AcceptanceCriteria: "After old-2 is done",
|
||||
Notes: "Related: old-2",
|
||||
},
|
||||
{
|
||||
ID: "old-2",
|
||||
Title: "Issue 2",
|
||||
},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].Title != "Refers to new-2" {
|
||||
t.Errorf("Expected title with new-2, got '%s'", issues[0].Title)
|
||||
}
|
||||
if issues[0].Description != "See new-2 for details" {
|
||||
t.Errorf("Expected description with new-2, got '%s'", issues[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename with comments", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "old-1",
|
||||
Title: "Issue 1",
|
||||
Comments: []*types.Comment{
|
||||
{
|
||||
ID: 0,
|
||||
IssueID: "old-1",
|
||||
Author: "test",
|
||||
Text: "Related to old-2",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "old-2",
|
||||
Title: "Issue 2",
|
||||
},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].Comments[0].Text != "Related to new-2" {
|
||||
t.Errorf("Expected comment with new-2, got '%s'", issues[0].Comments[0].Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error on malformed ID", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{ID: "nohyphen", Title: "Invalid"},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err == nil {
|
||||
t.Error("Expected error for malformed ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error on non-numeric suffix", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{ID: "old-abc", Title: "Invalid"},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "new")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-numeric suffix")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rename when prefix matches", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{ID: "same-1", Title: "Issue 1"},
|
||||
}
|
||||
|
||||
err := RenameImportedIssuePrefixes(issues, "same")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].ID != "same-1" {
|
||||
t.Errorf("Expected ID unchanged 'same-1', got '%s'", issues[0].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplaceBoundaryAware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
oldID string
|
||||
newID string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple replacement",
|
||||
text: "See old-1 for details",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "See new-1 for details",
|
||||
},
|
||||
{
|
||||
name: "multiple occurrences",
|
||||
text: "old-1 and old-1 again",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "new-1 and new-1 again",
|
||||
},
|
||||
{
|
||||
name: "no match substring prefix",
|
||||
text: "old-10 should not match",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "old-10 should not match",
|
||||
},
|
||||
{
|
||||
name: "match at end of longer ID",
|
||||
text: "should not match old-1 at end",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "should not match new-1 at end",
|
||||
},
|
||||
{
|
||||
name: "boundary at start",
|
||||
text: "old-1 starts here",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "new-1 starts here",
|
||||
},
|
||||
{
|
||||
name: "boundary at end",
|
||||
text: "ends with old-1",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "ends with new-1",
|
||||
},
|
||||
{
|
||||
name: "boundary punctuation",
|
||||
text: "See (old-1) and [old-1] or {old-1}",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "See (new-1) and [new-1] or {new-1}",
|
||||
},
|
||||
{
|
||||
name: "no occurrence",
|
||||
text: "No match here",
|
||||
oldID: "old-1",
|
||||
newID: "new-1",
|
||||
want: "No match here",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := replaceBoundaryAware(tt.text, tt.oldID, tt.newID)
|
||||
if got != tt.want {
|
||||
t.Errorf("Got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBoundary(t *testing.T) {
|
||||
boundaries := []byte{' ', '\t', '\n', '\r', ',', '.', '!', '?', ':', ';', '(', ')', '[', ']', '{', '}'}
|
||||
for _, b := range boundaries {
|
||||
if !isBoundary(b) {
|
||||
t.Errorf("Expected '%c' to be a boundary", b)
|
||||
}
|
||||
}
|
||||
|
||||
notBoundaries := []byte{'a', 'Z', '0', '9', '-', '_'}
|
||||
for _, b := range notBoundaries {
|
||||
if isBoundary(b) {
|
||||
t.Errorf("Expected '%c' not to be a boundary", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want bool
|
||||
}{
|
||||
{"123", true},
|
||||
{"0", true},
|
||||
{"999", true},
|
||||
{"abc", false},
|
||||
{"12a", false},
|
||||
{"", true}, // Empty string returns true (edge case in implementation)
|
||||
{"1.5", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.s, func(t *testing.T) {
|
||||
got := isNumeric(tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNumeric(%q) = %v, want %v", tt.s, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user