package main import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) func TestExportImport(t *testing.T) { // Create temp directory for test database tmpDir, err := os.MkdirTemp("", "bd-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "test.db") // Create test database with sample issues store, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } ctx := context.Background() // Create test issues issues := []*types.Issue{ { ID: "test-1", Title: "First issue", Description: "Description 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, { ID: "test-2", Title: "Second issue", Description: "Description 2", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeFeature, Assignee: "alice", CreatedAt: time.Now(), UpdatedAt: time.Now(), }, { ID: "test-3", Title: "Third issue", Description: "Description 3", Status: types.StatusClosed, Priority: 3, IssueType: types.TypeTask, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, } for _, issue := range issues { if err := store.CreateIssue(ctx, issue, "test"); err != nil { t.Fatalf("Failed to create issue: %v", err) } } // Test export t.Run("Export", func(t *testing.T) { exported, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } if len(exported) != 3 { t.Errorf("Expected 3 issues, got %d", len(exported)) } // Verify issues are sorted by ID for i := 0; i < len(exported)-1; i++ { if exported[i].ID > exported[i+1].ID { t.Errorf("Issues not sorted by ID: %s > %s", exported[i].ID, exported[i+1].ID) } } }) // Test JSONL format t.Run("JSONL Format", func(t *testing.T) { exported, _ := store.SearchIssues(ctx, "", types.IssueFilter{}) var buf bytes.Buffer encoder := json.NewEncoder(&buf) for _, issue := range exported { if err := encoder.Encode(issue); err != nil { t.Fatalf("Failed to encode issue: %v", err) } } // Verify each line is valid JSON lines := strings.Split(strings.TrimSpace(buf.String()), "\n") if len(lines) != 3 { t.Errorf("Expected 3 JSONL lines, got %d", len(lines)) } for i, line := range lines { var issue types.Issue if err := json.Unmarshal([]byte(line), &issue); err != nil { t.Errorf("Line %d is not valid JSON: %v", i, err) } } }) // Test import into new database t.Run("Import", func(t *testing.T) { // Export from original database exported, _ := store.SearchIssues(ctx, "", types.IssueFilter{}) // Create new database newDBPath := filepath.Join(tmpDir, "import-test.db") newStore, err := sqlite.New(newDBPath) if err != nil { t.Fatalf("Failed to create new storage: %v", err) } // Import issues for _, issue := range exported { if err := newStore.CreateIssue(ctx, issue, "import"); err != nil { t.Fatalf("Failed to import issue: %v", err) } } // Verify imported issues imported, err := newStore.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } if len(imported) != len(exported) { t.Errorf("Expected %d issues, got %d", len(exported), len(imported)) } // Verify issue data for i := range imported { if imported[i].ID != exported[i].ID { t.Errorf("Issue %d: ID = %s, want %s", i, imported[i].ID, exported[i].ID) } if imported[i].Title != exported[i].Title { t.Errorf("Issue %d: Title = %s, want %s", i, imported[i].Title, exported[i].Title) } } }) // Test update on import t.Run("Import Update", func(t *testing.T) { // Get first issue issue, err := store.GetIssue(ctx, "test-1") if err != nil { t.Fatalf("GetIssue failed: %v", err) } // Modify it issue.Title = "Updated title" issue.Status = types.StatusClosed // Import as update updates := map[string]interface{}{ "title": issue.Title, "status": issue.Status, } if err := store.UpdateIssue(ctx, issue.ID, updates, "test"); err != nil { t.Fatalf("UpdateIssue failed: %v", err) } // Verify update updated, err := store.GetIssue(ctx, "test-1") if err != nil { t.Fatalf("GetIssue failed: %v", err) } if updated.Title != "Updated title" { t.Errorf("Title = %s, want 'Updated title'", updated.Title) } if updated.Status != types.StatusClosed { t.Errorf("Status = %s, want %s", updated.Status, types.StatusClosed) } }) // Test filtering on export t.Run("Export with Filter", func(t *testing.T) { status := types.StatusOpen filter := types.IssueFilter{ Status: &status, } filtered, err := store.SearchIssues(ctx, "", filter) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } // Should only get open issues (test-1 might be updated, so check count > 0) for _, issue := range filtered { if issue.Status != types.StatusOpen { t.Errorf("Expected only open issues, got %s", issue.Status) } } }) } func TestExportEmpty(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "empty.db") store, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } ctx := context.Background() // Export from empty database issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } if len(issues) != 0 { t.Errorf("Expected 0 issues, got %d", len(issues)) } } func TestImportInvalidJSON(t *testing.T) { invalidJSON := []string{ `{"id":"test-1"`, // Incomplete JSON `{"id":"test-1","title":}`, // Invalid syntax `not json at all`, // Not JSON `{"id":"","title":"No ID"}`, // Empty ID } for i, line := range invalidJSON { var issue types.Issue err := json.Unmarshal([]byte(line), &issue) if err == nil && line != invalidJSON[3] { // Empty ID case will unmarshal but fail validation t.Errorf("Case %d: Expected unmarshal error for invalid JSON: %s", i, line) } } } func TestRoundTrip(t *testing.T) { // Create original database tmpDir, err := os.MkdirTemp("", "bd-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "original.db") store, err := sqlite.New(dbPath) if err != nil { t.Fatalf("Failed to create storage: %v", err) } ctx := context.Background() // Create issue with all fields populated estimatedMinutes := 120 closedAt := time.Now() original := &types.Issue{ ID: "test-1", Title: "Full issue", Description: "Description", Design: "Design doc", AcceptanceCriteria: "Criteria", Notes: "Notes", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeFeature, Assignee: "alice", EstimatedMinutes: &estimatedMinutes, CreatedAt: time.Now(), UpdatedAt: time.Now(), ClosedAt: &closedAt, } if err := store.CreateIssue(ctx, original, "test"); err != nil { t.Fatalf("Failed to create issue: %v", err) } // Export to JSONL var buf bytes.Buffer encoder := json.NewEncoder(&buf) if err := encoder.Encode(original); err != nil { t.Fatalf("Failed to encode: %v", err) } // Import from JSONL var decoded types.Issue if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { t.Fatalf("Failed to decode: %v", err) } // Verify all fields preserved if decoded.ID != original.ID { t.Errorf("ID = %s, want %s", decoded.ID, original.ID) } if decoded.Title != original.Title { t.Errorf("Title = %s, want %s", decoded.Title, original.Title) } if decoded.Description != original.Description { t.Errorf("Description = %s, want %s", decoded.Description, original.Description) } if decoded.EstimatedMinutes == nil || *decoded.EstimatedMinutes != *original.EstimatedMinutes { t.Errorf("EstimatedMinutes = %v, want %v", decoded.EstimatedMinutes, original.EstimatedMinutes) } }