textmachine/backend/internal/store/readonly_test.go

115 lines
3.9 KiB
Go

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")
}
}