Files
scrabble-game/backend/internal/ads/ads_test.go
T
Ilia Denisov 0946a3f66c
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
feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
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).
2026-06-15 23:00:19 +02:00

184 lines
5.5 KiB
Go

package ads
import (
"reflect"
"testing"
"time"
)
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
// msg builds a one-message slice with distinct bodies so resolution and
// campaign identity are visible in assertions.
msg := func(tag string) []Message {
return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}}
}
def := func(msgs []Message) Campaign {
return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs}
}
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
}{
{
name: "default only reduces to weight 1",
campaigns: []Campaign{def(msg("house"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "default fills the remainder",
campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))},
lang: "en",
// timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"promo-en"}},
{Weight: 7, Messages: []string{"house-en"}},
},
},
{
name: "timed fills 100, default dropped",
campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}},
},
{
name: "timed over 100, default dropped, proportional",
campaigns: []Campaign{
def(msg("house")),
timed("a", 60, nil, nil, msg("a")),
timed("b", 80, nil, nil, msg("b")),
},
lang: "en",
// default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 4, Messages: []string{"b-en"}},
},
},
{
name: "future and past windows exclude timed",
campaigns: []Campaign{
def(msg("house")),
timed("future", 50, &future, nil, msg("future")),
timed("past", 50, nil, &past, msg("past")),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "active window included",
campaigns: []Campaign{
def(msg("house")),
timed("live", 25, &past, &future, msg("live")),
},
lang: "en",
// timed 25, default 75; gcd 25 -> 1 and 3.
want: []ActiveCampaign{
{Weight: 1, Messages: []string{"live-en"}},
{Weight: 3, Messages: []string{"house-en"}},
},
},
{
name: "campaign without messages is skipped and not counted",
campaigns: []Campaign{
def(msg("house")),
timed("empty", 40, nil, nil, nil),
},
lang: "en",
// empty campaign skipped; default fills full 100 -> reduced to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "russian bodies resolved",
campaigns: []Campaign{def(msg("house"))},
lang: "ru",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}},
},
{
name: "three timed split by gcd",
campaigns: []Campaign{
def(msg("house")),
timed("a", 50, nil, nil, msg("a")),
timed("b", 30, nil, nil, msg("b")),
timed("c", 20, nil, nil, msg("c")),
},
lang: "en",
// sum 100, default dropped; gcd 10 -> 5,3,2.
want: []ActiveCampaign{
{Weight: 5, Messages: []string{"a-en"}},
{Weight: 3, Messages: []string{"b-en"}},
{Weight: 2, Messages: []string{"c-en"}},
},
},
{
name: "campaign with multiple messages keeps order",
campaigns: []Campaign{
def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
}
}
func TestGCD(t *testing.T) {
tests := []struct{ a, b, want int }{
{0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1},
}
for _, tt := range tests {
if got := gcd(tt.a, tt.b); got != tt.want {
t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}