textmachine/platform/cmd/tmplatformctl/runs_test.go

214 lines
9.3 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"textmachine/platform/internal/runner"
)
// seedALiveRun writes the smallest world the runs listing reads: an account, a book, a live run with
// a live attempt, and the hold against it. Written as SQL because the subject is the LISTING, and
// going through the service would drag the whole runner in to prove a table.
func seedALiveRun(t *testing.T, dsn string) {
t.Helper()
now := time.Now().UTC()
execSQL(t, dsn, `insert into users (id, email) values ('u1','u1@example.org')`)
execSQL(t, dsn, `insert into books (id, owner_id, title, source_lang, target_lang, status,
chapter_count, workdir, engine_book_id, added_at, revision)
values ('bk1','u1','蛊真人','zh','ru','translating',10,'/srv/books/bk1','bk1',$1,1)`, now)
execSQL(t, dsn, `insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision)
values ('run_probe','bk1','translating',false,10,$1,1)`, now)
execSQL(t, dsn, `insert into run_attempts (run_id, attempt_no, started_at, last_offset, engine_run_id)
values ('run_probe',1,$1,0,'tm-stream-run_probe-1')`, now)
}
func execSQL(t *testing.T, dsn, sql string, args ...any) {
t.Helper()
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, sql, args...); err != nil {
t.Fatalf("%s: %v", sql, err)
}
}
// exit-marker runs INSIDE a run's transient unit, as ExecStopPost, where there is no database and no
// credential to open one with. Reaching for the DSN there would fail every marker in production and
// lose the only record of how the run ended — so the dispatch has to answer this command before it
// reads a secret. Pinned here because the dispatch order is invisible at the call site.
func TestTheExitMarkerCommandNeedsNoDatabase(t *testing.T) {
t.Setenv("TM_PLATFORM_DSN", "")
t.Setenv("TM_PLATFORM_DSN_FILE", "")
t.Setenv("SERVICE_RESULT", "oom-kill")
t.Setenv("EXIT_CODE", "killed")
t.Setenv("EXIT_STATUS", "TERM")
path := filepath.Join(t.TempDir(), "runs", "run_1-1.exit")
if err := run([]string{"exit-marker", path, "tm-run-run_1-1"}, os.Stdout); err != nil {
t.Fatalf("exit-marker with no DSN: %v", err)
}
m, err := runner.ReadMarker(path)
if err != nil {
t.Fatal(err)
}
if m.Result != "oom-kill" || m.Code != "killed" || m.Status != "TERM" || m.Unit != "tm-run-run_1-1" {
t.Errorf("marker: %+v", m)
}
// Every other command still needs one, or it would silently do nothing.
if err := run([]string{"balance", "--user", "u1"}, os.Stdout); err == nil {
t.Error("a database command ran without a DSN")
}
}
func TestTheExitMarkerCommandNeedsBothArguments(t *testing.T) {
t.Setenv("TM_PLATFORM_DSN", "")
for _, args := range [][]string{{"exit-marker"}, {"exit-marker", "only-a-path"}} {
if err := run(append([]string{}, args...), os.Stdout); err == nil {
t.Errorf("%v was accepted", args)
}
}
}
// The development intake is what puts a book in the read model until the contract's upload exists.
// It refuses a directory that is not an engine project, because the alternative is a run that dies
// at argument parsing inside a transient unit, where the only trace is "exit-code 1".
func TestAddingABookRefusesADirectoryTheEngineCannotUse(t *testing.T) {
dir := t.TempDir()
args := []string{"--user", "u1", "--workdir", dir, "--title", "b",
"--source-lang", "zh", "--target-lang", "ru", "--chapters", "10", "--characters", "23000000"}
_, err := parseBookIntake(args)
if err == nil || !strings.Contains(err.Error(), "engine project directory") {
t.Fatalf("a directory without book.yaml gave %v", err)
}
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseBookIntake(args)
if err != nil {
t.Fatalf("a valid project directory was refused: %v", err)
}
if got.ChapterCount != 10 || got.CharacterCount != 23_000_000 || got.OwnerID != "u1" {
t.Errorf("intake: %+v", got)
}
if !filepath.IsAbs(got.Workdir) {
t.Errorf("the workdir was not made absolute: %q — the unit runs with its own working directory", got.Workdir)
}
}
// Chapters are what bound the run-ceiling scale, so a book registered without them would offer a
// scale of zero and no run could ever start on it.
func TestAddingABookRequiresTheFactsTheScaleIsBuiltFrom(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil {
t.Fatal(err)
}
full := []string{"--user", "u1", "--workdir", dir, "--title", "b",
"--source-lang", "zh", "--target-lang", "ru", "--chapters", "10"}
drop := func(flag string) []string {
out := []string{}
for i := 0; i < len(full); i += 2 {
if full[i] != flag {
out = append(out, full[i], full[i+1])
}
}
return out
}
for _, flag := range []string{"--user", "--workdir", "--title", "--source-lang", "--target-lang", "--chapters"} {
if _, err := parseBookIntake(drop(flag)); err == nil {
t.Errorf("a book without %s was accepted", flag)
}
}
}
// `tmplatformctl runs` shows LIVE runs, and `--stalled` narrows that to the ones the reconciler
// keeps failing on. Both halves are what the usage line promises, and the first half was missing:
// the floor was one failure, so on a healthy deployment the command answered "no run is failing to
// reconcile" and never showed the runs that were going perfectly well — the same answer it gives
// while a run IS wedged and the deferral has not counted it yet.
//
// Mutation caught: raising the plain form's floor above zero.
func TestTheRunsListingShowsLiveRunsAndNarrowsToStalledOnes(t *testing.T) {
dsn := freshDB(t)
t.Setenv("TM_PLATFORM_DSN", dsn)
seedALiveRun(t, dsn)
if got := capture(t, "runs"); !strings.Contains(got, "run_probe") {
t.Errorf("the plain listing does not show a live run:\n%s", got)
}
if got := capture(t, "runs", "--stalled"); strings.Contains(got, "run_probe") {
t.Errorf("a run with no failures is reported as stalled:\n%s", got)
}
// …and once it has failed enough, the narrowed form names it.
execSQL(t, dsn, `update run_attempts set reconcile_failures = 99`)
if got := capture(t, "runs", "--stalled"); !strings.Contains(got, "run_probe") {
t.Errorf("a run past the threshold is missing from --stalled:\n%s", got)
}
}
// The table stays a table, and the figures in it are the ones an operator may act on.
//
// Both columns it prints from the database are hostile: the engine's stderr arrives verbatim and
// multi-byte (the product translates zh/ja), and the TITLE is what the uploader typed — the intake
// strips control characters only from titles taken from a filename. A tab forges a column, a newline
// forges a row, and a byte-slice through a three-byte rune leaves the tail unreadable.
//
// The SPENT column is the money half: `spend_micro_usd` holds the engine's BOOK-lifetime figure, so
// the attempt's own cost is the difference from its baseline — and with no baseline there is no
// honest figure at all, which is what `settle` itself concludes.
//
// Mutation caught: cutting by byte in oneLine; dropping the control-character map; printing
// spend_micro_usd raw; printing zero for an attempt with no baseline.
func TestTheOperatorsTableCannotBeForgedByABooksOwnText(t *testing.T) {
dsn := freshDB(t)
t.Setenv("TM_PLATFORM_DSN", dsn)
seedALiveRun(t, dsn)
execSQL(t, dsn, `update books set title = 'A' || chr(9) || 'FORGED' || chr(10) || 'ROW'`)
execSQL(t, dsn, `update run_attempts
set reconcile_failures = 99, reconcile_error = $1,
spend_micro_usd = 123456, spend_baseline_micro_usd = 100000`,
"engine: "+strings.Repeat("蛊真人", 100))
got := capture(t, "runs", "--stalled")
rows := 0
for _, line := range strings.Split(strings.TrimSpace(got), "\n") {
if strings.Contains(line, "FORGED") {
rows++
}
}
switch {
case rows != 1:
t.Errorf("the book's title spans %d lines of the table: a newline in it forges rows\n%s", rows, got)
case strings.Contains(got, "A\tFORGED"):
t.Errorf("the book's title kept its tab: it forges a column in a table aligned by them\n%s", got)
case !utf8.ValidString(got):
t.Errorf("the table is not valid UTF-8: the engine's error was cut through a rune\n%q", got)
case !strings.Contains(got, "0.023456"):
t.Errorf("SPENT is not this attempt's own cost (123456 - 100000):\n%s", got)
}
// With no baseline the settlement refuses to price the attempt, and so does the table.
execSQL(t, dsn, `update run_attempts set spend_baseline_micro_usd = null`)
if got := capture(t, "runs", "--stalled"); !strings.Contains(got, "?") {
t.Errorf("an attempt the settlement will not price is given a figure anyway:\n%s", got)
}
// ⚠ AND THE CONTROL CHARACTERS `strings.Fields` DOES NOT TOUCH, asserted on the function because
// the table above cannot show them: tabwriter consumes a tab into padding, so the forged column
// is invisible in the output it produces. ESC is the one that matters — a title carrying terminal
// escapes rewrites the screen of the operator reading the table.
for _, r := range oneLine("A\x1b[2JB\x00C\x07D") {
if unicode.IsControl(r) {
t.Errorf("oneLine passes %q through to an operator's terminal", r)
}
}
}