d733ce3119
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.
113 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|