9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.
The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
397 lines
14 KiB
Go
397 lines
14 KiB
Go
package transcode_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
flatbuffers "github.com/google/flatbuffers/go"
|
|
|
|
"scrabble/gateway/internal/backendclient"
|
|
"scrabble/gateway/internal/transcode"
|
|
fb "scrabble/pkg/fbs/scrabblefb"
|
|
)
|
|
|
|
// fakeBackend serves the subset of backend endpoints the slice handlers call.
|
|
func fakeBackend(t *testing.T, h http.HandlerFunc) (*backendclient.Client, func()) {
|
|
t.Helper()
|
|
srv := httptest.NewServer(h)
|
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2_000_000_000)
|
|
if err != nil {
|
|
t.Fatalf("backendclient: %v", err)
|
|
}
|
|
return c, func() {
|
|
_ = c.Close()
|
|
srv.Close()
|
|
}
|
|
}
|
|
|
|
func TestGuestAuthRoundTrip(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/internal/sessions/guest" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"token":"tok-1","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, ok := reg.Lookup(transcode.MsgAuthGuest)
|
|
if !ok {
|
|
t.Fatal("auth.guest not registered")
|
|
}
|
|
payload, err := op.Handler(context.Background(), transcode.Request{})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
sess := fb.GetRootAsSession(payload, 0)
|
|
if string(sess.Token()) != "tok-1" || string(sess.UserId()) != "u-1" || !sess.IsGuest() {
|
|
t.Fatalf("session decoded wrong: token=%q user=%q guest=%v", sess.Token(), sess.UserId(), sess.IsGuest())
|
|
}
|
|
}
|
|
|
|
func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-User-ID"); got != "u-7" {
|
|
t.Errorf("X-User-ID = %q, want u-7", got)
|
|
}
|
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"hint_unlock_left_seconds":1200}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgGameState)
|
|
|
|
b := flatbuffers.NewBuilder(32)
|
|
gid := b.CreateString("g-1")
|
|
fb.StateRequestStart(b)
|
|
fb.StateRequestAddGameId(b, gid)
|
|
b.Finish(fb.StateRequestEnd(b))
|
|
|
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-7"})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
st := fb.GetRootAsStateView(payload, 0)
|
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 {
|
|
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds())
|
|
}
|
|
game := st.Game(nil)
|
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
|
t.Fatalf("nested game decoded wrong: %+v", game)
|
|
}
|
|
}
|
|
|
|
func TestWalletCatalogRoundTrip(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-User-ID"); got != "u-9" {
|
|
t.Errorf("X-User-ID = %q, want u-9", got)
|
|
}
|
|
if r.URL.Path != "/api/v1/user/wallet/catalog" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"products":[` +
|
|
`{"kind":"value","product_id":"val-1","title":"50 hints","chips":100,"money_amount":0,"money_currency":"","atoms":[{"atom_type":"hints","quantity":50}]},` +
|
|
`{"kind":"pack","product_id":"pack-1","title":"100 chips","chips":0,"money_amount":14900,"money_currency":"RUB","atoms":[{"atom_type":"chips","quantity":100}]}` +
|
|
`]}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, ok := reg.Lookup(transcode.MsgWalletCatalog)
|
|
if !ok {
|
|
t.Fatal("wallet.catalog not registered")
|
|
}
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-9"})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
|
|
cat := fb.GetRootAsCatalog(payload, 0)
|
|
if cat.ProductsLength() != 2 {
|
|
t.Fatalf("products = %d, want 2", cat.ProductsLength())
|
|
}
|
|
var value fb.CatalogProduct
|
|
cat.Products(&value, 0)
|
|
if string(value.Kind()) != "value" || string(value.ProductId()) != "val-1" || value.Chips() != 100 || value.AtomsLength() != 1 {
|
|
t.Fatalf("value decoded wrong: kind=%q id=%q chips=%d atoms=%d", value.Kind(), value.ProductId(), value.Chips(), value.AtomsLength())
|
|
}
|
|
var atom fb.CatalogAtom
|
|
value.Atoms(&atom, 0)
|
|
if string(atom.AtomType()) != "hints" || atom.Quantity() != 50 {
|
|
t.Fatalf("atom decoded wrong: type=%q qty=%d", atom.AtomType(), atom.Quantity())
|
|
}
|
|
var pack fb.CatalogProduct
|
|
cat.Products(&pack, 1)
|
|
if string(pack.Kind()) != "pack" || pack.Chips() != 0 || pack.MoneyAmount() != 14900 || string(pack.MoneyCurrency()) != "RUB" {
|
|
t.Fatalf("pack decoded wrong: kind=%q chips=%d money=%d cur=%q", pack.Kind(), pack.Chips(), pack.MoneyAmount(), pack.MoneyCurrency())
|
|
}
|
|
}
|
|
|
|
func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]}}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
|
|
|
|
b := flatbuffers.NewBuilder(32)
|
|
v := b.CreateString("scrabble_en")
|
|
fb.EnqueueRequestStart(b)
|
|
fb.EnqueueRequestAddVariant(b, v)
|
|
b.Finish(fb.EnqueueRequestEnd(b))
|
|
|
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
m := fb.GetRootAsMatchResult(payload, 0)
|
|
if !m.Matched() {
|
|
t.Fatal("match result should be matched")
|
|
}
|
|
if g := m.Game(nil); g == nil || string(g.Id()) != "g-9" {
|
|
t.Fatalf("match game decoded wrong: %+v", g)
|
|
}
|
|
}
|
|
|
|
func TestEnqueueEncodesOpenGameWhenNotMatched(t *testing.T) {
|
|
// An auto-match enqueue that opens a game awaiting an opponent returns matched=false but
|
|
// still carries the game; it must reach the client so it navigates into the game at once.
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"matched":false,"game":{"id":"g-open","variant":"scrabble_en","status":"open","players":2,"to_move":0,"seats":[]}}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
|
|
|
|
b := flatbuffers.NewBuilder(32)
|
|
v := b.CreateString("scrabble_en")
|
|
fb.EnqueueRequestStart(b)
|
|
fb.EnqueueRequestAddVariant(b, v)
|
|
b.Finish(fb.EnqueueRequestEnd(b))
|
|
|
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
m := fb.GetRootAsMatchResult(payload, 0)
|
|
if m.Matched() {
|
|
t.Fatal("an open game awaiting an opponent must report matched=false")
|
|
}
|
|
if g := m.Game(nil); g == nil || string(g.Id()) != "g-open" {
|
|
t.Fatalf("open game must be on the wire even when not matched: %+v", g)
|
|
}
|
|
}
|
|
|
|
func TestDomainErrorSurfacesBackendCode(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusConflict)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"not_your_turn","message":"nope"}}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgGameState)
|
|
|
|
b := flatbuffers.NewBuilder(32)
|
|
gid := b.CreateString("g-1")
|
|
fb.StateRequestStart(b)
|
|
fb.StateRequestAddGameId(b, gid)
|
|
b.Finish(fb.StateRequestEnd(b))
|
|
|
|
_, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
|
|
if err == nil {
|
|
t.Fatal("expected backend error")
|
|
}
|
|
code, ok := transcode.DomainCode(err)
|
|
if !ok || code != "not_your_turn" {
|
|
t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok)
|
|
}
|
|
}
|
|
|
|
// gameActionPayload builds a GameActionRequest payload (pass / resign / hint / etc.).
|
|
func gameActionPayload(gameID string) []byte {
|
|
b := flatbuffers.NewBuilder(32)
|
|
gid := b.CreateString(gameID)
|
|
fb.GameActionRequestStart(b)
|
|
fb.GameActionRequestAddGameId(b, gid)
|
|
b.Finish(fb.GameActionRequestEnd(b))
|
|
return b.FinishedBytes()
|
|
}
|
|
|
|
// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the
|
|
// game's /hide endpoint with the caller's id, and echoes an Ack.
|
|
func TestHideGameForwardsToBackend(t *testing.T) {
|
|
var hit bool
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
hit = true
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/hide" {
|
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
|
t.Errorf("X-User-ID = %q, want u-1", got)
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, ok := reg.Lookup(transcode.MsgGameHide)
|
|
if !ok {
|
|
t.Fatal("game.hide not registered")
|
|
}
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
if !hit {
|
|
t.Error("backend not called")
|
|
}
|
|
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
|
|
t.Error("ack not ok")
|
|
}
|
|
}
|
|
|
|
// TestMarkChatReadForwardsToBackend checks chat.read reuses GameActionRequest, POSTs to the
|
|
// game's /chat/read endpoint with the caller's id, and echoes an Ack.
|
|
func TestMarkChatReadForwardsToBackend(t *testing.T) {
|
|
var hit bool
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
hit = true
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/chat/read" {
|
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
|
t.Errorf("X-User-ID = %q, want u-1", got)
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, ok := reg.Lookup(transcode.MsgChatRead)
|
|
if !ok {
|
|
t.Fatal("chat.read not registered")
|
|
}
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
if !hit {
|
|
t.Error("backend not called")
|
|
}
|
|
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
|
|
t.Error("ack not ok")
|
|
}
|
|
}
|
|
|
|
func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-User-ID"); got != "u-9" {
|
|
t.Errorf("X-User-ID = %q, want u-9", got)
|
|
}
|
|
if r.URL.Path != "/api/v1/user/games" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7,"deleted":true}]}],"at_game_limit":true}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgGamesList)
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-9"})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
gl := fb.GetRootAsGameList(payload, 0)
|
|
if gl.GamesLength() != 1 {
|
|
t.Fatalf("games length = %d, want 1", gl.GamesLength())
|
|
}
|
|
var g fb.GameView
|
|
gl.Games(&g, 0)
|
|
if string(g.Id()) != "g-1" {
|
|
t.Errorf("game id = %q, want g-1", g.Id())
|
|
}
|
|
if g.LastActivityUnix() != 1717000000 {
|
|
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
|
}
|
|
if !g.UnreadChat() {
|
|
t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)")
|
|
}
|
|
if !g.UnreadMessages() {
|
|
t.Error("unread_messages = false, want true (the backend flagged an unread message)")
|
|
}
|
|
var seat fb.SeatView
|
|
g.Seats(&seat, 1)
|
|
if string(seat.DisplayName()) != "Ann" {
|
|
t.Errorf("seat display name = %q, want Ann", seat.DisplayName())
|
|
}
|
|
if !seat.Deleted() {
|
|
t.Error("seat deleted = false, want true (the backend marked the seat's account deleted)")
|
|
}
|
|
var own fb.SeatView
|
|
g.Seats(&own, 0)
|
|
if own.Deleted() {
|
|
t.Error("seat deleted = true for a live account, want false")
|
|
}
|
|
if !gl.AtGameLimit() {
|
|
t.Error("at_game_limit = false, want true (the backend reported the cap)")
|
|
}
|
|
}
|
|
|
|
func TestPassRoundTrip(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/user/games/g-2/pass" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"move":{"player":0,"action":"pass"},"game":{"id":"g-2","status":"active","seats":[{"seat":0,"account_id":"u-1","display_name":"You"}]}}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgGamePass)
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-2")})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
mr := fb.GetRootAsMoveResult(payload, 0)
|
|
var move fb.MoveRecord
|
|
mr.Move(&move)
|
|
if string(move.Action()) != "pass" {
|
|
t.Errorf("action = %q, want pass", move.Action())
|
|
}
|
|
}
|
|
|
|
func TestHintRoundTrip(t *testing.T) {
|
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/user/games/g-3/hint" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2,"wallet_balance":1}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
reg := transcode.NewRegistry(backend, nil)
|
|
op, _ := reg.Lookup(transcode.MsgGameHint)
|
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-3")})
|
|
if err != nil {
|
|
t.Fatalf("handler: %v", err)
|
|
}
|
|
hr := fb.GetRootAsHintResult(payload, 0)
|
|
if hr.HintsRemaining() != 2 || hr.WalletBalance() != 1 {
|
|
t.Errorf("hint decoded wrong: hints=%d wallet=%d", hr.HintsRemaining(), hr.WalletBalance())
|
|
}
|
|
var move fb.MoveRecord
|
|
hr.Move(&move)
|
|
if move.Score() != 9 {
|
|
t.Errorf("hint move score = %d, want 9", move.Score())
|
|
}
|
|
}
|