206 lines
7 KiB
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)
|
|
}
|
|
}
|