1200 lines
41 KiB
Go
1200 lines
41 KiB
Go
package files
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
|
||
type fakeMediaStore struct {
|
||
mu sync.Mutex
|
||
blobs map[string]domain.FileBlob
|
||
docs map[int64]domain.Document
|
||
photos map[int64]domain.Photo
|
||
sets map[int64]domain.StickerSet
|
||
reactions []domain.AvailableReaction
|
||
parts map[string][]domain.UploadPart
|
||
webPages map[int64]domain.MessageWebPage
|
||
seedState map[string]string
|
||
receipts map[string]domain.UploadedMediaReceipt
|
||
}
|
||
|
||
func newFakeMediaStore() *fakeMediaStore {
|
||
return &fakeMediaStore{
|
||
blobs: map[string]domain.FileBlob{},
|
||
docs: map[int64]domain.Document{},
|
||
photos: map[int64]domain.Photo{},
|
||
sets: map[int64]domain.StickerSet{},
|
||
parts: map[string][]domain.UploadPart{},
|
||
seedState: map[string]string{},
|
||
receipts: map[string]domain.UploadedMediaReceipt{},
|
||
}
|
||
}
|
||
|
||
func fakeUploadReceiptKey(ownerUserID, fileID int64) string {
|
||
return fmt.Sprintf("%d/%d", ownerUserID, fileID)
|
||
}
|
||
|
||
func (f *fakeMediaStore) GetUploadedMediaReceipt(_ context.Context, ownerUserID, fileID int64) (domain.UploadedMediaReceipt, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
receipt, ok := f.receipts[fakeUploadReceiptKey(ownerUserID, fileID)]
|
||
receipt.IntentHash = append([]byte(nil), receipt.IntentHash...)
|
||
return receipt, ok, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) PutUploadedMediaReceipt(_ context.Context, receipt domain.UploadedMediaReceipt) (domain.UploadedMediaReceipt, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
key := fakeUploadReceiptKey(receipt.OwnerUserID, receipt.FileID)
|
||
if stored, ok := f.receipts[key]; ok {
|
||
stored.IntentHash = append([]byte(nil), stored.IntentHash...)
|
||
return stored, false, nil
|
||
}
|
||
receipt.IntentHash = append([]byte(nil), receipt.IntentHash...)
|
||
f.receipts[key] = receipt
|
||
return receipt, true, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) SaveFilePart(_ context.Context, part domain.UploadPart) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
key := fakeUploadPartKey(part.OwnerUserID, part.FileID)
|
||
part.SHA256 = append([]byte(nil), part.SHA256...)
|
||
parts := f.parts[key]
|
||
for i := range parts {
|
||
if parts[i].Part == part.Part {
|
||
parts[i] = part
|
||
f.parts[key] = parts
|
||
return nil
|
||
}
|
||
}
|
||
f.parts[key] = append(parts, part)
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) UploadPartUsage(_ context.Context, ownerUserID int64) (domain.UploadPartUsage, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
var usage domain.UploadPartUsage
|
||
files := map[int64]struct{}{}
|
||
for _, parts := range f.parts {
|
||
for _, p := range parts {
|
||
if p.OwnerUserID != ownerUserID {
|
||
continue
|
||
}
|
||
usage.Bytes += p.Size
|
||
usage.Parts++
|
||
files[p.FileID] = struct{}{}
|
||
}
|
||
}
|
||
usage.Files = len(files)
|
||
return usage, nil
|
||
}
|
||
func (f *fakeMediaStore) UploadPartSlot(_ context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
parts := f.parts[fakeUploadPartKey(ownerUserID, fileID)]
|
||
slot := domain.UploadPartSlot{FileParts: len(parts)}
|
||
for _, p := range parts {
|
||
if p.Part == part {
|
||
slot.ExistingBytes = p.Size
|
||
slot.ObjectKey = p.ObjectKey
|
||
slot.Found = true
|
||
break
|
||
}
|
||
}
|
||
return slot, nil
|
||
}
|
||
func (f *fakeMediaStore) LoadFileParts(_ context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
parts := append([]domain.UploadPart(nil), f.parts[fakeUploadPartKey(ownerUserID, fileID)]...)
|
||
sort.Slice(parts, func(i, j int) bool { return parts[i].Part < parts[j].Part })
|
||
return parts, nil
|
||
}
|
||
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, ownerUserID, fileID int64) ([]string, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
key := fakeUploadPartKey(ownerUserID, fileID)
|
||
parts := f.parts[key]
|
||
keys := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
keys = append(keys, p.ObjectKey)
|
||
}
|
||
delete(f.parts, key)
|
||
return keys, nil
|
||
}
|
||
func (f *fakeMediaStore) DeleteExpiredUploadParts(_ context.Context, _ time.Time, _ int) ([]string, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func fakeUploadPartKey(ownerUserID, fileID int64) string {
|
||
return fmt.Sprintf("%d:%d", ownerUserID, fileID)
|
||
}
|
||
|
||
func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.blobs[blob.LocationKey] = blob
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.FileBlob, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
b, ok := f.blobs[key]
|
||
return b, ok, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) GetFileBlobs(_ context.Context, keys []string) (map[string]domain.FileBlob, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
out := make(map[string]domain.FileBlob, len(keys))
|
||
for _, key := range keys {
|
||
if b, ok := f.blobs[key]; ok {
|
||
out[key] = b
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) GetSeedState(_ context.Context, key string) (string, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
hash, ok := f.seedState[key]
|
||
return hash, ok, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) PutSeedState(_ context.Context, key, hash string) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.seedState[key] = hash
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.docs[doc.ID] = doc
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
d, ok := f.docs[id]
|
||
return d, ok, nil
|
||
}
|
||
func (f *fakeMediaStore) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
out := make([]domain.Document, 0, len(ids))
|
||
for _, id := range ids {
|
||
if d, ok := f.docs[id]; ok {
|
||
out = append(out, d)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
func (f *fakeMediaStore) PutPhoto(_ context.Context, p domain.Photo) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.photos[p.ID] = p
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
p, ok := f.photos[id]
|
||
return p, ok, nil
|
||
}
|
||
func (f *fakeMediaStore) PutWebPage(_ context.Context, urlHash int64, page domain.MessageWebPage, _ int) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
if f.webPages == nil {
|
||
f.webPages = map[int64]domain.MessageWebPage{}
|
||
}
|
||
f.webPages[urlHash] = page
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) GetWebPageByURLHash(_ context.Context, urlHash int64) (domain.MessageWebPage, int, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
p, ok := f.webPages[urlHash]
|
||
return p, 0, ok, nil
|
||
}
|
||
|
||
func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.sets[set.ID] = set
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) CreateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
for _, existing := range f.sets {
|
||
if existing.ShortName != "" && strings.EqualFold(existing.ShortName, set.ShortName) {
|
||
return domain.ErrStickerSetShortNameOccupied
|
||
}
|
||
}
|
||
f.sets[set.ID] = set
|
||
if f.docs == nil {
|
||
f.docs = map[int64]domain.Document{}
|
||
}
|
||
for _, doc := range docs {
|
||
f.docs[doc.ID] = doc
|
||
}
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) UpdateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
if _, ok := f.sets[set.ID]; !ok {
|
||
return domain.ErrStickerSetInvalid
|
||
}
|
||
f.sets[set.ID] = set
|
||
if f.docs == nil {
|
||
f.docs = map[int64]domain.Document{}
|
||
}
|
||
for _, doc := range docs {
|
||
f.docs[doc.ID] = doc
|
||
}
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creatorUserID int64) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
set, ok := f.sets[setID]
|
||
if !ok || set.Deleted || set.CreatorUserID != creatorUserID {
|
||
return domain.ErrStickerSetInvalid
|
||
}
|
||
set.Deleted = true
|
||
f.sets[setID] = set
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) AdminDeleteStickerSet(_ context.Context, setID int64) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
set, ok := f.sets[setID]
|
||
if !ok || set.Deleted {
|
||
return domain.ErrStickerSetInvalid
|
||
}
|
||
set.Deleted = true
|
||
f.sets[setID] = set
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
s, ok := f.sets[id]
|
||
if ok && s.Deleted {
|
||
return domain.StickerSet{}, false, nil
|
||
}
|
||
return s, ok, nil
|
||
}
|
||
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
for _, s := range f.sets {
|
||
if strings.EqualFold(s.ShortName, name) && !s.Deleted {
|
||
return s, true, nil
|
||
}
|
||
}
|
||
return domain.StickerSet{}, false, nil
|
||
}
|
||
func (f *fakeMediaStore) GetStickerSetBySystemKey(_ context.Context, key string) (domain.StickerSet, bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
for _, s := range f.sets {
|
||
if s.SystemKey == key {
|
||
return s, true, nil
|
||
}
|
||
}
|
||
return domain.StickerSet{}, false, nil
|
||
}
|
||
func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
var out []domain.StickerSet
|
||
for _, s := range f.sets {
|
||
if s.Kind == kind && !s.Deleted {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
func (f *fakeMediaStore) ListStickerSetsByCreator(_ context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
var all []domain.StickerSet
|
||
for _, s := range f.sets {
|
||
if s.CreatorUserID == creatorUserID && !s.Deleted {
|
||
s.Creator = true
|
||
all = append(all, s)
|
||
}
|
||
}
|
||
sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID })
|
||
total := len(all)
|
||
if offsetID != 0 {
|
||
filtered := all[:0]
|
||
for _, s := range all {
|
||
if s.ID < offsetID {
|
||
filtered = append(filtered, s)
|
||
}
|
||
}
|
||
all = filtered
|
||
}
|
||
if limit > 0 && len(all) > limit {
|
||
all = all[:limit]
|
||
}
|
||
return append([]domain.StickerSet(nil), all...), total, nil
|
||
}
|
||
func (f *fakeMediaStore) StickerSetShortNameAvailable(_ context.Context, shortName string) (bool, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
for _, s := range f.sets {
|
||
if s.ShortName != "" && strings.EqualFold(s.ShortName, shortName) && !s.Deleted {
|
||
return false, nil
|
||
}
|
||
}
|
||
return true, nil
|
||
}
|
||
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return len(f.sets), nil
|
||
}
|
||
func (f *fakeMediaStore) PutAvailableReaction(_ context.Context, r domain.AvailableReaction) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
for i, existing := range f.reactions {
|
||
if existing.Reaction == r.Reaction {
|
||
f.reactions[i] = r
|
||
return nil
|
||
}
|
||
}
|
||
f.reactions = append(f.reactions, r)
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) ListAvailableReactions(_ context.Context) ([]domain.AvailableReaction, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return append([]domain.AvailableReaction(nil), f.reactions...), nil
|
||
}
|
||
func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return len(f.reactions), nil
|
||
}
|
||
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
|
||
return nil
|
||
}
|
||
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) {
|
||
return 0, false, nil
|
||
}
|
||
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||
}
|
||
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||
}
|
||
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
|
||
return nil, 0, nil
|
||
}
|
||
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]domain.Photo, int, error) {
|
||
return nil, 0, nil
|
||
}
|
||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||
return nil, nil
|
||
}
|
||
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ []int64) ([]int64, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||
seedDir := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(seedDir, "telegram_reactions_export", "global_json"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reactionsDir := filepath.Join(seedDir, "telegram_reactions_export", "reactions")
|
||
if err := os.MkdirAll(reactionsDir, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw := `{"result":{"reactions":[{"reaction":"👍","title":"Like","static_icon":{"id":1111111,"access_hash":1,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"image/webp","size":4,"attributes":[],"thumbs":[]},"select_animation":{"id":2222222,"access_hash":2,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"attributes":[],"thumbs":[]}}]}}`
|
||
if err := os.WriteFile(filepath.Join(seedDir, "telegram_reactions_export", "global_json", "available_reactions_raw.json"), []byte(raw), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111.webp"), []byte("webp"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111_thumb1_PhotoSize_types_72x72.jpg"), []byte("jpeg"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_select_2222222.tgs"), []byte("tgs!"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
media := newFakeMediaStore()
|
||
local, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
blobs := &countingBlobBackend{BlobBackend: local}
|
||
svc := NewService(media, blobs, 2)
|
||
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
||
t.Fatalf("initial seed: %v", err)
|
||
} else if stats.Reactions != 1 || stats.Blobs != 3 {
|
||
t.Fatalf("initial stats = %+v, want one reaction and three blobs", stats)
|
||
}
|
||
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
|
||
if err != nil || !ok {
|
||
t.Fatalf("prewarmed getfile ok=%v err=%v", ok, err)
|
||
}
|
||
if string(chunk.Bytes) != "tgs!" {
|
||
t.Fatalf("prewarmed chunk = %q, want tgs!", chunk.Bytes)
|
||
}
|
||
if blobs.getRangeCalls != 0 {
|
||
t.Fatalf("seeded small blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls)
|
||
}
|
||
|
||
media.mu.Lock()
|
||
delete(media.blobs, "doc:2222222")
|
||
media.mu.Unlock()
|
||
|
||
stats, err := svc.SeedMedia(context.Background(), seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("repair seed: %v", err)
|
||
}
|
||
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
|
||
t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
|
||
}
|
||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||
t.Fatal("missing reaction blob was not repaired")
|
||
}
|
||
if reactions, _ := media.ListAvailableReactions(context.Background()); len(reactions) != 1 {
|
||
t.Fatalf("reaction upsert duplicated rows: got %d", len(reactions))
|
||
}
|
||
}
|
||
|
||
func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 4444444
|
||
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17)
|
||
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
stats, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("seed media: %v", err)
|
||
}
|
||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
|
||
t.Fatalf("stats = %+v, want one set, one doc, main blob plus synthetic preview", stats)
|
||
}
|
||
|
||
set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack")
|
||
if err != nil || !ok {
|
||
t.Fatalf("StatusPack ok=%v err=%v", ok, err)
|
||
}
|
||
doc, ok, err := media.GetDocument(ctx, set.DocumentIDs[0])
|
||
if err != nil || !ok {
|
||
t.Fatalf("StatusPack document ok=%v err=%v", ok, err)
|
||
}
|
||
if !seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji) {
|
||
t.Fatalf("document attributes = %+v, want custom emoji", doc.Attributes)
|
||
}
|
||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||
if !ok {
|
||
t.Fatalf("document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
|
||
}
|
||
if thumb.Type != seedSyntheticDocumentThumbType || thumb.W != 1 || thumb.H != 1 || len(thumb.Bytes) == 0 {
|
||
t.Fatalf("synthetic thumb = %+v, want 1x1 cached %q thumb", thumb, seedSyntheticDocumentThumbType)
|
||
}
|
||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||
if err != nil || !ok {
|
||
t.Fatalf("synthetic thumb blob ok=%v err=%v", ok, err)
|
||
}
|
||
if blob.MimeType != "image/png" {
|
||
t.Fatalf("synthetic thumb blob mime = %q, want image/png", blob.MimeType)
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 5555555
|
||
const setHash = 23
|
||
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, setHash)
|
||
|
||
media := newFakeMediaStore()
|
||
if err := media.PutDocument(ctx, domain.Document{
|
||
ID: sourceID,
|
||
MimeType: "application/x-tgsticker",
|
||
Attributes: []domain.DocumentAttribute{{
|
||
Kind: domain.DocAttrCustomEmoji,
|
||
Alt: "\U0001f44b",
|
||
TextColor: true,
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("put stale document: %v", err)
|
||
}
|
||
if err := media.PutStickerSet(ctx, domain.StickerSet{
|
||
ID: 773947703670341676,
|
||
AccessHash: 1,
|
||
ShortName: "StatusPack",
|
||
Title: "Status Pack",
|
||
Hash: setHash,
|
||
Kind: domain.StickerSetKindEmoji,
|
||
Emojis: true,
|
||
DocumentIDs: []int64{
|
||
sourceID,
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("put stale sticker set: %v", err)
|
||
}
|
||
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
stats, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("repair seed: %v", err)
|
||
}
|
||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
|
||
t.Fatalf("repair stats = %+v, want forced reimport", stats)
|
||
}
|
||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||
if err != nil || !ok {
|
||
t.Fatalf("repaired document ok=%v err=%v", ok, err)
|
||
}
|
||
if _, ok := findCachedThumb(doc.Thumbs); !ok {
|
||
t.Fatalf("repaired document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 6666666
|
||
writeEffectsSeed(t, seedDir, sourceID)
|
||
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
first, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("first seed: %v", err)
|
||
}
|
||
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 2 {
|
||
t.Fatalf("first stats = %+v, want one imported effect document with main plus synthetic preview blobs", first)
|
||
}
|
||
|
||
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("second seed: %v", err)
|
||
}
|
||
if second.Effects != 1 || second.Documents != 0 || second.Blobs != 0 {
|
||
t.Fatalf("second stats = %+v, want effects catalog loaded without document/blob import", second)
|
||
}
|
||
|
||
delete(media.blobs, fmt.Sprintf("doc:%d", sourceID))
|
||
repaired, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("repair seed: %v", err)
|
||
}
|
||
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 {
|
||
t.Fatalf("repair stats = %+v, want missing main blob repaired without rewriting intact preview", repaired)
|
||
}
|
||
}
|
||
|
||
func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 7777777
|
||
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 29)
|
||
writeEffectsSeed(t, seedDir, sourceID)
|
||
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
first, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("first seed: %v", err)
|
||
}
|
||
if first.StickerSets != 1 || first.Effects != 1 {
|
||
t.Fatalf("first stats = %+v, want shared sticker and effect catalogs", first)
|
||
}
|
||
|
||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||
if err != nil || !ok {
|
||
t.Fatalf("shared document ok=%v err=%v", ok, err)
|
||
}
|
||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||
if !ok {
|
||
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
|
||
}
|
||
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
|
||
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
|
||
}
|
||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
|
||
if err != nil || !ok {
|
||
t.Fatalf("shared preview blob ok=%v err=%v", ok, err)
|
||
}
|
||
if blob.MimeType != "image/jpeg" || blob.Size != int64(len(realThumb)) {
|
||
t.Fatalf("shared preview blob = %+v, want real JPEG metadata", blob)
|
||
}
|
||
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("doc:%d:m", sourceID), Limit: 1024})
|
||
if err != nil || !ok {
|
||
t.Fatalf("get shared preview ok=%v err=%v", ok, err)
|
||
}
|
||
if !bytes.Equal(chunk.Bytes, realThumb) {
|
||
t.Fatalf("downloaded shared preview = %x, want %x", chunk.Bytes, realThumb)
|
||
}
|
||
|
||
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("second seed: %v", err)
|
||
}
|
||
if second.Documents != 0 || second.Blobs != 0 {
|
||
t.Fatalf("second stats = %+v, want shared rich preview to satisfy effects readiness", second)
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 8888888
|
||
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
|
||
|
||
media := newFakeMediaStore()
|
||
if err := media.PutDocument(ctx, domain.Document{
|
||
ID: sourceID,
|
||
MimeType: "application/x-tgsticker",
|
||
Thumbs: []domain.PhotoSize{{
|
||
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
|
||
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("put stale document: %v", err)
|
||
}
|
||
if err := media.PutStickerSet(ctx, domain.StickerSet{
|
||
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
|
||
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
|
||
}); err != nil {
|
||
t.Fatalf("put stale sticker set: %v", err)
|
||
}
|
||
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
stats, err := svc.SeedMedia(ctx, seedDir, 0)
|
||
if err != nil {
|
||
t.Fatalf("migration seed: %v", err)
|
||
}
|
||
if stats.StickerSets != 1 || stats.Documents != 1 {
|
||
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
|
||
}
|
||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||
if err != nil || !ok {
|
||
t.Fatalf("migrated document ok=%v err=%v", ok, err)
|
||
}
|
||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
|
||
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
|
||
}
|
||
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
|
||
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaFromRealExport(t *testing.T) {
|
||
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
|
||
if seedDir == "" {
|
||
t.Skip("TELESRV_REAL_STICKER_SEED_DIR not set")
|
||
}
|
||
if _, err := os.Stat(seedDir); err != nil {
|
||
t.Skipf("seed dir %s not present: %v", seedDir, err)
|
||
}
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
maxRegularSets := 2
|
||
if raw := os.Getenv("TELESRV_REAL_STICKER_SEED_MAX_SETS"); raw != "" {
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil {
|
||
t.Fatalf("parse TELESRV_REAL_STICKER_SEED_MAX_SETS: %v", err)
|
||
}
|
||
maxRegularSets = n
|
||
}
|
||
stats, err := svc.SeedMedia(context.Background(), seedDir, maxRegularSets)
|
||
if err != nil {
|
||
t.Fatalf("seed media: %v", err)
|
||
}
|
||
t.Logf("seed stats: reactions=%d sets=%d docs=%d blobs=%d", stats.Reactions, stats.StickerSets, stats.Documents, stats.Blobs)
|
||
if stats.Reactions == 0 {
|
||
t.Error("expected reactions imported")
|
||
}
|
||
if stats.StickerSets == 0 {
|
||
t.Error("expected sticker sets imported")
|
||
}
|
||
if stats.Documents == 0 {
|
||
t.Error("expected documents imported")
|
||
}
|
||
if stats.Blobs == 0 {
|
||
t.Error("expected blobs imported")
|
||
}
|
||
|
||
// reaction 引用的文档应能被解析回真实 document(带 sticker 属性 + 主体 blob)。
|
||
reactions, _ := media.ListAvailableReactions(context.Background())
|
||
if len(reactions) == 0 {
|
||
t.Fatal("no reactions stored")
|
||
}
|
||
first := reactions[0]
|
||
if first.Reaction == "" {
|
||
t.Error("reaction emoticon empty")
|
||
}
|
||
if first.StaticIconID == 0 || first.SelectAnimationID == 0 {
|
||
t.Error("reaction missing document ids")
|
||
}
|
||
if d, ok, _ := media.GetDocument(context.Background(), first.SelectAnimationID); !ok {
|
||
t.Error("reaction select animation document missing")
|
||
} else {
|
||
if d.DCID != 2 {
|
||
t.Errorf("document dc_id not rewritten: %d", d.DCID)
|
||
}
|
||
if _, ok, _ := media.GetFileBlob(context.Background(), blobKeyDoc(d.ID)); !ok {
|
||
t.Errorf("reaction document %d main blob missing", d.ID)
|
||
}
|
||
}
|
||
|
||
// 一个常规贴纸集应有 documents 且能按 short_name 解析。
|
||
for _, s := range media.sets {
|
||
for _, thumb := range s.Thumbs {
|
||
if thumb.Downloadable() {
|
||
t.Fatalf("sticker set %s exposes downloadable cover thumb %q without a serviceable blob", s.ShortName, thumb.Type)
|
||
}
|
||
}
|
||
}
|
||
|
||
var sample domain.StickerSet
|
||
for _, s := range media.sets {
|
||
if s.Kind == domain.StickerSetKindStickers && len(s.DocumentIDs) > 0 {
|
||
sample = s
|
||
break
|
||
}
|
||
}
|
||
if sample.ID == 0 {
|
||
t.Fatal("no regular sticker set with documents imported")
|
||
}
|
||
if got, ok, _ := media.GetStickerSetByShortName(context.Background(), sample.ShortName); !ok || got.ID != sample.ID {
|
||
t.Error("sticker set not resolvable by short name")
|
||
}
|
||
if doc, ok, _ := media.GetDocument(context.Background(), sample.DocumentIDs[0]); !ok {
|
||
t.Fatalf("sample sticker document %d missing", sample.DocumentIDs[0])
|
||
} else {
|
||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||
if !ok {
|
||
t.Fatalf("sample sticker document thumbs are not inline cached: %+v", doc.Thumbs)
|
||
}
|
||
blob, ok, err := media.GetFileBlob(context.Background(), blobKeyDoc(doc.ID)+":"+thumb.Type)
|
||
if err != nil || !ok {
|
||
t.Fatalf("sample sticker thumb blob ok=%v err=%v", ok, err)
|
||
}
|
||
if want := seedThumbMimeType(thumb.Bytes); blob.MimeType != want {
|
||
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
|
||
}
|
||
if !hasPathThumb(doc.Thumbs) {
|
||
t.Logf("sample sticker document has no exported PhotoPathSize placeholder; synthetic cached preview is present: %+v", doc.Thumbs)
|
||
}
|
||
}
|
||
}
|
||
|
||
func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) {
|
||
t.Helper()
|
||
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
|
||
stickersDir := filepath.Join(setDir, "stickers")
|
||
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[]}]}}`, setHash, sourceID, sourceID, sourceID)
|
||
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func writeStatusPackWithThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) []byte {
|
||
t.Helper()
|
||
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
|
||
stickersDir := filepath.Join(setDir, "stickers")
|
||
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
realThumb := []byte{0xff, 0xd8, 0xff, 0xdb, 0, 4, 0xff, 0xd9}
|
||
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[{"_":"PhotoPathSize","type":"j","bytes":"01"},{"_":"PhotoSize","type":"m","w":128,"h":128,"size":%d}]}]}}`, setHash, sourceID, sourceID, sourceID, len(realThumb))
|
||
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
thumbName := fmt.Sprintf("status_%d_thumb1_PhotoSize_typem_128x128.jpg", sourceID)
|
||
if err := os.WriteFile(filepath.Join(stickersDir, thumbName), realThumb, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return realThumb
|
||
}
|
||
|
||
func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) {
|
||
t.Helper()
|
||
docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents")
|
||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw := fmt.Sprintf(`{"result":{"effects":[{"id":77,"emoticon":"🔥","effect_sticker_id":%d}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeFilename","file_name":"effect.tgs"}],"thumbs":[]}]}}`, sourceID, sourceID)
|
||
if err := os.WriteFile(filepath.Join(seedDir, "telegram_effects_export", "effects.json"), []byte(raw), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(docsDir, fmt.Sprintf("effect_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestSeedDocumentIDsAreImportedVerbatim(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const sourceID int64 = 5382305375846410902
|
||
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17)
|
||
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
if _, err := svc.SeedMedia(ctx, seedDir, 0); err != nil {
|
||
t.Fatalf("seed media: %v", err)
|
||
}
|
||
set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack")
|
||
if err != nil || !ok {
|
||
t.Fatalf("StatusPack ok=%v err=%v", ok, err)
|
||
}
|
||
if len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != sourceID {
|
||
t.Fatalf("set document ids = %+v, want [%d]", set.DocumentIDs, sourceID)
|
||
}
|
||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||
if err != nil || !ok {
|
||
t.Fatalf("GetDocument(%d) ok=%v err=%v", sourceID, ok, err)
|
||
}
|
||
if doc.ID != sourceID {
|
||
t.Fatalf("document id = %d, want %d", doc.ID, sourceID)
|
||
}
|
||
if _, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", sourceID)); err != nil || !ok {
|
||
t.Fatalf("main blob for raw id ok=%v err=%v", ok, err)
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaUsesInputSetTypeForSystemSets(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
cases := []struct {
|
||
name string
|
||
category string
|
||
dirName string
|
||
inputSetType string
|
||
shortName string
|
||
id int64
|
||
docID int64
|
||
emojis bool
|
||
wantSystem string
|
||
}{
|
||
{
|
||
name: "default topic icons from emoji export",
|
||
category: "telegram_emoji_export",
|
||
dirName: "Topics_7173162320003082",
|
||
inputSetType: "*tg.InputStickerSetEmojiDefaultTopicIcons",
|
||
shortName: "Topics",
|
||
id: 7173162320003082,
|
||
docID: 5312536423851630001,
|
||
emojis: true,
|
||
wantSystem: domain.StickerSetSystemKeyEmojiDefaultTopicIcons,
|
||
},
|
||
{
|
||
name: "premium gifts from fetched sticker export",
|
||
category: "telegram_stickers_export",
|
||
dirName: "PremiumGifts_328917524764688479",
|
||
inputSetType: "InputStickerSetPremiumGifts",
|
||
shortName: "PremiumGifts",
|
||
id: 328917524764688479,
|
||
docID: 5710643921839718407,
|
||
wantSystem: domain.StickerSetSystemKeyPremiumGifts,
|
||
},
|
||
{
|
||
name: "ton gifts from fetched sticker export",
|
||
category: "telegram_stickers_export",
|
||
dirName: "TonGifts_9000000000000001",
|
||
inputSetType: "*tg.InputStickerSetTonGifts",
|
||
shortName: "TonGifts",
|
||
id: 9000000000000001,
|
||
docID: 9000000000000101,
|
||
wantSystem: domain.StickerSetSystemKeyTonGifts,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
dir := filepath.Join(seedDir, tc.category, tc.dirName)
|
||
writeInputSetTypeSeed(t, dir, tc.inputSetType, tc.shortName, tc.id, tc.docID, tc.emojis)
|
||
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
if _, err := svc.SeedMedia(ctx, seedDir, 0); err != nil {
|
||
t.Fatalf("seed media: %v", err)
|
||
}
|
||
set, found, err := media.GetStickerSetBySystemKey(ctx, tc.wantSystem)
|
||
if err != nil || !found {
|
||
t.Fatalf("GetStickerSetBySystemKey(%q) found=%v err=%v", tc.wantSystem, found, err)
|
||
}
|
||
if set.Kind != domain.StickerSetKindSystem || set.SystemKey != tc.wantSystem {
|
||
t.Fatalf("set meta = %+v, want system key %q", set, tc.wantSystem)
|
||
}
|
||
if set.ID != tc.id || set.ShortName != tc.shortName || len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != tc.docID {
|
||
t.Fatalf("set = %+v, want id=%d short=%s doc=%d", set, tc.id, tc.shortName, tc.docID)
|
||
}
|
||
resolved, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{
|
||
Kind: domain.StickerSetRefBySystem,
|
||
SystemKey: tc.wantSystem,
|
||
})
|
||
if err != nil || !found {
|
||
t.Fatalf("ResolveStickerSet(%q) found=%v err=%v", tc.wantSystem, found, err)
|
||
}
|
||
if resolved.ID != tc.id || len(docs) != 1 || docs[0].ID != tc.docID {
|
||
t.Fatalf("resolved = set %d docs %+v, want set %d doc %d", resolved.ID, docs, tc.id, tc.docID)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSeedMediaReclassifiesExistingSystemSetWhenHashUnchanged(t *testing.T) {
|
||
ctx := context.Background()
|
||
seedDir := t.TempDir()
|
||
const (
|
||
setID int64 = 7173162320003082
|
||
docID int64 = 5312536423851630001
|
||
shortName = "Topics"
|
||
)
|
||
writeInputSetTypeSeed(t,
|
||
filepath.Join(seedDir, "telegram_emoji_export", "Topics_7173162320003082"),
|
||
"*tg.InputStickerSetEmojiDefaultTopicIcons",
|
||
shortName,
|
||
setID,
|
||
docID,
|
||
true,
|
||
)
|
||
|
||
media := newFakeMediaStore()
|
||
if err := media.PutStickerSet(ctx, domain.StickerSet{
|
||
ID: setID,
|
||
ShortName: shortName,
|
||
Hash: 17,
|
||
Kind: domain.StickerSetKindEmoji,
|
||
}); err != nil {
|
||
t.Fatalf("preload existing set: %v", err)
|
||
}
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("local fs: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
if _, err := svc.SeedMedia(ctx, seedDir, 0); err != nil {
|
||
t.Fatalf("seed media: %v", err)
|
||
}
|
||
set, found, err := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultTopicIcons)
|
||
if err != nil || !found {
|
||
t.Fatalf("GetStickerSetBySystemKey(topic_icons) found=%v err=%v", found, err)
|
||
}
|
||
if set.Kind != domain.StickerSetKindSystem || set.SystemKey != domain.StickerSetSystemKeyEmojiDefaultTopicIcons {
|
||
t.Fatalf("set meta = %+v, want reclassified system set", set)
|
||
}
|
||
if len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != docID {
|
||
t.Fatalf("set document ids = %+v, want [%d]", set.DocumentIDs, docID)
|
||
}
|
||
}
|
||
|
||
func writeInputSetTypeSeed(t *testing.T, setDir, inputSetType, shortName string, setID, docID int64, emojis bool) {
|
||
t.Helper()
|
||
stickersDir := filepath.Join(setDir, "stickers")
|
||
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
attrType := "DocumentAttributeSticker"
|
||
if emojis {
|
||
attrType = "DocumentAttributeCustomEmoji"
|
||
}
|
||
raw := fmt.Sprintf(`{"api_call":"messages.getStickerSet","input_set_type":%q,"result":{"_":"StickerSet","set":{"id":%d,"access_hash":2,"title":%q,"short_name":%q,"count":1,"hash":17,"emojis":%v},"packs":[{"emoticon":"🎁","documents":[%d]}],"documents":[{"id":%d,"access_hash":3,"file_reference":"","date":"2026-07-05T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":%q,"alt":"🎁","stickerset":{"id":%d,"access_hash":2}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[]}]}}`, inputSetType, setID, shortName, shortName, emojis, docID, docID, attrType, setID)
|
||
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("system_%d.tgs", docID)), []byte("tgs!"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
kind domain.StickerSetKind
|
||
want bool
|
||
}{
|
||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: false},
|
||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: false},
|
||
{name: "masks", kind: domain.StickerSetKindMasks, want: false},
|
||
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if got := seedStickerSetInstalled(tc.kind); got != tc.want {
|
||
t.Fatalf("seedStickerSetInstalled(%q) = %v, want %v", tc.kind, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSeedInlineCachedDocumentThumb(t *testing.T) {
|
||
input := domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 6400}
|
||
got := seedInlineCachedDocumentThumb(input, []byte("jpeg"))
|
||
if got.Kind != domain.PhotoSizeKindCached {
|
||
t.Fatalf("kind = %q, want cached", got.Kind)
|
||
}
|
||
if got.Size != 0 || string(got.Bytes) != "jpeg" {
|
||
t.Fatalf("cached thumb = %+v, want inline bytes without downloadable size", got)
|
||
}
|
||
large := make([]byte, seedInlineCachedDocumentThumbMaxBytes+1)
|
||
if got := seedInlineCachedDocumentThumb(input, large); got.Kind != domain.PhotoSizeKindDefault || got.Size != input.Size || len(got.Bytes) != 0 {
|
||
t.Fatalf("large thumb = %+v, want unchanged downloadable thumb", got)
|
||
}
|
||
}
|
||
|
||
func TestSeedThumbMimeType(t *testing.T) {
|
||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||
if got := seedThumbMimeType(webp); got != "image/webp" {
|
||
t.Fatalf("webp mime = %q, want image/webp", got)
|
||
}
|
||
jpeg := []byte{0xFF, 0xD8, 0xFF}
|
||
if got := seedThumbMimeType(jpeg); got != "image/jpeg" {
|
||
t.Fatalf("jpeg mime = %q, want image/jpeg", got)
|
||
}
|
||
}
|
||
|
||
func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}
|
||
doc := domain.Document{
|
||
ID: 100,
|
||
Thumbs: []domain.PhotoSize{
|
||
{Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: webp},
|
||
},
|
||
}
|
||
if err := media.PutDocument(ctx, doc); err != nil {
|
||
t.Fatalf("put doc: %v", err)
|
||
}
|
||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/jpeg"}); err != nil {
|
||
t.Fatalf("put blob: %v", err)
|
||
}
|
||
svc := NewService(media, nil, 2)
|
||
stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||
if err != nil {
|
||
t.Fatalf("documentsNeedInlineCachedThumbs: %v", err)
|
||
}
|
||
if !stale {
|
||
t.Fatal("expected stale mime to require repair")
|
||
}
|
||
|
||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/webp"}); err != nil {
|
||
t.Fatalf("put repaired blob: %v", err)
|
||
}
|
||
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})
|
||
if err != nil {
|
||
t.Fatalf("documentsNeedInlineCachedThumbs after repair: %v", err)
|
||
}
|
||
if stale {
|
||
t.Fatal("repaired mime should not require repair")
|
||
}
|
||
}
|
||
|
||
func findCachedThumb(sizes []domain.PhotoSize) (domain.PhotoSize, bool) {
|
||
for _, size := range sizes {
|
||
if size.Kind == domain.PhotoSizeKindCached && len(size.Bytes) > 0 {
|
||
return size, true
|
||
}
|
||
}
|
||
return domain.PhotoSize{}, false
|
||
}
|
||
|
||
func hasPathThumb(sizes []domain.PhotoSize) bool {
|
||
for _, size := range sizes {
|
||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func blobKeyDoc(id int64) string {
|
||
return "doc:" + itoa(id)
|
||
}
|
||
|
||
func itoa(v int64) string {
|
||
if v == 0 {
|
||
return "0"
|
||
}
|
||
neg := v < 0
|
||
if neg {
|
||
v = -v
|
||
}
|
||
var buf [20]byte
|
||
i := len(buf)
|
||
for v > 0 {
|
||
i--
|
||
buf[i] = byte('0' + v%10)
|
||
v /= 10
|
||
}
|
||
if neg {
|
||
i--
|
||
buf[i] = '-'
|
||
}
|
||
return string(buf[i:])
|
||
}
|