package store import ( "database/sql" "errors" "os" "path/filepath" "testing" ) // readonly_test.go pins the OpenReadOnly contract (пакет №4): no flock (status // works during a live run), hard read-only connections, loud errors on a missing // DB and on a schema the binary does not match. // TestOpenReadOnlyConcurrentWithWriter is the headline: a READ-ONLY open must // succeed while a WRITER owns the project (the exclusive flock is held), reads // must see the writer's committed rows, and a stray write through the read-only // store must fail loudly instead of corrupting the live writer's state. func TestOpenReadOnlyConcurrentWithWriter(t *testing.T) { path := filepath.Join(t.TempDir(), "book.db") w, err := Open(path) if err != nil { t.Fatal(err) } defer w.Close() if err := w.InsertRequestLog(RequestLog{BookID: "b1", Chapter: 1, Stage: "draft", Role: "translator", OK: true}); err != nil { t.Fatal(err) } // Writer still open (flock held) — the old Open here would fail with // "project database is in use by another tmctl process". ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("read-only open must not need the writer's flock: %v", err) } defer ro.Close() rows, err := ro.RequestLogRows("b1") if err != nil { t.Fatal(err) } if len(rows) != 1 || rows[0].Stage != "draft" { t.Fatalf("read-only store must see the writer's committed rows, got %+v", rows) } // A stray write must be a loud SQLITE_READONLY error, never a silent success // (the read-only Store aliases its write pool to the query_only connections). if err := ro.InsertRequestLog(RequestLog{BookID: "b1", Chapter: 2, Stage: "edit"}); err == nil { t.Fatal("a write through the read-only store must fail") } // The writer keeps working while the reader is open (WAL concurrent access). if err := w.InsertRequestLog(RequestLog{BookID: "b1", Chapter: 2, Stage: "edit", Role: "editor", OK: true}); err != nil { t.Fatalf("writer must not be disturbed by an open reader: %v", err) } rows, err = ro.RequestLogRows("b1") if err != nil { t.Fatal(err) } if len(rows) != 2 { t.Fatalf("reader must see the writer's new committed row, got %d rows", len(rows)) } } // TestOpenReadOnlyMissingDB: a read of a never-run project must not silently // create an empty database — the error wraps os.ErrNotExist so the runner can // fall back to the creating Open on first touch. func TestOpenReadOnlyMissingDB(t *testing.T) { _, err := OpenReadOnly(filepath.Join(t.TempDir(), "nope.db")) if err == nil { t.Fatal("read-only open of a missing DB must fail") } if !errors.Is(err, os.ErrNotExist) { t.Fatalf("error must wrap os.ErrNotExist for the first-touch fallback, got: %v", err) } } // TestOpenReadOnlySchemaMismatch: read-only open never migrates, so a database // behind (or ahead of) this binary's schema is a loud, actionable error — not a // query that fails later on a missing column. func TestOpenReadOnlySchemaMismatch(t *testing.T) { path := filepath.Join(t.TempDir(), "book.db") w, err := Open(path) if err != nil { t.Fatal(err) } w.Close() // Roll the recorded version back one step (simulates a DB from an older binary). db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`DELETE FROM schema_version WHERE version = (SELECT MAX(version) FROM schema_version)`); err != nil { t.Fatal(err) } db.Close() if _, err := OpenReadOnly(path); err == nil { t.Fatal("read-only open of an older-schema DB must fail loudly (it never migrates)") } // Ahead of the binary: an unknown future version must fail too. db, err = sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?), (?)`, len(migrations), len(migrations)+7); err != nil { t.Fatal(err) } db.Close() if _, err := OpenReadOnly(path); err == nil { t.Fatal("read-only open of a newer-schema DB must fail loudly") } }