feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Turn the gated-off mock banner into a real advertising subsystem (backend + admin half; the UI rotation lands in PR2). - internal/ads: campaigns (percent weight + validity window; a perpetual, undeletable default that fills the remainder up to 100%), 1..N bilingual messages (en+ru), global display timings; ActiveSet computes the window-filtered, default-remainder, GCD-reduced, language-resolved rotation feed. Smooth-weighted-round-robin math is unit-tested. - migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded default campaign + house message + default timings. - eligibility = !paid_account && hint_balance==0 && !no_banner role (new role; guests qualify). The resolved feed rides the profile.get response (no new RPC, works for guests, nothing distinct to filter); language by service_language. - live update: a notify `banner` sub-kind (re-poll signal) published when an operator grants hints or grants/revokes no_banner, so the client shows/hides in place. - admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD with reorder, default protection, clamped timings. - wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it. - docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker (incl. the deferred app.load aggregator note).
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user