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,183 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user