Files
scrabble-game/backend/internal/inttest/banner_console_test.go
T
Ilia Denisov 57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
feat(telegram,game): single bot + per-user variant preferences
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00

277 lines
11 KiB
Go

//go:build integration
package inttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/server"
)
// bannerServer assembles a console-capable server with the ads domain wired.
func bannerServer(t *testing.T) (*server.Server, *ads.Service) {
t.Helper()
adsSvc := ads.NewService(ads.NewStore(testDB))
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Ads: adsSvc,
})
return srv, adsSvc
}
// findCampaign returns the id of the campaign with the given name, or fails.
func findCampaign(t *testing.T, svc *ads.Service, name string) uuid.UUID {
t.Helper()
all, err := svc.ListCampaigns(context.Background())
if err != nil {
t.Fatalf("list campaigns: %v", err)
}
for _, c := range all {
if c.Name == name {
return c.ID
}
}
t.Fatalf("campaign %q not found", name)
return uuid.Nil
}
// defaultCampaign returns the seeded perpetual default campaign.
func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign {
t.Helper()
all, err := svc.ListCampaigns(context.Background())
if err != nil {
t.Fatalf("list campaigns: %v", err)
}
for _, c := range all {
if c.IsDefault {
return c
}
}
t.Fatal("no default campaign seeded")
return ads.Campaign{}
}
// TestBannerConsoleCRUD drives the /_gm/banners console over HTTP against real
// stores: pages render, the CSRF guard bites, validation and the default-campaign
// protection hold, and a campaign + message round-trips through create/edit/
// reorder/delete.
func TestBannerConsoleCRUD(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
// The list renders and shows the seeded default campaign.
if code, body := consoleDo(h, http.MethodGet, base+"/banners", "", ""); code != http.StatusOK || !strings.Contains(body, "Default (house)") {
t.Fatalf("banners list = %d, has default=%v", code, strings.Contains(body, "Default (house)"))
}
name := "Promo-" + uuid.NewString()[:8]
// CSRF: a create without a same-origin header is rejected.
if code, _ := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", ""); code != http.StatusForbidden {
t.Fatalf("create without origin = %d, want 403", code)
}
// Validation: weight out of range is refused.
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name=Bad&weight=0&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
t.Fatalf("create weight=0 = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
}
// A valid create persists.
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
}
id := findCampaign(t, adsSvc, name)
// The detail page renders.
if code, body := consoleDo(h, http.MethodGet, base+"/banners/"+id.String(), "", ""); code != http.StatusOK || !strings.Contains(body, name) {
t.Fatalf("detail = %d, has name=%v", code, strings.Contains(body, name))
}
// A message needs both languages.
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
t.Fatalf("add half message = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
}
// A bilingual message is added.
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=Привет", origin); code != http.StatusOK || !strings.Contains(body, "Added") {
t.Fatalf("add message = %d, has Added=%v", code, strings.Contains(body, "Added"))
}
cm, err := adsSvc.Campaign(ctx, id)
if err != nil || len(cm.Messages) != 1 {
t.Fatalf("campaign messages = %d (err %v), want 1", len(cm.Messages), err)
}
mid := cm.Messages[0].ID
// Edit it.
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String(), "body_en=Hi&body_ru=Привет!", origin); code != http.StatusOK {
t.Fatalf("edit message = %d", code)
}
if cm, _ := adsSvc.Campaign(ctx, id); cm.Messages[0].BodyEn != "Hi" {
t.Fatalf("edit did not persist: %q", cm.Messages[0].BodyEn)
}
// Reorder is a no-op for a single message but still succeeds.
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/move", "dir=down", origin); code != http.StatusOK {
t.Fatalf("move message = %d", code)
}
// Delete the message, then the campaign.
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/delete", "", origin); code != http.StatusOK {
t.Fatalf("delete message = %d", code)
}
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
t.Fatalf("delete campaign = %d, has Deleted=%v", code, strings.Contains(body, "Deleted"))
}
if _, err := adsSvc.Campaign(ctx, id); err == nil {
t.Fatal("campaign still present after delete")
}
// The default campaign cannot be deleted.
def := defaultCampaign(t, adsSvc)
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") {
t.Fatalf("delete default = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved"))
}
if _, err := adsSvc.Campaign(ctx, def.ID); err != nil {
t.Fatalf("default campaign gone after refused delete: %v", err)
}
}
// TestBannerMessageOwnership guards the campaign/message pairing: a message may
// only be edited or deleted through the URL of the campaign it belongs to. This
// closes a default-protection bypass — deleting the default's last message via an
// unrelated campaign's URL must be refused.
func TestBannerMessageOwnership(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
def := defaultCampaign(t, adsSvc)
if len(def.Messages) == 0 {
t.Fatal("default campaign has no seeded message")
}
defMsg := def.Messages[0].ID
// A separate timed campaign whose URL we will (mis)use.
otherID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{Name: "Own-" + uuid.NewString()[:8], Weight: 10, Enabled: true})
if err != nil {
t.Fatalf("create campaign: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, otherID) })
// Deleting the default's message through the other campaign's URL is refused,
// and the default keeps its message (the bypass is closed).
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
t.Fatalf("cross-campaign delete = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
}
// Editing it through the wrong campaign is refused too.
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String(), "body_en=Hijacked&body_ru=Взлом", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
t.Fatalf("cross-campaign edit = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
}
// The default's message is intact.
again := defaultCampaign(t, adsSvc)
if len(again.Messages) != len(def.Messages) {
t.Fatalf("default messages = %d, want %d (unchanged)", len(again.Messages), len(def.Messages))
}
if again.Messages[0].BodyEn != def.Messages[0].BodyEn {
t.Fatalf("default message body changed: %q -> %q", def.Messages[0].BodyEn, again.Messages[0].BodyEn)
}
}
// profileBanner is the banner block of the profile.get JSON response.
type profileBanner struct {
Banner *struct {
Campaigns []struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
} `json:"campaigns"`
Timings struct {
HoldMs int `json:"hold_ms"`
} `json:"timings"`
} `json:"banner"`
}
// TestBannerProfileEligibility checks the profile.get banner block follows
// eligibility: a free account sees it, the no_banner role and a non-empty hint
// wallet each suppress it.
func TestBannerProfileEligibility(t *testing.T) {
ctx := context.Background()
srv, _ := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
getBanner := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account with an empty hint wallet and no role is eligible.
p := getBanner()
if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
t.Fatalf("eligible account: banner=%v", p.Banner)
}
// The no_banner role suppresses it unconditionally.
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
if p := getBanner(); p.Banner != nil {
t.Fatal("no_banner role still shows the banner")
}
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// A non-empty hint wallet suppresses it.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
if p := getBanner(); p.Banner != nil {
t.Fatal("a non-empty hint wallet still shows the banner")
}
}
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) {
srv, _ := bannerServer(t)
id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true,` +
`"variant_preferences":["erudit_ru"]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
req.Header.Set("X-User-ID", id.String())
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("update profile = %d: %s", rec.Code, rec.Body.String())
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode: %v", err)
}
if p.Banner == nil || len(p.Banner.Campaigns) == 0 {
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
}
}