Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
This commit is contained in:
@@ -104,3 +104,113 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
}
|
||||
return confirmed
|
||||
}
|
||||
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван" {
|
||||
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Error("NotificationsInAppOnly should default to true")
|
||||
}
|
||||
|
||||
// A later login with different fields returns the same account, unchanged.
|
||||
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision telegram: %v", err)
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
|
||||
// client language falls back to the account default rather than failing the
|
||||
// language CHECK.
|
||||
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "en" {
|
||||
t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||
// including for a guest that carries no identity.
|
||||
func TestIdentityExternalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram)
|
||||
if err != nil {
|
||||
t.Fatalf("identity external id: %v", err)
|
||||
}
|
||||
if got != ext {
|
||||
t.Errorf("external id = %q, want %q", got, ext)
|
||||
}
|
||||
if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("email lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("guest lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Fatal("default should be in-app-only true")
|
||||
}
|
||||
updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
|
||||
DisplayName: "Player",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
NotificationsInAppOnly: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
}
|
||||
if updated.NotificationsInAppOnly {
|
||||
t.Error("update did not clear NotificationsInAppOnly")
|
||||
}
|
||||
got, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.NotificationsInAppOnly {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,13 @@ func TestTimeoutSweep(t *testing.T) {
|
||||
}
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
// Disable the to-move account's away window: with the default 00:00–07:00
|
||||
// window the sweeper (correctly) declines to time out a player whose deadline
|
||||
// fell while they were asleep, which made this test fail whenever CI ran with
|
||||
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
|
||||
// deterministic regardless of the time of day.
|
||||
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||||
|
||||
// The sweep is global over the shared pool; assert the target game itself,
|
||||
// not the count, since other tests leave active games behind.
|
||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
|
||||
Reference in New Issue
Block a user