owpengram-server/internal/rpc/account_themes_rpc_test.go

463 lines
17 KiB
Go

package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
themesapp "telesrv/internal/app/themes"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newThemeRouter(t *testing.T, files *fakeFiles) *Router {
t.Helper()
return New(Config{}, Deps{
Files: files,
Themes: themesapp.NewService(memory.NewThemeStore()),
}, zaptest.NewLogger(t), clock.System)
}
func mustEncodeTheme(t *testing.T, th *tg.Theme) {
t.Helper()
var b bin.Buffer
if err := th.Encode(&b); err != nil {
t.Fatalf("theme encode failed (likely nil base_theme): %v", err)
}
}
// TestAccountUploadThemeReturnsDocument 验证 uploadTheme 把上传文件落成可下载 Document 返回,
// 且校验 mime 前缀。
func TestAccountUploadThemeReturnsDocument(t *testing.T) {
ctx := WithUserID(context.Background(), 1000001)
r := newThemeRouter(t, &fakeFiles{})
req := &tg.AccountUploadThemeRequest{FileName: "theme.attheme", MimeType: "application/x-tgtheme-android"}
req.File = &tg.InputFile{ID: 42, Parts: 1, Name: "theme.attheme"}
got, err := r.onAccountUploadTheme(ctx, req)
if err != nil {
t.Fatalf("uploadTheme err = %v", err)
}
doc, ok := got.(*tg.Document)
if !ok {
t.Fatalf("uploadTheme = %T, want *tg.Document", got)
}
if doc.ID == 0 {
t.Fatalf("uploaded document id = 0, want non-zero downloadable id")
}
// 非主题 mime 前缀拒绝。
bad := &tg.AccountUploadThemeRequest{FileName: "x", MimeType: "image/png"}
bad.File = &tg.InputFile{ID: 7, Parts: 1, Name: "x"}
if _, err := r.onAccountUploadTheme(ctx, bad); !tgerr.Is(err, "THEME_MIME_INVALID") {
t.Fatalf("bad mime err = %v, want THEME_MIME_INVALID", err)
}
}
// TestAccountCreateThemeFullFlow 验证 createTheme 自动分配 slug、设 creator=true、回填 document,
// getTheme 按 slug 取回带 document;saveTheme/installTheme 返回 true。
func TestAccountCreateThemeFullFlow(t *testing.T) {
const userID = 1000002
ctx := WithUserID(context.Background(), userID)
files := &fakeFiles{docs: map[int64]domain.Document{
555: {ID: 555, AccessHash: 5, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 1234},
}}
r := newThemeRouter(t, files)
create := &tg.AccountCreateThemeRequest{Slug: "", Title: "My Theme"}
create.SetDocument(&tg.InputDocument{ID: 555, AccessHash: 5})
th, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme err = %v", err)
}
if th.ID == 0 || th.AccessHash == 0 {
t.Fatalf("created theme id/access_hash = %d/%d, want non-zero", th.ID, th.AccessHash)
}
if !th.GetCreator() {
t.Fatalf("created theme creator = false, want true (so client routes re-upload to updateTheme)")
}
if th.Slug == "" {
t.Fatalf("created theme slug empty, want auto-assigned slug")
}
if doc, ok := th.GetDocument(); !ok {
t.Fatalf("created theme has no document, want document (deep-link addtheme needs it)")
} else if d, ok := doc.(*tg.Document); !ok || d.ID != 555 {
t.Fatalf("created theme document = %#v, want *tg.Document id 555", doc)
}
mustEncodeTheme(t, th)
// getTheme by slug 取回,仍带 document。
get := &tg.AccountGetThemeRequest{Format: "android"}
get.Theme = &tg.InputThemeSlug{Slug: th.Slug}
got, err := r.onAccountGetTheme(ctx, get)
if err != nil {
t.Fatalf("getTheme by slug err = %v", err)
}
if got.ID != th.ID {
t.Fatalf("getTheme id = %d, want %d", got.ID, th.ID)
}
if _, ok := got.GetDocument(); !ok {
t.Fatalf("getTheme by slug missing document → client would show ThemeNotSupported")
}
mustEncodeTheme(t, got)
// saveTheme + installTheme by id+access_hash。
save := &tg.AccountSaveThemeRequest{Theme: &tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash}, Unsave: false}
if ok, err := r.onAccountSaveTheme(ctx, save); err != nil || !ok {
t.Fatalf("saveTheme = %v/%v, want true/nil", ok, err)
}
inst := &tg.AccountInstallThemeRequest{}
inst.SetTheme(&tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash})
inst.SetDark(true)
if ok, err := r.onAccountInstallTheme(ctx, inst); err != nil || !ok {
t.Fatalf("installTheme = %v/%v, want true/nil", ok, err)
}
// 未知主题引用 → THEME_INVALID。
bad := &tg.AccountGetThemeRequest{Format: "android", Theme: &tg.InputThemeSlug{Slug: "does-not-exist"}}
if _, err := r.onAccountGetTheme(ctx, bad); !tgerr.Is(err, "THEME_INVALID") {
t.Fatalf("getTheme unknown slug err = %v, want THEME_INVALID", err)
}
}
// TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题
// (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true);
// hash 稳定→NotModified,集合变化→重取。
func TestAccountGetThemesIncludesUserThemes(t *testing.T) {
const userID = 1000004
ctx := WithUserID(context.Background(), userID)
files := &fakeFiles{docs: map[int64]domain.Document{
321: {ID: 321, AccessHash: 3, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 100},
}}
r := newThemeRouter(t, files)
// 初始:仅默认主题。
first, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0})
if err != nil {
t.Fatalf("getThemes initial err = %v", err)
}
base, ok := first.(*tg.AccountThemes)
if !ok {
t.Fatalf("getThemes initial = %T, want *tg.AccountThemes", first)
}
defaultCount := len(base.Themes)
if defaultCount == 0 {
t.Fatalf("getThemes returned no default themes")
}
// 同 hash 回传 → NotModified。
if again, _ := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash}); func() bool {
_, ok := again.(*tg.AccountThemesNotModified)
return !ok
}() {
t.Fatalf("getThemes with matching hash should be NotModified")
}
// 创建一个自定义主题。
create := &tg.AccountCreateThemeRequest{Title: "Synced"}
create.SetDocument(&tg.InputDocument{ID: 321, AccessHash: 3})
created, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme err = %v", err)
}
// 现在 getThemes 必须包含该自定义主题,且 hash 变化。
second, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash})
if err != nil {
t.Fatalf("getThemes after create err = %v", err)
}
merged, ok := second.(*tg.AccountThemes)
if !ok {
t.Fatalf("getThemes after create = %T, want *tg.AccountThemes (hash changed)", second)
}
if len(merged.Themes) != defaultCount+1 {
t.Fatalf("getThemes themes = %d, want %d (defaults + 1 custom)", len(merged.Themes), defaultCount+1)
}
var foundCustom bool
for _, th := range merged.Themes {
if th.ID == created.ID {
foundCustom = true
if th.GetDefault() {
t.Fatalf("custom theme is_default = true, want false (must not pollute emoji strip)")
}
if !th.GetCreator() {
t.Fatalf("custom theme creator = false, want true for owner")
}
}
}
if !foundCustom {
t.Fatalf("getThemes did not include the user's created theme id %d", created.ID)
}
}
// TestAccountGetThemesTDesktopExcludesDocumentlessDefaults 验证:对 format="tdesktop"
// 不下发无 document 的合成 emoji 默认主题(否则 TDesktop 云主题网格点击会弹
// lng_theme_no_desktop),而 format="android" 仍下发它们供 emoji 预览条使用。
func TestAccountGetThemesTDesktopExcludesDocumentlessDefaults(t *testing.T) {
const userID = 1000007
ctx := WithUserID(context.Background(), userID)
r := newThemeRouter(t, &fakeFiles{})
android, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0})
if err != nil {
t.Fatalf("getThemes android err = %v", err)
}
a, ok := android.(*tg.AccountThemes)
if !ok || len(a.Themes) == 0 {
t.Fatalf("getThemes android = %#v, want non-empty default themes", android)
}
desktop, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "tdesktop", Hash: 0})
if err != nil {
t.Fatalf("getThemes tdesktop err = %v", err)
}
switch d := desktop.(type) {
case *tg.AccountThemes:
for _, th := range d.Themes {
if _, hasDoc := th.GetDocument(); !hasDoc {
t.Fatalf("tdesktop getThemes returned documentless theme id=%d emoticon=%q, want only document-backed cloud themes", th.ID, func() string { e, _ := th.GetEmoticon(); return e }())
}
}
case *tg.AccountThemesNotModified:
// 空集合(全新账号无自建主题)对应的稳定 hash 与 0 不同,首个 hash=0 不会命中,
// 故这里若返回 NotModified 说明哈希实现异常。
t.Fatalf("tdesktop getThemes with hash=0 = NotModified, want themes")
default:
t.Fatalf("tdesktop getThemes = %T, want *tg.AccountThemes", desktop)
}
}
func TestAccountGetThemesIOSProjectsOpaqueARGBAndRefreshesByContent(t *testing.T) {
const userID = 1000008
r := newThemeRouter(t, &fakeFiles{})
iosCtx := WithClientInfo(
WithUserID(context.Background(), userID),
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
)
first, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios"})
if err != nil {
t.Fatalf("getThemes ios err = %v", err)
}
themes, ok := first.(*tg.AccountThemes)
if !ok || len(themes.Themes) == 0 {
t.Fatalf("getThemes ios = %#v, want non-empty themes", first)
}
assertThemeAccentColorsOpaque(t, themes.Themes)
source := tdesktop.DefaultThemeList()
sourceSettings, _ := source[0].GetSettings()
projectedSettings, _ := themes.Themes[0].GetSettings()
if got, want := uint32(int32(projectedSettings[0].AccentColor))&0x00ffffff, uint32(sourceSettings[0].AccentColor); got != want {
t.Fatalf("projected RGB = %#06x, want source RGB %#06x", got, want)
}
if uint32(int32(sourceSettings[0].AccentColor))>>24 != 0 {
t.Fatalf("source Android/TDesktop accent unexpectedly changed to ARGB: %#08x", uint32(int32(sourceSettings[0].AccentColor)))
}
again, err := r.onAccountGetThemes(iosCtx, &tg.AccountGetThemesRequest{Format: "ios", Hash: themes.Hash})
if err != nil {
t.Fatalf("getThemes ios matching hash err = %v", err)
}
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getThemes ios matching hash = %T, want notModified", again)
}
}
func TestAccountGetChatThemesIOSInvalidatesTransparentCatalogCache(t *testing.T) {
const userID = 1000009
r := newThemeRouter(t, &fakeFiles{})
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
if !ok {
t.Fatalf("base ChatThemes = %T, want *tg.AccountThemes", tdesktop.ChatThemes(0))
}
iosCtx := WithClientInfo(
WithUserID(context.Background(), userID),
ClientInfo{Type: ClientTypeIOS, AppVersion: "12.9.2 (10000)"},
)
projected, err := r.onAccountGetChatThemes(iosCtx, base.Hash)
if err != nil {
t.Fatalf("getChatThemes ios err = %v", err)
}
themes, ok := projected.(*tg.AccountThemes)
if !ok {
t.Fatalf("getChatThemes ios with old hash = %T, want refreshed themes", projected)
}
if themes.Hash == base.Hash {
t.Fatalf("iOS projected hash = old catalog hash %d, want cache invalidation", themes.Hash)
}
assertThemeAccentColorsOpaque(t, themes.Themes)
again, err := r.onAccountGetChatThemes(iosCtx, themes.Hash)
if err != nil {
t.Fatalf("getChatThemes ios matching hash err = %v", err)
}
if _, ok := again.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getChatThemes ios matching hash = %T, want notModified", again)
}
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
android, err := r.onAccountGetChatThemes(androidCtx, base.Hash)
if err != nil {
t.Fatalf("getChatThemes android old hash err = %v", err)
}
if _, ok := android.(*tg.AccountThemesNotModified); !ok {
t.Fatalf("getChatThemes android old hash = %T, want unchanged notModified", android)
}
}
func TestThemesListHashIncludesVisibleContentAndIgnoresOrder(t *testing.T) {
first := tg.Theme{ID: 1, AccessHash: 11, Slug: "one", Title: "One"}
first.SetSettings([]tg.ThemeSettings{{
BaseTheme: &tg.BaseThemeClassic{},
AccentColor: 0x29b071,
}})
second := tg.Theme{ID: 2, AccessHash: 22, Slug: "two", Title: "Two"}
hashA, err := themesListHash([]tg.Theme{first, second})
if err != nil {
t.Fatalf("themesListHash initial: %v", err)
}
hashReordered, err := themesListHash([]tg.Theme{second, first})
if err != nil {
t.Fatalf("themesListHash reordered: %v", err)
}
if hashA != hashReordered {
t.Fatalf("hash changed with order: %d != %d", hashA, hashReordered)
}
changed := first
changedSettings, _ := changed.GetSettings()
changedSettings = append([]tg.ThemeSettings(nil), changedSettings...)
changedSettings[0].AccentColor = 0x329ed7
changed.SetSettings(changedSettings)
hashChanged, err := themesListHash([]tg.Theme{changed, second})
if err != nil {
t.Fatalf("themesListHash changed: %v", err)
}
if hashChanged == hashA {
t.Fatalf("hash did not change after accent update: %d", hashA)
}
}
func TestAccountSingleThemeResponsesUseIOSARGBProjection(t *testing.T) {
const userID = 1000011
r := newThemeRouter(t, &fakeFiles{})
androidCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeAndroid})
iosCtx := WithClientInfo(WithUserID(context.Background(), userID), ClientInfo{Type: ClientTypeIOS})
input := tg.InputThemeSettings{
BaseTheme: &tg.BaseThemeDay{},
AccentColor: 0x3997d3,
}
input.SetOutboxAccentColor(0x4cb064)
create := &tg.AccountCreateThemeRequest{Title: "Cross-platform"}
create.SetSettings([]tg.InputThemeSettings{input})
created, err := r.onAccountCreateTheme(androidCtx, create)
if err != nil {
t.Fatalf("create Android theme: %v", err)
}
androidSettings, _ := created.GetSettings()
if color := uint32(int32(androidSettings[0].AccentColor)); color>>24 != 0 {
t.Fatalf("Android create response accent = %#08x, want unchanged RGB24", color)
}
got, err := r.onAccountGetTheme(iosCtx, &tg.AccountGetThemeRequest{
Format: "ios",
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
})
if err != nil {
t.Fatalf("get iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*got})
iosSettings, _ := got.GetSettings()
if color := uint32(int32(iosSettings[0].AccentColor)); color != 0xff3997d3 {
t.Fatalf("iOS getTheme accent = %#08x, want 0xff3997d3", color)
}
if color, ok := iosSettings[0].GetOutboxAccentColor(); !ok || uint32(int32(color)) != 0xff4cb064 {
t.Fatalf("iOS getTheme outbox accent = %#08x ok=%v, want 0xff4cb064", uint32(int32(color)), ok)
}
iosCreate := &tg.AccountCreateThemeRequest{Title: "Created on iOS"}
iosCreate.SetSettings([]tg.InputThemeSettings{input})
createdOnIOS, err := r.onAccountCreateTheme(iosCtx, iosCreate)
if err != nil {
t.Fatalf("create iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*createdOnIOS})
updatedInput := tg.InputThemeSettings{
BaseTheme: &tg.BaseThemeTinted{},
AccentColor: 0x8660ad,
}
update := &tg.AccountUpdateThemeRequest{
Format: "ios",
Theme: &tg.InputTheme{ID: created.ID, AccessHash: created.AccessHash},
}
update.SetSettings([]tg.InputThemeSettings{updatedInput})
updated, err := r.onAccountUpdateTheme(iosCtx, update)
if err != nil {
t.Fatalf("update iOS theme: %v", err)
}
assertThemeAccentColorsOpaque(t, []tg.Theme{*updated})
updatedSettings, _ := updated.GetSettings()
if color := uint32(int32(updatedSettings[0].AccentColor)); color != 0xff8660ad {
t.Fatalf("iOS updateTheme accent = %#08x, want 0xff8660ad", color)
}
}
func assertThemeAccentColorsOpaque(t *testing.T, themes []tg.Theme) {
t.Helper()
for _, theme := range themes {
settings, ok := theme.GetSettings()
if !ok {
continue
}
for i := range settings {
if color := uint32(int32(settings[i].AccentColor)); color>>24 == 0 {
t.Fatalf("theme %d settings[%d] accent remains transparent: %#08x", theme.ID, i, color)
}
if color, ok := settings[i].GetOutboxAccentColor(); ok && uint32(int32(color))>>24 == 0 {
t.Fatalf("theme %d settings[%d] outbox accent remains transparent: %#08x", theme.ID, i, uint32(int32(color)))
}
}
}
}
// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。
func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) {
ctx := WithUserID(context.Background(), 1000003)
r := newThemeRouter(t, &fakeFiles{})
settings := tg.InputThemeSettings{BaseTheme: &tg.BaseThemeDay{}, AccentColor: 0x3997d3}
settings.SetMessageColors([]int{0xd4f1ff, 0xb9e4ff})
wp := tg.WallPaperSettings{}
wp.SetBackgroundColor(0xd4f1ff)
wp.SetSecondBackgroundColor(0xb9e4ff)
settings.SetWallpaperSettings(wp)
settings.SetWallpaper(&tg.InputWallPaperNoFile{})
create := &tg.AccountCreateThemeRequest{Title: "Accent"}
create.SetSettings([]tg.InputThemeSettings{settings})
th, err := r.onAccountCreateTheme(ctx, create)
if err != nil {
t.Fatalf("createTheme accent err = %v", err)
}
out, ok := th.GetSettings()
if !ok || len(out) != 1 {
t.Fatalf("created accent theme settings = %#v ok=%v, want 1", out, ok)
}
if _, isDay := out[0].BaseTheme.(*tg.BaseThemeDay); !isDay {
t.Fatalf("settings[0].base_theme = %T, want BaseThemeDay", out[0].BaseTheme)
}
if out[0].AccentColor != 0x3997d3 {
t.Fatalf("accent color = %#x, want 0x3997d3", out[0].AccentColor)
}
mustEncodeTheme(t, th)
}