package store import ( "database/sql" "path/filepath" "slices" "testing" ) func openTestDB(t *testing.T, path string) *sql.DB { t.Helper() db, err := sql.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)&_txlock=immediate") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) return db } func appliedNames(t *testing.T, db *sql.DB) []string { t.Helper() rows, err := db.Query(`SELECT name FROM migrations ORDER BY rowid`) if err != nil { t.Fatalf("list migrations: %v", err) } defer rows.Close() names := []string{} for rows.Next() { name := "" err = rows.Scan(&name) if err != nil { t.Fatalf("scan migration: %v", err) } names = append(names, name) } if rows.Err() != nil { t.Fatalf("list migrations: %v", rows.Err()) } return names } func migrationNames(ms []migration) []string { names := []string{} for _, m := range ms { names = append(names, m.name) } return names } func insertReport(t *testing.T, db *sql.DB, id, payload string) { t.Helper() _, err := db.Exec(` INSERT OR IGNORE INTO users (login, display_name, created_at) VALUES ('alice', 'Alice', 'x'); INSERT OR IGNORE INTO projects (id, name, repo_path, default_branch, remote, created_by, created_at) VALUES ('p1', 'demo', '/nonexistent', 'main', 'none', 'alice', 'x'); INSERT OR IGNORE INTO tasks (id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at) VALUES ('t1', 'p1', 't1', 't1', '/nonexistent', 'x', 'active', 'alice', 'x'); INSERT OR IGNORE INTO agent_sessions (id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at) VALUES ('s1', 't1', 'claude', '', 's1', 'idle', 'implementer', 'alice', 'x'); `) if err != nil { t.Fatalf("insert parents: %v", err) } _, err = db.Exec(` INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at) VALUES (?, 'p1', 't1', 's1', ?, 'report', 0, 0, ?, 'open', 'x') `, id, id, payload) if err != nil { t.Fatalf("insert report: %v", err) } } func reportPayload(t *testing.T, db *sql.DB, id string) string { t.Helper() payload := "" err := db.QueryRow(`SELECT payload FROM inbox_items WHERE id = ?`, id).Scan(&payload) if err != nil { t.Fatalf("read report: %v", err) } return payload } func TestMigrateFreshDatabase(t *testing.T) { db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db")) err := migrate(db, migrations) if err != nil { t.Fatalf("migrate: %v", err) } err = migrate(db, migrations) if err != nil { t.Fatalf("migrate again: %v", err) } got := appliedNames(t, db) want := migrationNames(migrations) if !slices.Equal(got, want) { t.Fatalf("applied %v, want %v", got, want) } } func TestMigrateFromUserVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "byre.db") db := openTestDB(t, path) for _, m := range migrations[:9] { _, err := db.Exec(m.sql) if err != nil { t.Fatalf("old migration %s: %v", m.name, err) } } _, err := db.Exec(`PRAGMA user_version = 9`) if err != nil { t.Fatalf("set user_version: %v", err) } insertReport(t, db, "r1", `{"text":"- shipped"}`) db.Close() db = openTestDB(t, path) err = migrate(db, migrations) if err != nil { t.Fatalf("migrate: %v", err) } got := appliedNames(t, db) want := migrationNames(migrations) if !slices.Equal(got, want) { t.Fatalf("applied %v, want %v", got, want) } payload := reportPayload(t, db, "r1") if payload != `{"text":"- shipped"}` { t.Fatalf("report payload %s, want it unchanged", payload) } } func TestMigrateRunsMigrationAddedBeforeApplied(t *testing.T) { db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db")) first := migration{"create-a", `CREATE TABLE a (x TEXT)`} other := migration{"create-b", `CREATE TABLE b (x TEXT)`} last := migration{"create-c", `CREATE TABLE c (x TEXT)`} err := migrate(db, []migration{first, last}) if err != nil { t.Fatalf("migrate: %v", err) } err = migrate(db, []migration{first, other, last}) if err != nil { t.Fatalf("migrate with other branch: %v", err) } got := appliedNames(t, db) want := []string{"create-a", "create-c", "create-b"} if !slices.Equal(got, want) { t.Fatalf("applied %v, want %v", got, want) } _, err = db.Exec(`INSERT INTO b (x) VALUES ('y')`) if err != nil { t.Fatalf("table from other branch: %v", err) } } func TestBlobsMigrationOverPositionalCopy(t *testing.T) { db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db")) err := migrate(db, migrations[:9]) if err != nil { t.Fatalf("migrate: %v", err) } insertReport(t, db, "r1", `{"text":"x"}`) _, err = db.Exec(` CREATE TABLE blobs (id TEXT PRIMARY KEY, content_type TEXT NOT NULL, path TEXT NOT NULL, created_by TEXT NOT NULL REFERENCES users(login), created_at TEXT NOT NULL); INSERT INTO blobs VALUES ('b1', 'image/png', 'blobs/b1', 'alice', 'x'); ALTER TABLE turn_queue ADD COLUMN images TEXT NOT NULL DEFAULT '[]'; INSERT INTO turn_queue (session_id, actor, text, created_at) VALUES ('s1', 'alice', 'queued', 'x'); `) if err != nil { t.Fatalf("positional copy: %v", err) } err = migrate(db, migrations) if err != nil { t.Fatalf("migrate: %v", err) } text := "" images := "" err = db.QueryRow(`SELECT text, images FROM turn_queue WHERE session_id = 's1'`).Scan(&text, &images) if err != nil { t.Fatalf("queued turn: %v", err) } if text != "queued" || images != "[]" { t.Fatalf("queued turn %q %q", text, images) } blobs := 0 err = db.QueryRow(`SELECT count(*) FROM blobs`).Scan(&blobs) if err != nil || blobs != 1 { t.Fatalf("blobs %d: %v", blobs, err) } } func TestInlineQuestionChoicesMigration(t *testing.T) { db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db")) i := slices.IndexFunc(migrations, func(m migration) bool { return m.name == "inline-question-choices" }) err := migrate(db, migrations[:i]) if err != nil { t.Fatalf("migrate: %v", err) } insertReport(t, db, "r1", `{"text":"x"}`) _, err = db.Exec(` INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at) VALUES ('q1', 'p1', 't1', 's1', 'q1', 'question', 0, 1, '{"context":"Pick a color.","question":"Which?","why_blocked":"w","options":["red","blue"],"recommended_option":"blue","attachments":[]}', 'answered', 'x'); INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a1', 'q1', 'alice', 'option', 'red', NULL, '[]', 'x'); INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a2', 'q1', 'alice', 'other', NULL, 'green', '[]', 'x'); `) if err != nil { t.Fatalf("insert question: %v", err) } err = migrate(db, migrations) if err != nil { t.Fatalf("migrate: %v", err) } payload := reportPayload(t, db, "q1") want := `{"context":"Pick a color.\n\n- ( ) red\n- (*) blue","question":"Which?","why_blocked":"w","options":["red","blue"],"attachments":[],"multiple":false,"recommended":["blue"]}` if payload != want { t.Fatalf("question payload\n got %s\nwant %s", payload, want) } if reportPayload(t, db, "r1") != `{"text":"x"}` { t.Fatalf("report payload changed") } options := map[string]sql.NullString{} rows, err := db.Query(`SELECT id, options FROM inbox_responses`) if err != nil { t.Fatalf("responses: %v", err) } defer rows.Close() for rows.Next() { id := "" o := sql.NullString{} err = rows.Scan(&id, &o) if err != nil { t.Fatalf("scan: %v", err) } options[id] = o } if options["a1"].String != `["red"]` || options["a2"].Valid { t.Fatalf("response options %+v", options) } } func TestFlattenReportPayloadsRunsTwice(t *testing.T) { db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db")) err := migrate(db, migrations) if err != nil { t.Fatalf("migrate: %v", err) } insertReport(t, db, "r1", `{"done":["one","two"]}`) i := slices.IndexFunc(migrations, func(m migration) bool { return m.name == "flatten-report-payloads-to-text" }) for range 2 { _, err = db.Exec(migrations[i].sql) if err != nil { t.Fatalf("flatten: %v", err) } payload := reportPayload(t, db, "r1") if payload != `{"text":"- one\n- two"}` { t.Fatalf("report payload %s", payload) } } }