408da3f201
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.
101 lines
3.0 KiB
Go
101 lines
3.0 KiB
Go
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)
|
|
}
|
|
}
|