f916d5e0ca
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s
The admin game detail now shows, per robot seat, the game's deterministic play-to-win decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the ~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports, account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants (never early, never in the sleep window), PlayToWin export, and an admin render integration test asserting the intent + ETA + target appear.
223 lines
8.1 KiB
Go
223 lines
8.1 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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"
|
|
)
|
|
|
|
// TestComplaintResolutionPipeline drives a complaint from filing through
|
|
// resolution into the dictionary-change pipeline and on to "applied".
|
|
func TestComplaintResolutionPipeline(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newGameService()
|
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
|
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
|
|
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
|
|
if err != nil {
|
|
t.Fatalf("file: %v", err)
|
|
}
|
|
|
|
if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
|
|
t.Fatalf("open complaints = %d, want >= 1", open)
|
|
}
|
|
list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
|
|
if err != nil || !containsComplaint(list, filed.ID) {
|
|
t.Fatalf("open list missing filed complaint (err %v)", err)
|
|
}
|
|
|
|
resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
|
|
t.Fatalf("unexpected resolved complaint: %+v", resolved)
|
|
}
|
|
|
|
changes, err := svc.DictionaryChanges(ctx)
|
|
if err != nil {
|
|
t.Fatalf("changes: %v", err)
|
|
}
|
|
if !changeFor(changes, word, true) {
|
|
t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
|
|
}
|
|
|
|
n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
|
|
if err != nil || n < 1 {
|
|
t.Fatalf("mark applied n=%d err=%v", n, err)
|
|
}
|
|
if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
|
|
t.Fatalf("change still pending after apply (err %v): %+v", err, after)
|
|
}
|
|
}
|
|
|
|
func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
|
|
for _, c := range list {
|
|
if c.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
|
|
for _, c := range changes {
|
|
if c.Word == word && c.Add == add {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
|
|
func TestAdminListsAndCounts(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
svc := newGameService()
|
|
|
|
accBefore, err := store.CountAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("count accounts: %v", err)
|
|
}
|
|
a, b := provisionAccount(t), provisionAccount(t)
|
|
accAfter, err := store.CountAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("count accounts: %v", err)
|
|
}
|
|
if accAfter < accBefore+2 {
|
|
t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
|
|
}
|
|
if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
|
|
t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
|
|
}
|
|
if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
|
|
t.Fatalf("identities for a = %+v (err %v)", ids, err)
|
|
}
|
|
|
|
gBefore, _ := svc.CountGames(ctx, "")
|
|
if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
|
|
t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
|
|
}
|
|
if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
|
|
t.Fatalf("list active games = %d (err %v)", len(active), err)
|
|
}
|
|
}
|
|
|
|
// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
|
|
// stores: pages render, and a state-changing POST needs a same-origin header.
|
|
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newGameService()
|
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
|
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
|
|
if err != nil {
|
|
t.Fatalf("file: %v", err)
|
|
}
|
|
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zap.NewNop(),
|
|
Accounts: account.NewStore(testDB),
|
|
Games: svc,
|
|
Registry: testRegistry,
|
|
DictDir: dictDir(),
|
|
})
|
|
h := srv.Handler()
|
|
base := "http://admin.test/_gm"
|
|
|
|
if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
|
|
t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
|
|
}
|
|
if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
|
|
t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
|
|
}
|
|
// A resolve POST without a same-origin header is rejected by the CSRF guard.
|
|
if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
|
|
t.Fatalf("resolve without origin = %d, want 403", code)
|
|
}
|
|
// With a matching Origin it succeeds and persists.
|
|
if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject¬e=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
|
|
t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
|
|
}
|
|
if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
|
|
t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
|
|
}
|
|
}
|
|
|
|
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
|
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
|
|
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newGameService()
|
|
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
|
|
if err != nil {
|
|
t.Fatalf("provision robot: %v", err)
|
|
}
|
|
human := provisionAccount(t)
|
|
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
|
|
g, err := svc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
|
|
})
|
|
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("game detail = %d, want 200", code)
|
|
}
|
|
if !strings.Contains(body, "🤖") {
|
|
t.Error("robot seat is not marked in the game detail")
|
|
}
|
|
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
|
|
t.Error("robot play-to-win intent missing")
|
|
}
|
|
if !strings.Contains(body, "next move") {
|
|
t.Error("robot is to move but the next-move ETA is missing")
|
|
}
|
|
if !strings.Contains(body, "~40%") {
|
|
t.Error("robot play-to-win target caption missing")
|
|
}
|
|
}
|
|
|
|
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
|
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
|
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
if origin != "" {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Body.String()
|
|
}
|