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