422bd14b53
- display-name marker: letters-only 'Zzloadtest' (the editable-name validator forbids digits/colons), so profile.update resends the seeded name successfully. - draft.save: rack_order is a string in the backend draft DTO (was sent as []), fixing the bad_request. Both confirmed ok against the contour. chat_not_your_turn / nudge_own_turn are by-design turn gates (backend/internal/social/chat.go), correctly exercised.
182 lines
5.8 KiB
Go
182 lines
5.8 KiB
Go
package seed
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Marker prefixes every display_name the harness writes. Cleanup matches on it, so
|
|
// the harness only ever deletes its own rows and never touches real accounts. It is
|
|
// a distinctive, letters-only string so a profile.update can resend the seeded name
|
|
// through the editable-display-name validator (which forbids digits and colons).
|
|
const Marker = "Zzloadtest"
|
|
|
|
// Schema-qualified targets so the seeder does not depend on the connection's
|
|
// search_path (the backend pins search_path=backend; we qualify explicitly).
|
|
var (
|
|
accountsTbl = pgx.Identifier{"backend", "accounts"}
|
|
identitiesTbl = pgx.Identifier{"backend", "identities"}
|
|
sessionsTbl = pgx.Identifier{"backend", "sessions"}
|
|
)
|
|
|
|
// Account is one seeded player: its account id, marker display name and the
|
|
// plaintext bearer token the driver presents in the Authorization header. Guest
|
|
// marks a guest (no identity, accrues no statistics). Name is retained so a
|
|
// profile.update can resend the marker display name and keep the row findable by
|
|
// Cleanup.
|
|
type Account struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
Token string
|
|
Guest bool
|
|
}
|
|
|
|
// Pool is the seeded population, split by durability.
|
|
type Pool struct {
|
|
Guests []Account
|
|
Durables []Account
|
|
}
|
|
|
|
// All returns every seeded account, durables first.
|
|
func (p *Pool) All() []Account {
|
|
out := make([]Account, 0, len(p.Durables)+len(p.Guests))
|
|
out = append(out, p.Durables...)
|
|
out = append(out, p.Guests...)
|
|
return out
|
|
}
|
|
|
|
// Seeder writes and removes the harness population over a pgx pool against the
|
|
// backend Postgres schema.
|
|
type Seeder struct{ pool *pgxpool.Pool }
|
|
|
|
// New connects to dsn (the backend Postgres) and verifies the connection.
|
|
func New(ctx context.Context, dsn string) (*Seeder, error) {
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seed: connect: %w", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("seed: ping: %w", err)
|
|
}
|
|
return &Seeder{pool: pool}, nil
|
|
}
|
|
|
|
// Close releases the pool.
|
|
func (s *Seeder) Close() { s.pool.Close() }
|
|
|
|
// Seed inserts nDurable durable accounts (each with a confirmed email identity) and
|
|
// nGuest guest accounts, an active session per account, and returns the population
|
|
// with the plaintext tokens. Rows go in over COPY in foreign-key order (accounts,
|
|
// then identities and sessions). Every row carries Marker in its display name /
|
|
// external id so Cleanup can find them.
|
|
func (s *Seeder) Seed(ctx context.Context, nDurable, nGuest int) (*Pool, error) {
|
|
pool := &Pool{
|
|
Durables: make([]Account, 0, nDurable),
|
|
Guests: make([]Account, 0, nGuest),
|
|
}
|
|
var acctRows, identRows, sessRows [][]any
|
|
|
|
add := func(guest bool, i int) error {
|
|
aid, err := uuid.NewV7()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sid, err := uuid.NewV7()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, hash, err := GenerateToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lang := "en"
|
|
if i%2 == 1 {
|
|
lang = "ru"
|
|
}
|
|
kind := "d"
|
|
if guest {
|
|
kind = "g"
|
|
}
|
|
// A letters-only display name (Marker + kind), valid per the editable-name
|
|
// validator; account_id, not the name, is the unique key, so duplicates are fine.
|
|
name := Marker + kind
|
|
acctRows = append(acctRows, []any{aid, name, guest, lang})
|
|
sessRows = append(sessRows, []any{sid, aid, hash, "active"})
|
|
if !guest {
|
|
iid, err := uuid.NewV7()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ext := fmt.Sprintf("%s%s@loadtest.invalid", Marker, aid)
|
|
identRows = append(identRows, []any{iid, aid, "email", ext, true})
|
|
}
|
|
acc := Account{ID: aid, Name: name, Token: token, Guest: guest}
|
|
if guest {
|
|
pool.Guests = append(pool.Guests, acc)
|
|
} else {
|
|
pool.Durables = append(pool.Durables, acc)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for i := 0; i < nDurable; i++ {
|
|
if err := add(false, i); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for i := 0; i < nGuest; i++ {
|
|
if err := add(true, i); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if _, err := s.pool.CopyFrom(ctx, accountsTbl,
|
|
[]string{"account_id", "display_name", "is_guest", "preferred_language"},
|
|
pgx.CopyFromRows(acctRows)); err != nil {
|
|
return nil, fmt.Errorf("seed: copy accounts: %w", err)
|
|
}
|
|
if len(identRows) > 0 {
|
|
if _, err := s.pool.CopyFrom(ctx, identitiesTbl,
|
|
[]string{"identity_id", "account_id", "kind", "external_id", "confirmed"},
|
|
pgx.CopyFromRows(identRows)); err != nil {
|
|
return nil, fmt.Errorf("seed: copy identities: %w", err)
|
|
}
|
|
}
|
|
if _, err := s.pool.CopyFrom(ctx, sessionsTbl,
|
|
[]string{"session_id", "account_id", "token_hash", "status"},
|
|
pgx.CopyFromRows(sessRows)); err != nil {
|
|
return nil, fmt.Errorf("seed: copy sessions: %w", err)
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
// Cleanup removes everything the harness created: first the games any harness
|
|
// account is seated in (cascading game_players / game_moves / complaints / chat),
|
|
// then the harness accounts (cascading identities, sessions, stats, invitations,
|
|
// drafts and the rest). It is scoped by Marker, so it is safe to run against a
|
|
// contour that also holds real data. The authoritative hard reset remains the
|
|
// contour DB wipe (DROP SCHEMA backend CASCADE + backend restart). It returns the
|
|
// number of accounts removed.
|
|
func (s *Seeder) Cleanup(ctx context.Context) (int, error) {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
DELETE FROM backend.games
|
|
WHERE game_id IN (
|
|
SELECT p.game_id FROM backend.game_players p
|
|
JOIN backend.accounts a ON a.account_id = p.account_id
|
|
WHERE a.display_name LIKE $1
|
|
)`, Marker+"%"); err != nil {
|
|
return 0, fmt.Errorf("seed: cleanup games: %w", err)
|
|
}
|
|
tag, err := s.pool.Exec(ctx,
|
|
`DELETE FROM backend.accounts WHERE display_name LIKE $1`, Marker+"%")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("seed: cleanup accounts: %w", err)
|
|
}
|
|
return int(tag.RowsAffected()), nil
|
|
}
|