Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s

New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
This commit is contained in:
Ilia Denisov
2026-06-02 22:38:24 +02:00
parent 104eb2a978
commit 408da3f201
98 changed files with 8134 additions and 57 deletions
+100
View File
@@ -0,0 +1,100 @@
package notify_test
import (
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
fb "scrabble/pkg/fbs/scrabblefb"
)
func TestHubDeliversToSubscriber(t *testing.T) {
h := notify.NewHub(4)
ch, cancel := h.Subscribe()
defer cancel()
want := notify.Intent{UserID: uuid.New(), Kind: notify.KindYourTurn, Payload: []byte{1, 2, 3}}
h.Publish(want)
select {
case got := <-ch:
if got.Kind != want.Kind || got.UserID != want.UserID {
t.Fatalf("delivered %+v, want %+v", got, want)
}
case <-time.After(time.Second):
t.Fatal("no delivery within timeout")
}
}
func TestHubDropsWhenSubscriberBufferFull(t *testing.T) {
h := notify.NewHub(1)
ch, cancel := h.Subscribe()
defer cancel()
in := notify.Intent{UserID: uuid.New(), Kind: notify.KindNudge}
// Buffer holds one; the second and third are dropped, and Publish must not block.
h.Publish(in, in, in)
if got := len(ch); got != 1 {
t.Fatalf("buffered %d intents, want 1 (rest dropped)", got)
}
}
func TestHubUnsubscribeClosesChannel(t *testing.T) {
h := notify.NewHub(2)
ch, cancel := h.Subscribe()
cancel()
if _, ok := <-ch; ok {
t.Fatal("channel should be closed after unsubscribe")
}
// Publishing after unsubscribe must be safe (no panic, no delivery).
h.Publish(notify.Intent{Kind: notify.KindMatchFound})
}
func TestNopPublisherDiscards(t *testing.T) {
var p notify.Publisher = notify.Nop{}
p.Publish(notify.Intent{Kind: notify.KindYourTurn}) // must not panic
}
func TestYourTurnPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0))
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsYourTurnEvent(in.Payload, 0)
if got := string(ev.GameId()); got != gid.String() {
t.Fatalf("game id = %q, want %q", got, gid)
}
if got := ev.DeadlineUnix(); got != 1717000000 {
t.Fatalf("deadline = %d, want 1717000000", got)
}
}
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130)
if in.Kind != notify.KindOpponentMoved {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
}
}
func TestChatMessagePayloadRoundTrips(t *testing.T) {
uid, gid, sid := uuid.New(), uuid.New(), uuid.New()
in := notify.ChatMessage(uid, gid, sid, "msg-1", "message", "hi", time.Unix(1717000001, 0))
if in.Kind != notify.KindChatMessage {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsChatMessage(in.Payload, 0)
if string(ev.Id()) != "msg-1" || string(ev.SenderId()) != sid.String() || string(ev.Body()) != "hi" || ev.CreatedAtUnix() != 1717000001 {
t.Fatalf("decoded wrong chat message: %+v", ev)
}
}