R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
|
||||
@@ -78,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
||||
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
|
||||
// reads back the zero statistics rather than an error (the stats screen).
|
||||
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -109,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
// later login (language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -196,7 +198,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHighRateFlagRoundTrip covers the R3 soft high-rate marker: a fresh account
|
||||
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
|
||||
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
|
||||
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
|
||||
// the operator clear takes a fresh timestamp.
|
||||
@@ -279,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -311,3 +313,72 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if !acc.IsGuest {
|
||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
|
||||
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
@@ -207,7 +207,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to
|
||||
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
|
||||
// end against real stores: a gateway report past the threshold auto-flags the
|
||||
// account, the throttled view shows the episode and the flagged account, the
|
||||
// user card carries the marker, and the operator clear (a same-origin POST)
|
||||
|
||||
@@ -34,7 +34,7 @@ func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.M
|
||||
return svc, g.ID, seats, hint
|
||||
}
|
||||
|
||||
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
|
||||
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
|
||||
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
||||
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
||||
func TestDraftPersistAndConflictReset(t *testing.T) {
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
|
||||
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
|
||||
// accepted, persisted verbatim, and resolved to the right fixed offset.
|
||||
func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
t.Fatalf("ResolveZone offset = %d, want 10800", off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ func TestResignWinnerAndStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
|
||||
// TestResignOnOpponentTurn checks a player can forfeit on the
|
||||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||||
// despite leading on score.
|
||||
@@ -464,7 +464,7 @@ func TestHintPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
|
||||
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
|
||||
// created game's variant and ErrNotFound for an unknown id.
|
||||
func TestGameVariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -619,7 +619,7 @@ func equalStrings(a, b []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
||||
// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
|
||||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be
|
||||
// TestHideFinishedGame covers per-account game hiding: an active game cannot be
|
||||
// hidden, a finished game is removed from the hider's own list while staying visible to the
|
||||
// other player, an outsider cannot hide it, and the action is idempotent.
|
||||
func TestHideFinishedGame(t *testing.T) {
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
||||
t.Fatalf("get robot account: %v", err)
|
||||
}
|
||||
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
||||
// expires, mirroring a human who ignores it (Stage 17).
|
||||
// expires, mirroring a human who ignores it.
|
||||
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
||||
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
||||
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||
|
||||
@@ -70,7 +70,7 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
|
||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -342,7 +342,7 @@ func TestChatRejectsBadContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
|
||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
|
||||
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
||||
func TestChatOnlyOnYourTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -383,7 +383,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
|
||||
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -413,7 +413,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
||||
// friends" item: a pending request shows for the requester only; an accepted one
|
||||
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||
func TestListOutgoingRequests(t *testing.T) {
|
||||
@@ -469,7 +469,7 @@ func TestListOutgoingRequests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
||||
// requester over the live channel: accept -> friend_added, decline ->
|
||||
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||
func TestRespondPublishesToRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -503,7 +503,7 @@ func TestRespondPublishesToRequester(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
|
||||
// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17).
|
||||
// it is delivered by the game's bot rather than the recipient's last-login bot.
|
||||
func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -528,7 +528,7 @@ func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
||||
// TestAdminListMessages checks the admin moderation list: real messages only
|
||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||
func TestAdminListMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if !acc.IsGuest {
|
||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user