Files
Ilia Denisov d733ce3119
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s
Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
2026-06-03 19:47:40 +02:00

113 lines
3.5 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)
}
}
func TestNotificationPayloadRoundTrips(t *testing.T) {
uid := uuid.New()
in := notify.Notification(uid, notify.NotifyFriendRequest)
if in.UserID != uid || in.Kind != notify.KindNotification || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if got := string(ev.Kind()); got != notify.NotifyFriendRequest {
t.Fatalf("notification sub-kind = %q, want %q", got, notify.NotifyFriendRequest)
}
}