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 }