429 lines
18 KiB
Go
429 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"testing"
|
||
"time"
|
||
|
||
"textmachine/backend/internal/pipeline"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// migrate_cli_test.go is the CLI half of backlog row 174: the deploy deadlock is a SHELL fact — the
|
||
// platform learns what happened from an exit code and a line of stderr — so the contract is exercised
|
||
// where it is a contract. The migration mechanics themselves are pinned generically in
|
||
// internal/store/migrate_test.go.
|
||
|
||
// headOfSchemaAddedColumns are the columns the NEWEST migration adds. Rolling a project back one
|
||
// vintage has to take them with it: a database an older engine binary left behind does not have them,
|
||
// and a fixture that left them in place would build a state the real migrator cannot produce — the step
|
||
// and its version row commit in ONE transaction (store.applyStep), so «the DDL is there and the version
|
||
// is not» never happens outside a test.
|
||
//
|
||
// The list is coupled to the head of store.migrations deliberately. An ALTER step at the head is the
|
||
// case backlog row 49а names (those steps are not re-appliable), and a fixture that ignored it would
|
||
// re-apply the step onto its own effect and fail on a duplicate column instead of on the property the
|
||
// test is about.
|
||
var headOfSchemaAddedColumns = []string{"request_log.reasoning_in_completion"}
|
||
|
||
// headOfSchemaVersion is the migration the list above DESCRIBES. It is asserted rather than assumed,
|
||
// because a stale list does not fail where it is wrong: with a newer head the roll-back would drop this
|
||
// column while deleting the NEWER version row, so the migrator would re-apply the new step and never
|
||
// restore the old column — the "DDL missing while the version says present" state the fixture exists to
|
||
// avoid, surfacing later inside some unrelated test. The assertion turns that into one sentence here.
|
||
const headOfSchemaVersion = 18
|
||
|
||
// staleTheProjectSchema rolls the project database back one vintage — the recorded version AND the
|
||
// newest step's own columns — which is what an older engine binary left behind.
|
||
//
|
||
// These tests are about the exit codes and the output, so the roll-back is done here rather than with
|
||
// the deep fixture (the migration chain truncated to an older vintage), which is only reachable from
|
||
// inside the store package where those properties are tested.
|
||
func staleTheProjectSchema(t *testing.T, dbPath string) int {
|
||
t.Helper()
|
||
if got := store.SchemaHead(); got != headOfSchemaVersion {
|
||
t.Fatalf("the migration head moved to %d, and headOfSchemaAddedColumns still describes %d: "+
|
||
"add migration %d's own columns to that list (or empty it if the step adds none) and bump "+
|
||
"headOfSchemaVersion — otherwise this fixture rolls back the wrong vintage",
|
||
got, headOfSchemaVersion, got)
|
||
}
|
||
db, err := sql.Open("sqlite", "file:"+dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.Close()
|
||
if _, err := db.Exec(`DELETE FROM schema_version WHERE version = (SELECT MAX(version) FROM schema_version)`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, col := range headOfSchemaAddedColumns {
|
||
table, name, ok := strings.Cut(col, ".")
|
||
if !ok {
|
||
t.Fatalf("headOfSchemaAddedColumns entry %q must be table.column", col)
|
||
}
|
||
if _, err := db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN ` + name); err != nil {
|
||
t.Fatalf("roll %s back one vintage: %v", col, err)
|
||
}
|
||
}
|
||
return schemaVersionOf(t, dbPath)
|
||
}
|
||
|
||
// schemaVersionOf reads the recorded schema version through its own connection — the state under test
|
||
// must be asserted independently of the product code that reports it.
|
||
func schemaVersionOf(t *testing.T, dbPath string) int {
|
||
t.Helper()
|
||
db, err := sql.Open("sqlite", "file:"+dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.Close()
|
||
var v int
|
||
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// holdTheProject takes the project's exclusive flock the way another tmctl process would, WITHOUT
|
||
// opening the store — because a write open would migrate the database, and the state under test is a
|
||
// project that is both stale and held.
|
||
func holdTheProject(t *testing.T, dbPath string) {
|
||
t.Helper()
|
||
f, err := os.OpenFile(dbPath+".lock", os.O_CREATE|os.O_RDWR, 0o644)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||
f.Close()
|
||
})
|
||
}
|
||
|
||
func projectDBOf(bookPath string) string {
|
||
return filepath.Join(filepath.Dir(bookPath), "cli-book.db")
|
||
}
|
||
|
||
func backupsOf(t *testing.T, dbPath string) []string {
|
||
t.Helper()
|
||
entries, err := os.ReadDir(backupDirFor(dbPath))
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var names []string
|
||
for _, e := range entries {
|
||
names = append(names, e.Name())
|
||
}
|
||
return names
|
||
}
|
||
|
||
// mockProvider answers every call with a usable draft, so a `translate` really settles money.
|
||
func mockProvider(t *testing.T) *httptest.Server {
|
||
t.Helper()
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.ReadAll(r.Body)
|
||
fmt.Fprint(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":"ЧЕРНОВИК ПЕРЕВОДА"},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500}}`)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return srv
|
||
}
|
||
|
||
// TestTheDeployDeadlockIsBrokenAtTheShell drives the real binary through the exact sequence the
|
||
// platform meets on an engine upgrade: `status --json` before a spawn refuses the older schema, and the
|
||
// contract must make that refusal actionable — its own number inside the refusal band, and the two
|
||
// versions machine-readable — so the caller can migrate and start the run instead of stopping the world.
|
||
func TestTheDeployDeadlockIsBrokenAtTheShell(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("builds and runs the binary")
|
||
}
|
||
bin := buildTmctl(t)
|
||
bookPath := setupCLIProject(t, mockProvider(t).URL)
|
||
dbPath := projectDBOf(bookPath)
|
||
|
||
// A book an older binary left behind.
|
||
st, err := store.Open(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st.Close()
|
||
from := staleTheProjectSchema(t, dbPath)
|
||
|
||
var stderr bytes.Buffer
|
||
status := exec.Command(bin, "status", "--config", bookPath, "--json")
|
||
status.Stderr = &stderr
|
||
if got := exitCodeOf(t, status.Run()); got != exitSchemaMismatch {
|
||
t.Fatalf("`status --json` on an older schema exited %d, want %d", got, exitSchemaMismatch)
|
||
}
|
||
// The number says WHAT; these two say which way and how far — without them a caller cannot tell the
|
||
// direction a migration repairs from the one where it would loop forever.
|
||
want := fmt.Sprintf("schema_mismatch found=%d expected=%d", from, store.SchemaHead())
|
||
if !strings.Contains(stderr.String(), want) {
|
||
t.Fatalf("the refusal must carry %q machine-readably; got:\n%s", want, stderr.String())
|
||
}
|
||
if exitSchemaMismatch < refusalFirst || exitSchemaMismatch > refusalLast {
|
||
t.Fatalf("%d is outside the refusal band [%d,%d] every consumer keys on", exitSchemaMismatch, refusalFirst, refusalLast)
|
||
}
|
||
|
||
out, err := exec.Command(bin, "migrate", "--config", bookPath).Output()
|
||
if got := exitCodeOf(t, err); got != 0 {
|
||
t.Fatalf("`migrate` exited %d: %s", got, out)
|
||
}
|
||
if want := fmt.Sprintf("schema v%d -> v%d", from, store.SchemaHead()); !strings.Contains(string(out), want) {
|
||
t.Fatalf("migrate must report the transition %q; got:\n%s", want, out)
|
||
}
|
||
if got := exitCodeOf(t, exec.Command(bin, "status", "--config", bookPath, "--json").Run()); got != 0 {
|
||
t.Fatalf("`status --json` after the migration exited %d, want 0 — the deadlock is not broken", got)
|
||
}
|
||
// …and the run the migration unblocked actually starts. It follows within milliseconds, which is
|
||
// what put migrate's restore point and the paid path's pre-flight guard in the same second.
|
||
if run := exec.Command(bin, "translate", "--config", bookPath); exitCodeOf(t, run.Run()) != 0 {
|
||
t.Fatal("the run spawned right after the migration must start, not die in its pre-flight guard")
|
||
}
|
||
}
|
||
|
||
// TestTheMigrationRestorePointNeverCollidesWithThePaidPath is the deterministic half of the same fact:
|
||
// both producers are handed the SAME stamp, because in the deploy sequence they really do land in the
|
||
// same second. `store.BackupSQLite` refuses to overwrite a restore point (backlog row 173), so sharing
|
||
// the name would make the run refuse to start with exit 1 — outside the refusal band, and after a
|
||
// migration that already succeeded.
|
||
func TestTheMigrationRestorePointNeverCollidesWithThePaidPath(t *testing.T) {
|
||
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
||
dbPath := projectDBOf(bookPath)
|
||
st, err := store.Open(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st.Close()
|
||
staleTheProjectSchema(t, dbPath)
|
||
|
||
// PRODUCTION takes the restore point — the point of the test. Supplying the name here instead would
|
||
// only pin that the constant is non-empty, and the regression to catch is the call site dropping it.
|
||
if err := migrateCmd(bookPath, io.Discard); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
names := backupsOf(t, dbPath)
|
||
if len(names) != 1 {
|
||
t.Fatalf("restore points after the migration: %v, want exactly one", names)
|
||
}
|
||
|
||
// Strip the tag production chose and the bare second-stamp must still be free, so translate's
|
||
// pre-flight guard can take its own backup in the SAME second. Untagged, the two names are equal and
|
||
// BackupSQLite refuses to overwrite — the run the migration just unblocked would die in its
|
||
// pre-flight guard with exit 1. No wall clock is involved: the collision is decided by the name.
|
||
tagged := strings.TrimSuffix(names[0], ".db")
|
||
bare := strings.TrimSuffix(tagged, migrateSuffix)
|
||
if bare == tagged {
|
||
t.Fatalf("the migration's restore point %q carries no tag of its own — it is inside the paid path's namespace", names[0])
|
||
}
|
||
if _, err := store.BackupSQLite(dbPath, backupDirFor(dbPath), bare); err != nil {
|
||
t.Fatalf("the paid path's pre-flight backup in the SAME second must still succeed: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestMigrateNeedsNeitherPricesNorModels: the $0 path must not carry the paid path's gates. A
|
||
// models.yaml whose prices are older than 120 days refuses to load (backlog row 146), and if the
|
||
// migration were built on the full config stack a stand with stale prices would meet the same deadlock
|
||
// this command exists to remove — the read command refuses, and the one thing that could migrate
|
||
// refuses too.
|
||
func TestMigrateNeedsNeitherPricesNorModels(t *testing.T) {
|
||
bookPath := setupCLIProject(t, mockProvider(t).URL)
|
||
modelsPath := filepath.Join(filepath.Dir(bookPath), "models.yaml")
|
||
raw, err := os.ReadFile(modelsPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stale := time.Now().UTC().AddDate(0, 0, -200).Format("2006-01-02")
|
||
writeCLIFile(t, modelsPath, strings.Replace(string(raw), time.Now().UTC().Format("2006-01-02"), stale, 1))
|
||
|
||
// The contrast: the read-only projection is refused by the price gate before it reads a schema.
|
||
if got := exitCode(status(context.Background(), bookPath, true)); got != exitConfigInvalid {
|
||
t.Fatalf("setup: `status` on stale prices exited %d, want %d", got, exitConfigInvalid)
|
||
}
|
||
var out bytes.Buffer
|
||
if err := migrateCmd(bookPath, &out); err != nil {
|
||
t.Fatalf("migrate must not consult prices: %v", err)
|
||
}
|
||
if !strings.Contains(out.String(), fmt.Sprintf("v%d", store.SchemaHead())) {
|
||
t.Fatalf("migrate must report the head it brought the project to; got %q", out.String())
|
||
}
|
||
|
||
// The structural version of the same claim, which also covers the provider keys the models file
|
||
// carries: with no models.yaml at all the migration still runs, so it cannot be consulting either.
|
||
if err := os.Remove(modelsPath); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := exitCode(status(context.Background(), bookPath, true)); got != exitConfigInvalid {
|
||
t.Fatalf("setup: `status` without models.yaml exited %d, want %d", got, exitConfigInvalid)
|
||
}
|
||
if err := migrateCmd(bookPath, io.Discard); err != nil {
|
||
t.Fatalf("migrate must not load the model stack at all: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestMigrateKeepsTheMoneyAndRepeatsAsANoOp is the money half at the command level: a real run's
|
||
// settled spend, a reservation its process never settled, and a migration between them. `committed` is
|
||
// what the platform's ceiling argument is computed from (PD-158) and what a settle reconciles against,
|
||
// so it must survive the deploy step untouched; `reserved` is advisory and the write open's recovery
|
||
// pass is entitled to zero it.
|
||
func TestMigrateKeepsTheMoneyAndRepeatsAsANoOp(t *testing.T) {
|
||
bookPath := setupCLIProject(t, mockProvider(t).URL)
|
||
dbPath := projectDBOf(bookPath)
|
||
if err := translate(context.Background(), bookPath, false, pipeline.RebillConsent{}, false, 0, 0); err != nil {
|
||
t.Fatalf("setup run: %v", err)
|
||
}
|
||
|
||
st, err := store.Open(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
committed, _, err := st.SpentUSD("cli-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if committed <= 0 {
|
||
t.Fatalf("setup: the run must have settled money, committed=%v", committed)
|
||
}
|
||
// The reservation a killed run leaves behind.
|
||
if _, verdict, err := st.Reserve("cli-book", 0.25, store.Ceilings{BookUSD: 10, DayUSD: 10}); err != nil || verdict != store.ReserveOK {
|
||
t.Fatalf("stale reserve: %v %v", verdict, err)
|
||
}
|
||
st.Close()
|
||
from := staleTheProjectSchema(t, dbPath)
|
||
|
||
for i := range 2 {
|
||
var out bytes.Buffer
|
||
if err := migrateCmd(bookPath, &out); err != nil {
|
||
t.Fatalf("call %d: %v", i, err)
|
||
}
|
||
ro, err := store.OpenReadOnly(dbPath)
|
||
if err != nil {
|
||
t.Fatalf("call %d: the read path must work after migrating: %v", i, err)
|
||
}
|
||
gotCommitted, gotReserved, err := ro.SpentUSD("cli-book")
|
||
ro.Close()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if gotCommitted != committed {
|
||
t.Fatalf("call %d: committed %v -> %v; a migration must not move settled money by a cent", i, committed, gotCommitted)
|
||
}
|
||
if gotReserved != 0 {
|
||
t.Fatalf("call %d: reserved=%v, want the recovery pass to have zeroed it", i, gotReserved)
|
||
}
|
||
// The restore point is taken for the call that MIGRATES and for no other, and it holds the
|
||
// database as it was BEFORE the step — a copy taken afterwards is not a restore point at all.
|
||
names := backupsOf(t, dbPath)
|
||
if len(names) != 1 {
|
||
t.Fatalf("call %d: restore points %v, want exactly the one the migrating call took", i, names)
|
||
}
|
||
if v := schemaVersionOf(t, filepath.Join(backupDirFor(dbPath), names[0])); v != from {
|
||
t.Fatalf("call %d: the restore point is at schema v%d, want the pre-migration v%d", i, v, from)
|
||
}
|
||
if i == 1 && !strings.Contains(out.String(), "already at schema") {
|
||
t.Fatalf("the repeat call must report a no-op; got %q", out.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMigrateRefusesWithTheSameVocabularyAsEveryOtherCommand(t *testing.T) {
|
||
t.Run("a broken config", func(t *testing.T) {
|
||
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
||
writeCLIFile(t, bookPath, "book_id: cli-book\n bad indentation: [\n")
|
||
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitConfigInvalid {
|
||
t.Fatalf("exit %d, want %d", got, exitConfigInvalid)
|
||
}
|
||
})
|
||
|
||
t.Run("a project another process holds", func(t *testing.T) {
|
||
// The deploy step runs while the operator believes the runs are stopped. If one is not, the
|
||
// answer is "come back later" — never a migration under a live writer's feet, whose reservations
|
||
// the recovery pass would zero. And nothing may be written for that refusal: a restore point
|
||
// taken before the lock would copy the whole database once per retry.
|
||
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
||
dbPath := projectDBOf(bookPath)
|
||
st, err := store.Open(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st.Close()
|
||
staleTheProjectSchema(t, dbPath)
|
||
holdTheProject(t, dbPath)
|
||
|
||
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitProjectLocked {
|
||
t.Fatalf("exit %d, want %d", got, exitProjectLocked)
|
||
}
|
||
if got := backupsOf(t, dbPath); len(got) != 0 {
|
||
t.Fatalf("a refused migration wrote %v; nothing may be written for a project it does not own", got)
|
||
}
|
||
// The retry the refusal invites reaches the lock too, instead of dying on the leftovers of the
|
||
// previous attempt.
|
||
if got := exitCode(migrateCmd(bookPath, io.Discard)); got != exitProjectLocked {
|
||
t.Fatalf("retry exited %d, want %d again", got, exitProjectLocked)
|
||
}
|
||
})
|
||
|
||
t.Run("a project newer than this binary", func(t *testing.T) {
|
||
// The direction a migration cannot repair. It must not read as success, or a caller looping
|
||
// "status refused → migrate → retry" would loop forever on a rolled-back binary.
|
||
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
||
dbPath := projectDBOf(bookPath)
|
||
st, err := store.Open(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st.Close()
|
||
db, err := sql.Open("sqlite", "file:"+dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, store.SchemaHead()+7); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db.Close()
|
||
err = migrateCmd(bookPath, io.Discard)
|
||
if got := exitCode(err); got != exitSchemaMismatch {
|
||
t.Fatalf("exit %d, want %d", got, exitSchemaMismatch)
|
||
}
|
||
if !strings.Contains(err.Error(), fmt.Sprintf("schema_mismatch found=%d expected=%d", store.SchemaHead()+7, store.SchemaHead())) {
|
||
t.Fatalf("the refusal must name both versions machine-readably: %v", err)
|
||
}
|
||
if got := backupsOf(t, dbPath); len(got) != 0 {
|
||
t.Fatalf("a refused migration wrote %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("a book that has never been run", func(t *testing.T) {
|
||
// The deploy sweep does not have to know which books have a database yet. Nothing is backed up:
|
||
// a database this call creates has no history to lose.
|
||
bookPath := setupCLIProject(t, "http://127.0.0.1:1")
|
||
var out bytes.Buffer
|
||
if err := migrateCmd(bookPath, &out); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(out.String(), "created") {
|
||
t.Fatalf("migrate must say it created the project; got %q", out.String())
|
||
}
|
||
if v := schemaVersionOf(t, projectDBOf(bookPath)); v != store.SchemaHead() {
|
||
t.Fatalf("created at v%d, want v%d", v, store.SchemaHead())
|
||
}
|
||
if got := backupsOf(t, projectDBOf(bookPath)); len(got) != 0 {
|
||
t.Fatalf("a project being created was backed up: %v", got)
|
||
}
|
||
})
|
||
}
|