feat(variants): default a registered account to Erudit + Russian Scrabble
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s

New Game opened on a single variant for everyone, which reads poorly on VK
where Russian Scrabble is the familiar game. A registered account now starts
with both Russian-alphabet games, so the picker offers a real choice with no
pre-selection.

A guest stays on Erudit alone and is invited to register for the rest: the
device-local guest has no profile at all, so the client-side fallback has to
keep matching the server. Registering promotes the set, but only while the
guest still carries the untouched guest default. Existing accounts are not
backfilled — the set they carry may be a deliberate choice.

The Telegram promo start-param becomes additive rather than a replacement,
with English Scrabble withheld from a Russian-speaking arrival; otherwise the
English campaign link would have taken Russian Scrabble away from every
account it onboarded.
This commit is contained in:
Ilia Denisov
2026-07-27 20:38:09 +02:00
parent 69b1a50644
commit b6d88da78c
19 changed files with 338 additions and 56 deletions
+76
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"regexp"
"slices"
"testing"
"time"
@@ -266,6 +267,81 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
}
}
// TestVariantPreferenceDefaults covers the variant set an account is born with and how
// registration widens it: a registered account starts on both Russian-alphabet games
// (the column default), a guest on Эрудит alone, and promoting the guest lifts it to the
// registered default — but only while the guest still carries that untouched set, and
// never for an account that is already durable.
func TestVariantPreferenceDefaults(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
equal := func(t *testing.T, what string, got, want []string) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("%s = %v, want %v", what, got, want)
}
}
// A registered account (any identity kind) takes the column default.
durable, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
equal(t, "registered account variants", durable.VariantPreferences, account.DefaultVariantPreferences)
// A guest is deliberately narrower.
guest, err := store.ProvisionGuest(ctx, "", "ru")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
equal(t, "guest variants", guest.VariantPreferences, account.GuestVariantPreferences)
// Registering the guest promotes it to a durable account on the registered default.
if err := store.ClearGuest(ctx, guest.ID); err != nil {
t.Fatalf("clear guest: %v", err)
}
promoted, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("reload promoted guest: %v", err)
}
if promoted.IsGuest {
t.Error("promoted guest is still flagged is_guest")
}
equal(t, "promoted guest variants", promoted.VariantPreferences, account.DefaultVariantPreferences)
// A guest who picked their own set keeps it through the promotion.
picky, err := store.ProvisionGuest(ctx, "", "en")
if err != nil {
t.Fatalf("provision picky guest: %v", err)
}
if _, err := store.SetVariantPreferences(ctx, picky.ID, []string{"scrabble_en"}); err != nil {
t.Fatalf("set picky guest variants: %v", err)
}
if err := store.ClearGuest(ctx, picky.ID); err != nil {
t.Fatalf("clear picky guest: %v", err)
}
reloaded, err := store.GetByID(ctx, picky.ID)
if err != nil {
t.Fatalf("reload picky guest: %v", err)
}
equal(t, "picky guest variants", reloaded.VariantPreferences, []string{"scrabble_en"})
// An account that is already durable is never rewritten — an established player who
// settled on Эрудит alone stays there.
if _, err := store.SetVariantPreferences(ctx, durable.ID, []string{"erudit_ru"}); err != nil {
t.Fatalf("narrow durable variants: %v", err)
}
if err := store.ClearGuest(ctx, durable.ID); err != nil {
t.Fatalf("clear guest on a durable account: %v", err)
}
established, err := store.GetByID(ctx, durable.ID)
if err != nil {
t.Fatalf("reload durable account: %v", err)
}
equal(t, "established account variants", established.VariantPreferences, []string{"erudit_ru"})
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
// client language falls back to the account default rather than failing the
// language CHECK.
+3 -2
View File
@@ -135,10 +135,11 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB)
acc := provisionAccount(t)
// A fresh account defaults to Erudit only (the DB-level column default).
// A fresh registered account defaults to both Russian-alphabet games (the DB-level
// column default); the lifecycle around it is covered by TestVariantPreferenceDefaults.
if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err)
} else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) {
} else if want := account.DefaultVariantPreferences; !slices.Equal(def.VariantPreferences, want) {
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
}
+25 -16
View File
@@ -19,10 +19,11 @@ import (
)
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
// to confirm a promo deep-link start-param seeds a brand-new account's variant
// preferences (English Scrabble alongside the default Erudit), that a new account with no
// such payload keeps the Erudit-only default, and that an existing account is never
// re-seeded on a later login (the new-user-only contract).
// to confirm a promo deep-link start-param widens a brand-new account's variant
// preferences English Scrabble on top of the default pair, but only for a
// non-Russian-speaking arrival — that a new account with no such payload keeps the
// default, and that an existing account is never re-seeded on a later login (the
// new-user-only contract).
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
@@ -33,8 +34,8 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
})
h := srv.Handler()
post := func(ext, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
post := func(ext, language, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"` + language + `","first_name":"Promo"`
if startParam != "" {
body += `,"start_param":"` + startParam + `"`
}
@@ -56,25 +57,33 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
return acc.VariantPreferences
}
// A brand-new account reached through a promo deep-link is seeded with English
// Scrabble alongside the default Erudit.
// An English-speaking arrival on a promo deep-link gains English Scrabble on top of
// the default pair, so a native English player is not lost at the door.
promoExt := "tg-" + uuid.NewString()
post(promoExt, "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("promo new account variants = %v, want %v", got, want)
post(promoExt, "en", "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("english promo account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the Erudit-only default.
// A Russian-speaking arrival on the same link stays on the default pair: the two
// Russian-alphabet games serve them, and English would only clutter New Game.
ruExt := "tg-" + uuid.NewString()
post(ruExt, "ru", "verudit_ru-scrabble_en")
if got, want := reload(ruExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) {
t.Errorf("russian promo account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the default.
plainExt := "tg-" + uuid.NewString()
post(plainExt, "")
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
post(plainExt, "en", "")
if got, want := reload(plainExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) {
t.Errorf("plain new account variants = %v, want %v", got, want)
}
// A later login of the promo account, even via a different payload, must not re-seed:
// the seed is first-contact only.
post(promoExt, "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
post(promoExt, "en", "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
}
}
@@ -16,10 +16,10 @@ import (
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
// or create a friend invitation only in a variant they have enabled in their profile
// (a fresh account defaults to Erudit only), and any other variant is refused with 400.
// The complementary case — an invited friend accepting an invitation in a variant they
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
// accepts an English invitation over HTTP.
// (a fresh account defaults to the two Russian-alphabet games), and any other variant is
// refused with 400. The complementary case — an invited friend accepting an invitation in
// a variant they have NOT enabled — is exercised by TestGameLimitGate, where a
// default-preference human accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t)
srv := server.New(":0", server.Deps{