textmachine/platform/internal/pgstore/pg_test.go

206 lines
7 KiB
Go

package pgstore
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"textmachine/platform/internal/auth"
)
// The database-backed battery. It runs against TM_PLATFORM_TEST_DSN and skips loudly without it —
// `make check` names the skip, because an invisible skip reads as coverage.
//
// Each run gets its OWN database, created and dropped here: a test that leaves rows behind passes
// once and then lies.
func testDB(t *testing.T) (*Store, context.Context) {
t.Helper()
admin := os.Getenv("TM_PLATFORM_TEST_DSN")
if admin == "" {
t.Skip("TM_PLATFORM_TEST_DSN not set: schema and session tests need a live Postgres")
}
ctx := t.Context()
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
t.Fatal(err)
}
name := "tm_platform_test_" + hex.EncodeToString(suffix[:])
adminConn, err := pgx.Connect(ctx, admin)
if err != nil {
t.Fatalf("connect: %v", err)
}
if _, err := adminConn.Exec(ctx, "create database "+pgx.Identifier{name}.Sanitize()); err != nil {
adminConn.Close(ctx)
t.Skipf("cannot create a scratch database (%v): grant CREATEDB or point the DSN at one", err)
}
t.Cleanup(func() {
dropCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, _ = adminConn.Exec(dropCtx, "drop database if exists "+pgx.Identifier{name}.Sanitize()+" with (force)")
adminConn.Close(dropCtx)
})
dsn := swapDatabase(t, admin, name)
if err := Migrate(ctx, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
// Twice, because a rollout re-runs it on every replica.
if err := Migrate(ctx, dsn); err != nil {
t.Fatalf("second migrate must be a no-op: %v", err)
}
s, err := Open(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
if err := s.Ping(ctx); err != nil {
t.Fatal(err)
}
return s, ctx
}
func swapDatabase(t *testing.T, dsn, name string) string {
t.Helper()
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("TM_PLATFORM_TEST_DSN must be a URL: %v", err)
}
u.Path = "/" + name
return u.String()
}
func TestSessionLifecycle(t *testing.T) {
s, ctx := testDB(t)
now := time.Now().UTC().Truncate(time.Millisecond)
seedUser(t, s, ctx, "u1")
token := auth.NewToken()
digest := auth.Digest(token)
if err := s.CreateSession(ctx, digest, "u1", now, time.Hour, 24*time.Hour); err != nil {
t.Fatal(err)
}
got, err := s.Lookup(ctx, digest, now)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.UserID != "u1" {
t.Fatalf("session = %+v", got)
}
// Expiry is a clause of the query, not a caller's duty.
if _, err := s.Lookup(ctx, digest, now.Add(2*time.Hour)); !errors.Is(err, auth.ErrNoSession) {
t.Fatalf("expired idle window: %v", err)
}
// Sliding never outlives the absolute deadline.
if err := s.Touch(ctx, digest, now.Add(30*time.Minute), 48*time.Hour); err != nil {
t.Fatal(err)
}
slid, err := s.Lookup(ctx, digest, now.Add(30*time.Minute))
if err != nil {
t.Fatal(err)
}
if slid.IdleExpiresAt.After(slid.AbsoluteExpiresAt) {
t.Fatalf("idle %s outlived absolute %s", slid.IdleExpiresAt, slid.AbsoluteExpiresAt)
}
if err := s.RevokeSession(ctx, digest, now); err != nil {
t.Fatal(err)
}
if _, err := s.Lookup(ctx, digest, now); !errors.Is(err, auth.ErrNoSession) {
t.Fatalf("revoked session still resolves: %v", err)
}
n, err := s.DeleteExpiredSessions(ctx, now.Add(72*time.Hour))
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("sweep removed %d rows, want 1", n)
}
}
func TestUnknownTokenIsIndistinguishable(t *testing.T) {
s, ctx := testDB(t)
if _, err := s.Lookup(ctx, auth.Digest("never-issued"), time.Now()); !errors.Is(err, auth.ErrNoSession) {
t.Fatalf("want ErrNoSession, got %v", err)
}
}
// The read-model's derivation rules are DDL, so a materializer bug fails at the write instead of
// reaching a reader. These assert the constraints actually fire.
func TestReadModelConstraints(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "u1")
exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
values ('bk1','u1','蛊真人','zh','ru','translating','/srv/books/bk1','gzr')`)
exec(t, s, ctx, `insert into chapters (id, book_id, number) values ('ch1','bk1',1)`)
t.Run("translated needs text", func(t *testing.T) {
assertViolation(t, s, ctx, "units_translated_has_text",
`insert into units (id, chapter_id, ordinal, source, target, state)
values ('un1','ch1',1,'第一节','','translated')`)
})
t.Run("withheld carries none", func(t *testing.T) {
assertViolation(t, s, ctx, "units_unshipped_is_empty",
`insert into units (id, chapter_id, ordinal, source, target, state)
values ('un2','ch1',2,'第二节','перевод','withheld')`)
})
t.Run("promote needs a rendering", func(t *testing.T) {
exec(t, s, ctx, `insert into bank_terms (id, book_id, src, status, origin) values ('t1','bk1','方源','draft','mined')`)
assertViolation(t, s, ctx, "bank_decisions_promote_has_dst",
`insert into bank_decisions (book_id, term_id, action, dst, decided_by) values ('bk1','t1','promote','','u1')`)
})
t.Run("kind may be absent but not invented", func(t *testing.T) {
// A ruby candidate that is neither a name nor a place carries no kind, and the row is still
// signable — the contract forbids inventing one for it.
exec(t, s, ctx, `insert into bank_terms (id, book_id, src, kind, status, origin) values ('t2','bk1','李',null,'draft','ruby')`)
assertViolation(t, s, ctx, "bank_terms_kind_check",
`insert into bank_terms (id, book_id, src, kind, status, origin) values ('t3','bk1','王','org','draft','ruby')`)
})
t.Run("one live run per book", func(t *testing.T) {
exec(t, s, ctx, `insert into runs (id, book_id, status, verify_bank) values ('r1','bk1','translating',true)`)
assertViolation(t, s, ctx, "runs_one_live_per_book",
`insert into runs (id, book_id, status, verify_bank) values ('r2','bk1','translating',false)`)
})
t.Run("the paused status exists", func(t *testing.T) {
// D39.100: a ceiling stop is the eleventh book status, and it is not `failed`.
exec(t, s, ctx, `update books set status='paused' where id='bk1'`)
assertViolation(t, s, ctx, "books_status_check", `update books set status='exhausted' where id='bk1'`)
})
}
func seedUser(t *testing.T, s *Store, ctx context.Context, id string) {
t.Helper()
exec(t, s, ctx, `insert into users (id, email) values ($1, $1 || '@example.org')`, id)
}
func exec(t *testing.T, s *Store, ctx context.Context, sql string, args ...any) {
t.Helper()
if _, err := s.pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %s: %v", sql, err)
}
}
func assertViolation(t *testing.T, s *Store, ctx context.Context, constraint, sql string) {
t.Helper()
_, err := s.pool.Exec(ctx, sql)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
t.Fatalf("want a constraint violation, got %v", err)
}
if pgErr.ConstraintName != constraint {
t.Fatalf("violated %q, want %q", pgErr.ConstraintName, constraint)
}
}