package parser import ( "database/sql" "os" "path/filepath" "testing" "time" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Cursor owns and actively writes ai-code-tracking.db; agentsview must // never hold a writable handle on it. func TestOpenCursorAttributionDB_ReadOnly(t *testing.T) { path := seedCursorAttributionDBTest(t) conn, err := openCursorAttributionDB(path) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) _, err = conn.Exec( `INSERT INTO conversation_summaries (model, mode, updatedAt) VALUES ('m', 'tab', 0)`, ) require.ErrorContains(t, err, "readonly database", "attribution db handle must reject writes") } func TestOpenCursorAttributionDB_DoesNotCreateMissingDB(t *testing.T) { missing := filepath.Join(t.TempDir(), "missing.db") conn, err := openCursorAttributionDB(missing) if err == nil { _ = conn.Close() } require.Error(t, err, "read-only open of a missing db must fail, not create it") _, statErr := os.Stat(missing) assert.True(t, os.IsNotExist(statErr), "open must not create the attribution db file") } func TestLoadCursorAttribution_Happy(t *testing.T) { path := seedCursorAttributionDBTest(t) t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB", path) from := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) got, status, err := LoadCursorAttribution(from, to) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, CursorAttributionAvailable, status) assert.Equal(t, int64(2), got.ScoredCommits) assert.Equal(t, int64(18), got.LinesAdded) assert.Equal(t, int64(6), got.LinesDeleted) assert.Equal(t, int64(6), got.TabLinesAdded) assert.Equal(t, int64(4), got.ComposerLinesAdded) assert.Equal(t, int64(5), got.HumanLinesAdded) assert.Equal(t, int64(3), got.BlankLinesAdded) assert.InDelta(t, 10.0/18.0, got.AIAuthoredPct, 1e-9) require.Len(t, got.ConversationCounts, 2) assert.Equal(t, "model-a", got.ConversationCounts[0].Model) assert.Equal(t, "composer", got.ConversationCounts[0].Mode) assert.Equal(t, int64(3), got.ConversationCounts[0].Count) } func TestLoadCursorAttribution_MissingDB(t *testing.T) { missing := filepath.Join(t.TempDir(), "missing.db") t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB", missing) got, status, err := LoadCursorAttribution( time.Unix(0, 0).UTC(), time.Unix(0, 1).UTC(), ) require.NoError(t, err) assert.Nil(t, got) assert.Equal(t, CursorAttributionUnavailable, status) } func TestLoadCursorAttribution_EmptyDB(t *testing.T) { path := seedEmptyCursorAttributionDBTest(t) t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB", path) got, status, err := LoadCursorAttribution( time.Unix(0, 0).UTC(), time.Unix(0, 1).UTC(), ) require.NoError(t, err) assert.Nil(t, got) assert.Equal(t, CursorAttributionEmpty, status) } func TestLoadCursorAttribution_NormalizesEmptyConversationKeys(t *testing.T) { path := seedCursorAttributionDBTest(t) t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB", path) conn, err := sql.Open("sqlite3", path) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) ts := time.Date(2026, 6, 1, 14, 0, 0, 0, time.UTC).UnixMilli() _, err = conn.Exec(` INSERT INTO conversation_summaries (model, mode, updatedAt) VALUES (NULL, NULL, ?), ('', '', ?) `, ts, ts) require.NoError(t, err) got, status, err := LoadCursorAttribution( time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC), ) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, CursorAttributionAvailable, status) emptyRows := 0 for _, entry := range got.ConversationCounts { if entry.Model == "" && entry.Mode == "" { emptyRows++ assert.Equal(t, int64(2), entry.Count) } } assert.Equal(t, 1, emptyRows) } func seedCursorAttributionDBTest(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ai-code-tracking.db") conn, err := sql.Open("sqlite3", path) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) _, err = conn.Exec(` CREATE TABLE scored_commits ( commitHash TEXT PRIMARY KEY, scoredAt INTEGER NOT NULL, commitDate TEXT NOT NULL, linesAdded INTEGER NOT NULL DEFAULT 0, linesDeleted INTEGER NOT NULL DEFAULT 0, tabLinesAdded INTEGER NOT NULL DEFAULT 0, tabLinesDeleted INTEGER NOT NULL DEFAULT 0, composerLinesAdded INTEGER NOT NULL DEFAULT 0, composerLinesDeleted INTEGER NOT NULL DEFAULT 0, humanLinesAdded INTEGER NOT NULL DEFAULT 0, humanLinesDeleted INTEGER NOT NULL DEFAULT 0, blankLinesAdded INTEGER NOT NULL DEFAULT 0, blankLinesDeleted INTEGER NOT NULL DEFAULT 0 ) `) require.NoError(t, err) _, err = conn.Exec(` CREATE TABLE conversation_summaries ( model TEXT, mode TEXT, updatedAt INTEGER NOT NULL ) `) require.NoError(t, err) first := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC).UnixMilli() second := time.Date(2026, 6, 1, 13, 0, 0, 0, time.UTC).UnixMilli() insideA := time.Date(2026, 6, 1, 8, 0, 0, 0, time.FixedZone("EDT", -4*60*60)) insideB := time.Date(2026, 6, 3, 10, 0, 0, 0, time.FixedZone("EDT", -4*60*60)) outside := time.Date(2026, 6, 1, 15, 0, 0, 0, time.FixedZone("EDT", -4*60*60)) _, err = conn.Exec(` INSERT INTO scored_commits ( commitHash, scoredAt, commitDate, linesAdded, linesDeleted, tabLinesAdded, tabLinesDeleted, composerLinesAdded, composerLinesDeleted, humanLinesAdded, humanLinesDeleted, blankLinesAdded, blankLinesDeleted ) VALUES ('c1', ?, ?, 12, 4, 6, 1, 3, 1, 1, 2, 2, 0), ('c2', ?, ?, 6, 2, 0, 0, 1, 0, 4, 1, 1, 0), ('c3', ?, ?, 99, 1, 50, 0, 40, 0, 9, 0, 0, 0) `, first, formatCursorCommitDate(insideA), second, formatCursorCommitDate(insideB), time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC).UnixMilli(), formatCursorCommitDate(outside), ) require.NoError(t, err) _, err = conn.Exec(` INSERT INTO conversation_summaries (model, mode, updatedAt) VALUES ('model-a', 'composer', ?), ('model-a', 'composer', ?), ('model-a', 'composer', ?), ('model-a', 'tab', ?) `, first, second, second, second) require.NoError(t, err) return path } func seedEmptyCursorAttributionDBTest(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ai-code-tracking.db") conn, err := sql.Open("sqlite3", path) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) _, err = conn.Exec(` CREATE TABLE scored_commits ( commitHash TEXT PRIMARY KEY, scoredAt INTEGER NOT NULL, commitDate TEXT NOT NULL, linesAdded INTEGER NOT NULL DEFAULT 0, linesDeleted INTEGER NOT NULL DEFAULT 0, tabLinesAdded INTEGER NOT NULL DEFAULT 0, tabLinesDeleted INTEGER NOT NULL DEFAULT 0, composerLinesAdded INTEGER NOT NULL DEFAULT 0, composerLinesDeleted INTEGER NOT NULL DEFAULT 0, humanLinesAdded INTEGER NOT NULL DEFAULT 0, humanLinesDeleted INTEGER NOT NULL DEFAULT 0, blankLinesAdded INTEGER NOT NULL DEFAULT 0, blankLinesDeleted INTEGER NOT NULL DEFAULT 0 ) `) require.NoError(t, err) _, err = conn.Exec(` CREATE TABLE conversation_summaries ( model TEXT, mode TEXT, updatedAt INTEGER NOT NULL ) `) require.NoError(t, err) return path } func formatCursorCommitDate(t time.Time) string { return t.Format("Mon Jan 2 15:04:05 2006 -0700") }