owpengram-server/internal/app/files/seed_test.go
A 8ff3343ae0 business: add privacy-aware user projection
(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
2026-06-14 00:19:59 +08:00

494 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package files
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"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
}
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{},
}
}
func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil }
func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) {
return nil, nil
}
func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil }
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) 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) PutStickerSet(_ context.Context, set domain.StickerSet) error {
f.mu.Lock()
defer f.mu.Unlock()
f.sets[set.ID] = 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]
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 s.ShortName == name {
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 {
out = append(out, s)
}
}
return out, 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) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
return nil
}
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
return nil
}
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
return 0, false, 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) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) {
return nil, 0, nil
}
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, 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 != 2 {
t.Fatalf("initial stats = %+v, want one reaction and two 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 repair import", 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 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)
stats, err := svc.SeedMedia(context.Background(), seedDir, 2)
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.ID > seedExternalDocumentIDOffset {
t.Errorf("reaction document kept external source id: %d", d.ID)
}
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 {
if doc.ID > seedExternalDocumentIDOffset {
t.Fatalf("sample sticker kept external source id: %d", doc.ID)
}
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.Fatalf("sample sticker document dropped its PhotoPathSize placeholder: %+v", doc.Thumbs)
}
}
}
func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
const sourceID int64 = 5382305375846410902
const want int64 = 1382305375846410902
if got := seedDocumentStorageID(sourceID); got != want {
t.Fatalf("seedDocumentStorageID(%d) = %d, want %d", sourceID, got, want)
}
if got := seedDocumentStorageID(2222222); got != 2222222 {
t.Fatalf("small server id changed: %d", got)
}
}
func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) {
cases := []struct {
name string
kind domain.StickerSetKind
want bool
}{
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
{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", 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", 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:])
}