//go:build integration package inttest import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/google/uuid" "go.uber.org/zap/zaptest" "scrabble/backend/internal/account" "scrabble/backend/internal/notify" "scrabble/backend/internal/server" "scrabble/backend/internal/session" ) // chatAccessBody mirrors the backend's /internal/chat-access JSON for the test. type chatAccessBody struct { ExternalID string `json:"external_id"` Registered bool `json:"registered"` Eligible bool `json:"eligible"` } // chatAccess issues the gateway-internal chat-access query and asserts a 200. func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String()) } var b chatAccessBody if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { t.Fatalf("decode chat-access: %v", err) } return b } // TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP: // the registered/suspended/chat_muted truth table by Telegram identity and by account // id, the suspension dominating the chat_muted role, an unknown identity reported // unregistered, and an account with no Telegram identity carrying an empty external_id. func TestChatAccessResolver(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) ext := "tg-" + uuid.NewString() acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") if err != nil { t.Fatalf("provision: %v", err) } id := acc.ID byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) } byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) } // A registered, unsuspended, unmuted account is eligible by either address, and the // account-id query resolves back to its Telegram identity. if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext { t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b) } if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext { t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b) } // A suspension mutes; a lift restores. if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil { t.Fatalf("suspend: %v", err) } if b := byExt(); !b.Registered || b.Eligible { t.Fatalf("suspended = %+v, want registered but not eligible", b) } if err := accounts.LiftSuspension(ctx, id); err != nil { t.Fatalf("lift: %v", err) } if b := byExt(); !b.Eligible { t.Fatalf("after lift = %+v, want eligible", b) } // The chat_muted role mutes independently; a revoke restores. if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil { t.Fatalf("grant chat_muted: %v", err) } if b := byExt(); !b.Registered || b.Eligible { t.Fatalf("chat_muted = %+v, want registered but not eligible", b) } // Suspension dominates: while chat_muted is set, lifting a concurrent suspension // must not re-grant chat (the role still mutes). if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil { t.Fatalf("suspend over mute: %v", err) } if b := byExt(); b.Eligible { t.Fatalf("suspended+muted = %+v, want not eligible", b) } if err := accounts.LiftSuspension(ctx, id); err != nil { t.Fatalf("lift over mute: %v", err) } if b := byExt(); b.Eligible { t.Fatalf("lifted but still muted = %+v, want not eligible", b) } if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil { t.Fatalf("revoke chat_muted: %v", err) } if b := byExt(); !b.Eligible { t.Fatalf("after revoke = %+v, want eligible", b) } // An unknown Telegram identity is unregistered (and thus left muted). if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible { t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b) } // An account with no Telegram identity (a guest) carries an empty external_id, so // the gateway has nothing to gate. guest := provisionGuest(t) if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered { t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b) } // A request naming neither address is a bad request. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("empty query = %d, want 400", rec.Code) } } // captureNotifier records every published intent so a test can assert which live // events a console action emitted. type captureNotifier struct { mu sync.Mutex intents []notify.Intent } func (c *captureNotifier) Publish(in ...notify.Intent) { c.mu.Lock() defer c.mu.Unlock() c.intents = append(c.intents, in...) } // count returns how many intents of kind addressed to user were captured. func (c *captureNotifier) count(user uuid.UUID, kind string) int { c.mu.Lock() defer c.mu.Unlock() n := 0 for _, in := range c.intents { if in.UserID == user && in.Kind == kind { n++ } } return n } // TestChatAccessPublishedOnModeration drives the admin console and asserts each // moderation action that can change chat eligibility — block, unblock, and the // chat_muted role grant/revoke — emits the chat_access_changed signal the gateway // turns into a chat-gate command. func TestChatAccessPublishedOnModeration(t *testing.T) { notifier := &captureNotifier{} srv := server.New(":0", server.Deps{ Logger: zaptest.NewLogger(t), DB: testDB, Accounts: account.NewStore(testDB), Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), Notifier: notifier, }) h := srv.Handler() id := provisionAccount(t) base := "http://admin.test/_gm/users/" + id.String() const origin = "http://admin.test" steps := []struct { name, path, body string want string }{ {"block", "/block", "duration=permanent", "Blocked"}, {"unblock", "/unblock", "", "Unblocked"}, {"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"}, {"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"}, } for i, s := range steps { code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin) if code != http.StatusOK || !strings.Contains(body, s.want) { t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want)) } if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 { t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1) } } } // TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact // (the sessions/telegram endpoint creating the account) emits chat_access_changed — // the re-grant for a user who joined the moderated chat before registering — and that // a repeat login does not re-emit. func TestChatAccessPublishedOnFirstRegistration(t *testing.T) { notifier := &captureNotifier{} srv := server.New(":0", server.Deps{ Logger: zaptest.NewLogger(t), DB: testDB, Accounts: account.NewStore(testDB), Sessions: session.NewService(session.NewStore(testDB), session.NewCache()), Notifier: notifier, }) h := srv.Handler() ext := "tg-" + uuid.NewString() post := func() { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`)) req.Header.Set("Content-Type", "application/json") h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String()) } } post() acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext) if err != nil { t.Fatalf("lookup: %v", err) } if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 { t.Fatalf("first registration: chat_access_changed count = %d, want 1", got) } // A repeat login (the account already exists) must not re-emit. post() if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 { t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got) } } // TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted // temporary block whose expiry falls in the window is returned, while one outside the // window, a permanent block, and a lifted block are not. func TestSuspensionsExpiredBetween(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) // A temporary block whose expiry already lapsed at a known instant. tempID := provisionAccount(t) expiry := time.Now().Add(-time.Hour).Truncate(time.Second) if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil { t.Fatalf("suspend temp: %v", err) } contains := func(ids []uuid.UUID, want uuid.UUID) bool { for _, id := range ids { if id == want { return true } } return false } // A window straddling the expiry returns the account. got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute)) if err != nil { t.Fatalf("expired between: %v", err) } if !contains(got, tempID) { t.Fatalf("window over expiry missing the lapsed block %s", tempID) } // A window entirely after the expiry does not. got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute)) if err != nil { t.Fatalf("expired between (after): %v", err) } if contains(got, tempID) { t.Fatalf("window after expiry should not return %s", tempID) } // A permanent block never appears, even in a wide window. permID := provisionAccount(t) if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil { t.Fatalf("suspend perm: %v", err) } // A lifted block does not appear either. The block must still be in force when lifted // (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the // wide window below still covers it — yet lifted_at excludes it. liftID := provisionAccount(t) liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second) if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil { t.Fatalf("suspend lift: %v", err) } if err := accounts.LiftSuspension(ctx, liftID); err != nil { t.Fatalf("lift: %v", err) } wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour)) if err != nil { t.Fatalf("expired between (wide): %v", err) } if contains(wide, permID) { t.Fatalf("permanent block %s must not be reported as expired", permID) } if contains(wide, liftID) { t.Fatalf("lifted block %s must not be reported as expired", liftID) } }