Files
scrabble-game/backend/internal/inttest/dictionary_update_test.go
T
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00

240 lines
8.6 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 on the SAME build: an out-of-band admin install newer
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
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 (a newer out-of-band install is kept)", got)
}
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
t.Fatalf("make v10.0.0 resident: %v", err)
}
deployed := newGameServiceOn(dir, "v10.0.0", reg)
if err := deployed.InitActiveVersion(ctx); err != nil {
t.Fatalf("deploy init: %v", err)
}
if got := deployed.ActiveVersion(); got != "v10.0.0" {
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
}
// Restore the out-of-band active version so the assertions below still read v9.9.9.
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
t.Fatalf("restore active: %v", err)
}
// 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()
}