Files
byre/internal/store/migrate_internal_test.go
T

270 lines
8.1 KiB
Go
Raw Normal View History

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)
}
}
2026-10-14 20:07:26 -07:00
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)
}
}
}