Files
beads/cmd/bd/doctor/database_test.go
Ryan 3c08e5eb9d DOCTOR IMPROVEMENTS: visual improvements/grouping + add comprehensive tests + fix gosec warnings (#656)
* test(doctor): add comprehensive tests for fix and check functions

Add edge case tests, e2e tests, and improve test coverage for:
- database_test.go: database integrity and sync checks
- git_test.go: git hooks, merge driver, sync branch tests
- gitignore_test.go: gitignore validation
- prefix_test.go: ID prefix handling
- fix/fix_test.go: fix operations
- fix/e2e_test.go: end-to-end fix scenarios
- fix/fix_edge_cases_test.go: edge case handling

* docs: add testing philosophy and anti-patterns guide

- Create TESTING_PHILOSOPHY.md covering test pyramid, priority matrix,
  what NOT to test, and 5 anti-patterns with code examples
- Add cross-reference from README_TESTING.md
- Document beads-specific guidance (well-covered areas vs gaps)
- Include target metrics (test-to-code ratio, execution time targets)

* chore: revert .beads/ to upstream/main state

* refactor(doctor): add category grouping and Ayu theme colors

- Add Category field to DoctorCheck for organizing checks by type
- Define category constants: Core, Git, Runtime, Data, Integration, Metadata
- Update thanks command to use shared Ayu color palette from internal/ui
- Simplify test fixtures by removing redundant test cases

* fix(doctor): prevent test fork bomb and fix test failures

- Add ErrTestBinary guard in getBdBinary() to prevent tests from
  recursively executing the test binary when calling bd subcommands
- Update claude_test.go to use new check names (CLI Availability,
  Prime Documentation)
- Fix syncbranch test path comparison by resolving symlinks
  (/var vs /private/var on macOS)
- Fix permissions check to use exact comparison instead of bitmask
- Fix UntrackedJSONL to use git commit --only to preserve staged changes
- Fix MergeDriver edge case test by making both .git dir and config
  read-only
- Add skipIfTestBinary helper for E2E tests that need real bd binary

* test(doctor): skip read-only config test in CI environments

GitHub Actions containers may have CAP_DAC_OVERRIDE or similar
capabilities that allow writing to read-only files, causing
the test to fail. Skip the test when CI=true or GITHUB_ACTIONS=true.
2025-12-20 03:10:06 -08:00

918 lines
24 KiB
Go

package doctor
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
// setupTestDatabase creates a minimal valid SQLite database for testing
func setupTestDatabase(t *testing.T, dir string) string {
t.Helper()
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create minimal issues table
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT,
status TEXT
)`)
if err != nil {
t.Fatalf("failed to create table: %v", err)
}
return dbPath
}
func TestCheckDatabaseIntegrity(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
expectMessage string
}{
{
name: "no database",
setup: func(t *testing.T, dir string) {
// No database file created
},
expectedStatus: "ok",
expectMessage: "N/A (no database)",
},
{
name: "valid database",
setup: func(t *testing.T, dir string) {
setupTestDatabase(t, dir)
},
expectedStatus: "ok",
expectMessage: "No corruption detected",
},
{
name: "corrupt database",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
// Write garbage that isn't a valid SQLite file
if err := os.WriteFile(dbPath, []byte("not a sqlite database"), 0600); err != nil {
t.Fatalf("failed to create corrupt db: %v", err)
}
},
expectedStatus: "error",
expectMessage: "", // message varies based on sqlite driver error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseIntegrity(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q", tt.expectedStatus, check.Status)
}
if tt.expectMessage != "" && check.Message != tt.expectMessage {
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
}
})
}
}
func TestCheckDatabaseJSONLSync(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
expectMessage string
}{
{
name: "no database",
setup: func(t *testing.T, dir string) {
// No database, but create JSONL
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "ok",
expectMessage: "N/A (no database)",
},
{
name: "no JSONL",
setup: func(t *testing.T, dir string) {
setupTestDatabase(t, dir)
},
expectedStatus: "ok",
expectMessage: "N/A (no JSONL file)",
},
{
name: "both exist with same count",
setup: func(t *testing.T, dir string) {
// Create database with one issue
dbPath := setupTestDatabase(t, dir)
db, _ := sql.Open("sqlite3", dbPath)
defer db.Close()
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
// Create JSONL with one issue
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test Issue","status":"open"}`+"\n"), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning", // Warning because db doesn't have full schema for prefix check
expectMessage: "",
},
{
name: "count mismatch",
setup: func(t *testing.T, dir string) {
// Create database with one issue
dbPath := setupTestDatabase(t, dir)
db, _ := sql.Open("sqlite3", dbPath)
defer db.Close()
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
// Create JSONL with two issues
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
content := `{"id":"test-1","title":"Test Issue 1","status":"open"}
{"id":"test-2","title":"Test Issue 2","status":"open"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseJSONLSync(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
if tt.expectMessage != "" && check.Message != tt.expectMessage {
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
}
})
}
}
func TestCheckDatabaseVersion(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
}{
{
name: "fresh clone with JSONL",
setup: func(t *testing.T, dir string) {
// No database but JSONL exists - fresh clone warning
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning", // Warning for fresh clone needing init
},
{
name: "no database no jsonl",
setup: func(t *testing.T, dir string) {
// No database, no JSONL - error (need to run bd init)
},
expectedStatus: "error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseVersion(tmpDir, "0.1.0")
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
})
}
}
func TestCheckSchemaCompatibility(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
}{
{
name: "no database",
setup: func(t *testing.T, dir string) {
// No database created
},
expectedStatus: "ok",
},
{
name: "minimal schema",
setup: func(t *testing.T, dir string) {
// Our minimal test database doesn't have full schema
setupTestDatabase(t, dir)
},
expectedStatus: "error", // Error because schema is incomplete
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckSchemaCompatibility(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
})
}
}
func TestCountJSONLIssues(t *testing.T) {
tests := []struct {
name string
content string
expectedCount int
expectError bool
}{
{
name: "empty file",
content: "",
expectedCount: 0,
expectError: false,
},
{
name: "single issue",
content: `{"id":"test-1","title":"Test"}` + "\n",
expectedCount: 1,
expectError: false,
},
{
name: "multiple issues",
content: `{"id":"test-1","title":"Test 1"}
{"id":"test-2","title":"Test 2"}
{"id":"test-3","title":"Test 3"}
`,
expectedCount: 3,
expectError: false,
},
{
name: "counts all including tombstones",
content: `{"id":"test-1","title":"Test 1","status":"open"}
{"id":"test-2","title":"Test 2","status":"tombstone"}
{"id":"test-3","title":"Test 3","status":"closed"}
`,
expectedCount: 3, // CountJSONLIssues counts all records including tombstones
expectError: false,
},
{
name: "skips empty lines",
content: `{"id":"test-1","title":"Test 1"}
{"id":"test-2","title":"Test 2"}
`,
expectedCount: 2,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(tt.content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, _, err := CountJSONLIssues(jsonlPath)
if tt.expectError && err == nil {
t.Error("expected error, got nil")
}
if !tt.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if count != tt.expectedCount {
t.Errorf("expected count %d, got %d", tt.expectedCount, count)
}
})
}
}
func TestCountJSONLIssues_NonexistentFile(t *testing.T) {
count, _, err := CountJSONLIssues("/nonexistent/path/issues.jsonl")
if err == nil {
t.Error("expected error for nonexistent file")
}
if count != 0 {
t.Errorf("expected count 0, got %d", count)
}
}
func TestCountJSONLIssues_ExtractsPrefixes(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
content := `{"id":"bd-123","title":"Test 1"}
{"id":"bd-456","title":"Test 2"}
{"id":"proj-789","title":"Test 3"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, prefixes, err := CountJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Errorf("expected count 3, got %d", count)
}
// Check prefixes were extracted
if _, ok := prefixes["bd"]; !ok {
t.Error("expected 'bd' prefix to be detected")
}
if _, ok := prefixes["proj"]; !ok {
t.Error("expected 'proj' prefix to be detected")
}
}
// Edge case tests
func TestCheckDatabaseIntegrity_EdgeCases(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string) string
expectedStatus string
}{
{
name: "locked database file",
setup: func(t *testing.T, dir string) string {
dbPath := setupTestDatabase(t, dir)
// Open a connection with an exclusive lock
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Start a transaction to hold a lock
tx, err := db.Begin()
if err != nil {
db.Close()
t.Fatalf("failed to begin transaction: %v", err)
}
// Write some data to ensure the lock is held
_, err = tx.Exec("INSERT INTO issues (id, title, status) VALUES ('lock-test', 'Lock Test', 'open')")
if err != nil {
tx.Rollback()
db.Close()
t.Fatalf("failed to insert test data: %v", err)
}
// Keep the transaction open by returning a cleanup function via test context
t.Cleanup(func() {
tx.Rollback()
db.Close()
})
return dbPath
},
expectedStatus: "ok", // Should still succeed with busy_timeout
},
{
name: "read-only database file",
setup: func(t *testing.T, dir string) string {
dbPath := setupTestDatabase(t, dir)
// Make the database file read-only
if err := os.Chmod(dbPath, 0400); err != nil {
t.Fatalf("failed to chmod database: %v", err)
}
return dbPath
},
expectedStatus: "ok", // Integrity check uses read-only mode
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseIntegrity(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
})
}
}
func TestCheckDatabaseJSONLSync_EdgeCases(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
}{
{
name: "malformed JSONL with some valid entries",
setup: func(t *testing.T, dir string) {
dbPath := setupTestDatabase(t, dir)
db, _ := sql.Open("sqlite3", dbPath)
defer db.Close()
// Insert test issue into database
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test Issue', 'open')`)
// Create JSONL with malformed entries
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
content := `{"id":"test-1","title":"Valid Entry"}
{malformed json without quotes
{"id":"test-2","incomplete
{"id":"test-3","title":"Another Valid Entry"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning", // Should warn about malformed lines
},
{
name: "JSONL with mixed valid and invalid JSON",
setup: func(t *testing.T, dir string) {
setupTestDatabase(t, dir)
// Create JSONL with some invalid JSON lines
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
content := `{"id":"test-1","title":"Valid"}
not json at all
{"id":"test-2","title":"Also Valid"}
{"broken": json}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning",
},
{
name: "JSONL with entries missing id field",
setup: func(t *testing.T, dir string) {
dbPath := setupTestDatabase(t, dir)
db, _ := sql.Open("sqlite3", dbPath)
defer db.Close()
_, _ = db.Exec(`INSERT INTO issues (id, title, status) VALUES ('test-1', 'Test', 'open')`)
// Create JSONL where some entries don't have id field
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
content := `{"id":"test-1","title":"Has ID"}
{"title":"No ID field","status":"open"}
{"id":"test-2","title":"Has ID"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
},
expectedStatus: "warning", // Count mismatch: db has 1, jsonl counts only 2 valid
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseJSONLSync(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
})
}
}
func TestCheckDatabaseVersion_EdgeCases(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
cliVersion string
expectedStatus string
expectMessage string
}{
{
name: "future database version",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create metadata table with future version
_, err = db.Exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)`)
if err != nil {
t.Fatalf("failed to create metadata table: %v", err)
}
_, err = db.Exec(`INSERT INTO metadata (key, value) VALUES ('bd_version', '99.99.99')`)
if err != nil {
t.Fatalf("failed to insert version: %v", err)
}
},
cliVersion: "0.1.0",
expectedStatus: "warning",
expectMessage: "version 99.99.99 (CLI: 0.1.0)",
},
{
name: "database with metadata table but no version",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create metadata table but don't insert version
_, err = db.Exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)`)
if err != nil {
t.Fatalf("failed to create metadata table: %v", err)
}
},
cliVersion: "0.1.0",
expectedStatus: "error",
expectMessage: "Unable to read database version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckDatabaseVersion(tmpDir, tt.cliVersion)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s)", tt.expectedStatus, check.Status, check.Message)
}
if tt.expectMessage != "" && check.Message != tt.expectMessage {
t.Errorf("expected message %q, got %q", tt.expectMessage, check.Message)
}
})
}
}
func TestCheckSchemaCompatibility_EdgeCases(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
expectedStatus string
expectInDetail string
}{
{
name: "partial schema - missing dependencies table",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create only issues table, missing other required tables
_, err = db.Exec(`CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT,
content_hash TEXT,
external_ref TEXT,
compacted_at INTEGER,
close_reason TEXT
)`)
if err != nil {
t.Fatalf("failed to create issues table: %v", err)
}
},
expectedStatus: "error",
expectInDetail: "table:dependencies",
},
{
name: "partial schema - missing columns in issues table",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create issues table missing some required columns
_, err = db.Exec(`CREATE TABLE issues (
id TEXT PRIMARY KEY,
title TEXT
)`)
if err != nil {
t.Fatalf("failed to create issues table: %v", err)
}
// Create other tables to avoid those errors
_, err = db.Exec(`CREATE TABLE dependencies (
issue_id TEXT,
depends_on_id TEXT,
type TEXT
)`)
if err != nil {
t.Fatalf("failed to create dependencies table: %v", err)
}
_, err = db.Exec(`CREATE TABLE child_counters (
parent_id TEXT,
last_child INTEGER
)`)
if err != nil {
t.Fatalf("failed to create child_counters table: %v", err)
}
_, err = db.Exec(`CREATE TABLE export_hashes (
issue_id TEXT,
content_hash TEXT
)`)
if err != nil {
t.Fatalf("failed to create export_hashes table: %v", err)
}
},
expectedStatus: "error",
expectInDetail: "issues.content_hash",
},
{
name: "database with no tables",
setup: func(t *testing.T, dir string) {
dbPath := filepath.Join(dir, ".beads", "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
// Execute a query to ensure the database file is created
_, err = db.Exec("SELECT 1")
if err != nil {
t.Fatalf("failed to initialize database: %v", err)
}
db.Close()
},
expectedStatus: "error",
expectInDetail: "table:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
tt.setup(t, tmpDir)
check := CheckSchemaCompatibility(tmpDir)
if check.Status != tt.expectedStatus {
t.Errorf("expected status %q, got %q (message: %s, detail: %s)",
tt.expectedStatus, check.Status, check.Message, check.Detail)
}
if tt.expectInDetail != "" && !strings.Contains(check.Detail, tt.expectInDetail) {
t.Errorf("expected detail to contain %q, got %q", tt.expectInDetail, check.Detail)
}
})
}
}
func TestCountJSONLIssues_EdgeCases(t *testing.T) {
tests := []struct {
name string
setupContent func() string
expectedCount int
expectError bool
errorContains string
}{
{
name: "malformed JSON lines",
setupContent: func() string {
return `{"id":"valid-1","title":"Valid"}
{this is not json
{"id":"valid-2","title":"Also Valid"}
{malformed: true}
{"id":"valid-3","title":"Third Valid"}
`
},
expectedCount: 3,
expectError: true,
errorContains: "malformed",
},
{
name: "very large file with 10000 issues",
setupContent: func() string {
var sb strings.Builder
for i := 0; i < 10000; i++ {
sb.WriteString(fmt.Sprintf(`{"id":"issue-%d","title":"Issue %d","status":"open"}`, i, i))
sb.WriteString("\n")
}
return sb.String()
},
expectedCount: 10000,
expectError: false,
},
{
name: "file with unicode and special characters",
setupContent: func() string {
return `{"id":"test-1","title":"Issue with émojis 🎉","description":"Unicode: 日本語"}
{"id":"test-2","title":"Quotes \"escaped\" and 'mixed'","status":"open"}
{"id":"test-3","title":"Newlines\nand\ttabs","status":"closed"}
`
},
expectedCount: 3,
expectError: false,
},
{
name: "file with trailing whitespace",
setupContent: func() string {
return `{"id":"test-1","title":"Test"}
{"id":"test-2","title":"Test 2"}
{"id":"test-3","title":"Test 3"}
`
},
expectedCount: 3,
expectError: false,
},
{
name: "all lines are malformed",
setupContent: func() string {
return `not json
also not json
{still: not valid}
`
},
expectedCount: 0,
expectError: true,
errorContains: "malformed",
},
{
name: "valid JSON but missing id in all entries",
setupContent: func() string {
return `{"title":"No ID 1","status":"open"}
{"title":"No ID 2","status":"closed"}
{"title":"No ID 3","status":"pending"}
`
},
expectedCount: 0,
expectError: false,
},
{
name: "entries with numeric ids",
setupContent: func() string {
return `{"id":123,"title":"Numeric ID"}
{"id":"valid-1","title":"String ID"}
{"id":null,"title":"Null ID"}
`
},
expectedCount: 1, // Only the string ID counts
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
content := tt.setupContent()
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, _, err := CountJSONLIssues(jsonlPath)
if tt.expectError && err == nil {
t.Error("expected error, got nil")
}
if !tt.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tt.expectError && err != nil && tt.errorContains != "" {
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("expected error to contain %q, got %q", tt.errorContains, err.Error())
}
}
if count != tt.expectedCount {
t.Errorf("expected count %d, got %d", tt.expectedCount, count)
}
})
}
}
func TestCountJSONLIssues_Performance(t *testing.T) {
if testing.Short() {
t.Skip("skipping performance test in short mode")
}
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "large.jsonl")
// Create a very large JSONL file (100k issues)
file, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("failed to create file: %v", err)
}
for i := 0; i < 100000; i++ {
line := fmt.Sprintf(`{"id":"perf-%d","title":"Performance Test Issue %d","status":"open","description":"Testing performance with large files"}`, i, i)
if _, err := file.WriteString(line + "\n"); err != nil {
file.Close()
t.Fatalf("failed to write line: %v", err)
}
}
file.Close()
// Measure time to count issues
start := time.Now()
count, prefixes, err := CountJSONLIssues(jsonlPath)
duration := time.Since(start)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if count != 100000 {
t.Errorf("expected count 100000, got %d", count)
}
if len(prefixes) != 1 || prefixes["perf"] != 100000 {
t.Errorf("expected single prefix 'perf' with count 100000, got %v", prefixes)
}
// Performance should be reasonable (< 5 seconds for 100k issues)
if duration > 5*time.Second {
t.Logf("Warning: counting 100k issues took %v (expected < 5s)", duration)
} else {
t.Logf("Performance: counted 100k issues in %v", duration)
}
}