Files
scrabble-game/backend/internal/engine/reload_test.go
T
Ilia Denisov aafdd46a4b
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 13s
Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts)
Server-rendered admin console in the backend at /_gm (internal/adminconsole),
fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy
(mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs;
no operator identity is tracked. This supersedes the Stage 6 gateway-fronts-
/api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are
dropped and gateway/internal/admin is repurposed to the verbatim proxy.

- Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/
  resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a
  query-derived pending dictionary-change pipeline (marked applied after a reload).
- Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new
  Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on
  restart. Partially addresses TODO-2.
- Broadcasts: a backend Telegram-connector client (internal/connector,
  BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9
  forward-note).
- Admin reads: account.ListAccounts/CountAccounts/Identities and
  game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/
  ResolveComplaint/DictionaryChanges/MarkChangesApplied.
- Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim
  proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console.
- Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13,
  FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
2026-06-04 09:24:59 +02:00

120 lines
3.7 KiB
Go

package engine
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
// real DAWG files laid out in temporary version directories.
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
t.Helper()
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dstDir, err)
}
name := dictFiles[v]
src, err := os.Open(filepath.Join(srcDir, name))
if err != nil {
t.Fatalf("open source dawg %s: %v", name, err)
}
defer func() { _ = src.Close() }()
dst, err := os.Create(filepath.Join(dstDir, name))
if err != nil {
t.Fatalf("create dest dawg %s: %v", name, err)
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, src); err != nil {
t.Fatalf("copy dawg %s: %v", name, err)
}
}
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
// the variants whose DAWG is present in the directory, under the given version.
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
dir := t.TempDir()
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
reg := NewRegistry()
defer func() { _ = reg.Close() }()
loaded, err := reg.LoadAvailable(dir, "v2")
if err != nil {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [english]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("english v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("russian v2 should be absent: got %v", err)
}
}
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
// version plus every version subdirectory, the subdir version becoming the
// variant's latest while the boot version stays resident.
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() { // flat boot version: all three variants
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
for _, v := range Variants() {
if _, err := reg.Solver(v, "v1"); err != nil {
t.Errorf("boot solver %s/v1: %v", v, err)
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("english versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest english: %v", err)
}
if latest != "v2" {
t.Errorf("latest english = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("russian versions = %v, want one (no v2 file)", got)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
reg, err := Open(testDictDir(), "v1", VariantEnglish)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reg.Close() }()
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
t.Fatalf("reload v2: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Fatalf("versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest: %v", err)
}
if latest != "v2" {
t.Errorf("latest = %q, want v2", latest)
}
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("v1 still resident: %v", err)
}
}