Files
scrabble-game/backend/internal/inttest/dictionary_update_test.go
T
Ilia Denisov caefc8f579
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
feat(game): official first-move tile draw + admin step-by-step replay
Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
2026-06-20 08:47:18 +02:00

223 lines
7.7 KiB
Go

//go:build integration
package inttest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
)
// TestDictionaryUpdateFlow drives the admin dictionary update end to end over HTTP
// against real stores and an isolated dictionary directory: upload a release
// archive, preview the diff, install it, and confirm it becomes the active version,
// is written to disk, survives a restart (a fresh service re-adopts it), pins new
// games while leaving an in-progress game on its old version, and is immutable.
func TestDictionaryUpdateFlow(t *testing.T) {
ctx := context.Background()
// Isolated dictionary directory seeded with the real DAWGs as the flat boot
// version v1.0.0, with its own registry and service so the shared fixtures are
// untouched.
dir := t.TempDir()
seedDawgs(t, dictDir(), dir)
reg, err := engine.OpenWithVersions(dir, "v1.0.0")
if err != nil {
t.Fatalf("open registry: %v", err)
}
defer func() { _ = reg.Close() }()
svc := newGameServiceOn(dir, "v1.0.0", reg)
if err := svc.InitActiveVersion(ctx); err != nil {
t.Fatalf("init active version: %v", err)
}
if got := svc.ActiveVersion(); got != "v1.0.0" {
t.Fatalf("active version = %q, want v1.0.0", got)
}
// An in-progress game pinned to the original version.
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
oldGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create old game: %v", err)
}
if oldGame.DictVersion != "v1.0.0" {
t.Fatalf("old game dict_version = %q, want v1.0.0", oldGame.DictVersion)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: reg, DictDir: dir,
})
h := srv.Handler()
base := "http://admin.test/_gm"
archive := releaseArchive(t, dir)
const fileName = "scrabble-dawg-v9.9.9.tar.gz"
// Upload without a same-origin header is rejected by the CSRF guard.
if code, _ := consoleUpload(h, base+"/dictionary/upload", fileName, archive, ""); code != http.StatusForbidden {
t.Fatalf("upload without origin = %d, want 403", code)
}
// Upload with origin renders the preview carrying the version and a token.
code, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test")
if code != http.StatusOK || !strings.Contains(body, "v9.9.9") {
t.Fatalf("upload = %d, has version=%v", code, strings.Contains(body, "v9.9.9"))
}
token := tokenFrom(t, body)
// Confirm installs, loads and activates the new version.
code, body = consoleDo(h, http.MethodPost, base+"/dictionary/install", "token="+token+"&version=v9.9.9", "http://admin.test")
if code != http.StatusOK || !strings.Contains(body, "Updated") {
t.Fatalf("install = %d, has Updated=%v", code, strings.Contains(body, "Updated"))
}
// The new version is active, resident for every variant, and written to disk.
if got := svc.ActiveVersion(); got != "v9.9.9" {
t.Fatalf("active version after install = %q, want v9.9.9", got)
}
for _, v := range engine.Variants() {
if _, err := reg.Solver(v, "v9.9.9"); err != nil {
t.Errorf("variant %s v9.9.9 not resident: %v", v, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "v9.9.9", "en_sowpods.dawg")); err != nil {
t.Errorf("installed dawg missing on disk: %v", err)
}
// The choice survives a restart: a fresh service over the same DB and registry
// re-adopts the persisted active version.
restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err)
}
if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
}
// New games pin the new version; the in-progress game keeps its own, still resident.
newGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)}, TurnTimeout: 24 * time.Hour, Seed: 2})
if err != nil {
t.Fatalf("create new game: %v", err)
}
if newGame.DictVersion != "v9.9.9" {
t.Errorf("new game dict_version = %q, want v9.9.9", newGame.DictVersion)
}
if _, err := reg.Solver(engine.VariantEnglish, oldGame.DictVersion); err != nil {
t.Errorf("old game's version %s no longer resident: %v", oldGame.DictVersion, err)
}
// Re-uploading the now-resident version is rejected (versions are immutable).
if _, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test"); !strings.Contains(body, "Already resident") {
t.Errorf("re-upload of resident version not rejected: %q", body)
}
}
// newGameServiceOn builds a game service over the shared pool but a caller-supplied
// dictionary directory, version and registry, for the isolated dictionary tests.
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
svc := game.NewService(
game.NewStore(testDB), account.NewStore(testDB), reg,
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
zap.NewNop(),
)
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
}
// seedDawgs copies the committed DAWG of every variant from src into dst (flat).
func seedDawgs(t *testing.T, src, dst string) {
t.Helper()
for _, name := range engine.DictFiles() {
copyFile(t, filepath.Join(src, name), filepath.Join(dst, name))
}
}
// releaseArchive packs the three DAWGs from dir into an in-memory gzip+tar release
// archive, the shape consoleUploadDictionary expects.
func releaseArchive(t *testing.T, dir string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, name := range engine.DictFiles() {
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("tar header %s: %v", name, err)
}
if _, err := tw.Write(data); err != nil {
t.Fatalf("tar write %s: %v", name, err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
t.Fatalf("write %s: %v", dst, err)
}
}
var tokenRE = regexp.MustCompile(`name="token" value="([0-9a-f]{32})"`)
// tokenFrom extracts the staging token from a rendered preview page.
func tokenFrom(t *testing.T, body string) string {
t.Helper()
m := tokenRE.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no staging token in preview body:\n%s", body)
}
return m[1]
}
// consoleUpload posts a multipart archive to target as the "archive" file field,
// optionally with an Origin header, returning the status and body.
func consoleUpload(h http.Handler, target, filename string, data []byte, origin string) (int, string) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("archive", filename)
_, _ = io.Copy(fw, bytes.NewReader(data))
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, target, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}