466 lines
15 KiB
Go
466 lines
15 KiB
Go
package files
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"testing"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
thumbBytes := testJPEG(t, 4, 2)
|
||
thumbnailer := &fakeVideoThumbnailer{thumb: thumbBytes}
|
||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
|
||
|
||
if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("fake-video-bytes")); err != nil {
|
||
t.Fatalf("SaveFilePart: %v", err)
|
||
}
|
||
doc, err := svc.CreateDocumentFromUpload(ctx,
|
||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 100, Parts: 1, Name: "video.mp4"},
|
||
domain.DocumentSpec{
|
||
MimeType: "video/mp4",
|
||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateDocumentFromUpload: %v", err)
|
||
}
|
||
if thumbnailer.calls != 1 {
|
||
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
|
||
}
|
||
if len(doc.Thumbs) != 1 {
|
||
t.Fatalf("thumbs = %+v, want one generated thumbnail", doc.Thumbs)
|
||
}
|
||
if got := doc.Thumbs[0]; got.Type != "m" || got.W != 4 || got.H != 2 || got.Size != len(thumbBytes) {
|
||
t.Fatalf("thumb = %+v, want m 4x2 size=%d", got, len(thumbBytes))
|
||
}
|
||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", doc.ID))
|
||
if err != nil || !ok {
|
||
t.Fatalf("generated thumb blob ok=%v err=%v", ok, err)
|
||
}
|
||
gotBytes, err := blobs.Get(ctx, blob.ObjectKey)
|
||
if err != nil {
|
||
t.Fatalf("read generated thumb blob: %v", err)
|
||
}
|
||
if !bytes.Equal(gotBytes, thumbBytes) {
|
||
t.Fatalf("generated thumb bytes mismatch")
|
||
}
|
||
}
|
||
|
||
func TestCreateDocumentFromUploadKeepsClientThumb(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
thumbnailer := &fakeVideoThumbnailer{err: errors.New("should not be called")}
|
||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
|
||
clientThumb := testJPEG(t, 3, 5)
|
||
|
||
if _, err := svc.SaveFilePart(ctx, 10, 200, 0, []byte("fake-video-bytes")); err != nil {
|
||
t.Fatalf("SaveFilePart video: %v", err)
|
||
}
|
||
if _, err := svc.SaveFilePart(ctx, 10, 201, 0, clientThumb); err != nil {
|
||
t.Fatalf("SaveFilePart thumb: %v", err)
|
||
}
|
||
doc, err := svc.CreateDocumentFromUpload(ctx,
|
||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 200, Parts: 1, Name: "video.mp4"},
|
||
domain.DocumentSpec{
|
||
MimeType: "video/mp4",
|
||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
|
||
Thumb: &domain.UploadedFileRef{OwnerUserID: 10, FileID: 201, Parts: 1, Name: "thumb.jpg"},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateDocumentFromUpload: %v", err)
|
||
}
|
||
if thumbnailer.calls != 0 {
|
||
t.Fatalf("thumbnailer calls = %d, want 0 when client thumb is available", thumbnailer.calls)
|
||
}
|
||
if len(doc.Thumbs) != 1 {
|
||
t.Fatalf("thumbs = %+v, want client thumbnail", doc.Thumbs)
|
||
}
|
||
if got := doc.Thumbs[0]; got.W != 3 || got.H != 5 || got.Size != len(clientThumb) {
|
||
t.Fatalf("thumb = %+v, want client thumb dimensions", got)
|
||
}
|
||
}
|
||
|
||
func TestCreateDocumentFromUploadWithoutThumbnailerDoesNotBlockVideo(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil))
|
||
|
||
if _, err := svc.SaveFilePart(ctx, 10, 300, 0, []byte("fake-video-bytes")); err != nil {
|
||
t.Fatalf("SaveFilePart: %v", err)
|
||
}
|
||
doc, err := svc.CreateDocumentFromUpload(ctx,
|
||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 300, Parts: 1, Name: "video.mp4"},
|
||
domain.DocumentSpec{
|
||
MimeType: "video/mp4",
|
||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateDocumentFromUpload without thumbnailer: %v", err)
|
||
}
|
||
if len(doc.Thumbs) != 0 {
|
||
t.Fatalf("thumbs = %+v, want no fallback thumbnail when thumbnailer is disabled", doc.Thumbs)
|
||
}
|
||
}
|
||
|
||
func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
data := testJPEG(t, 16, 9)
|
||
|
||
photo, err := svc.CreatePhotoFromBytes(ctx, data)
|
||
if err != nil {
|
||
t.Fatalf("CreatePhotoFromBytes: %v", err)
|
||
}
|
||
if photo.ID == 0 || photo.AccessHash == 0 || photo.DCID != 2 || len(photo.Sizes) != 2 {
|
||
t.Fatalf("photo = %+v, want stored photo with message sizes", photo)
|
||
}
|
||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:x", photo.ID))
|
||
if err != nil || !ok {
|
||
t.Fatalf("photo blob ok=%v err=%v", ok, err)
|
||
}
|
||
got, err := blobs.Get(ctx, blob.ObjectKey)
|
||
if err != nil {
|
||
t.Fatalf("read photo blob: %v", err)
|
||
}
|
||
if !bytes.Equal(got, data) {
|
||
t.Fatalf("photo blob bytes mismatch")
|
||
}
|
||
}
|
||
|
||
func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil))
|
||
data := []byte("inline document body")
|
||
spec := domain.DocumentSpec{
|
||
MimeType: "application/pdf",
|
||
Attributes: []domain.DocumentAttribute{
|
||
{Kind: domain.DocAttrFilename, FileName: "inline.pdf"},
|
||
},
|
||
}
|
||
|
||
doc, err := svc.CreateDocumentFromBytes(ctx, data, spec)
|
||
if err != nil {
|
||
t.Fatalf("CreateDocumentFromBytes: %v", err)
|
||
}
|
||
if doc.ID == 0 || doc.AccessHash == 0 || doc.Size != int64(len(data)) || doc.MimeType != "application/pdf" || len(doc.Attributes) != 1 {
|
||
t.Fatalf("document = %+v, want stored document body and attributes", doc)
|
||
}
|
||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
|
||
if err != nil || !ok {
|
||
t.Fatalf("document blob ok=%v err=%v", ok, err)
|
||
}
|
||
got, err := blobs.Get(ctx, blob.ObjectKey)
|
||
if err != nil {
|
||
t.Fatalf("read document blob: %v", err)
|
||
}
|
||
if !bytes.Equal(got, data) || blob.MimeType != "application/pdf" {
|
||
t.Fatalf("document blob mime=%q bytes=%q", blob.MimeType, got)
|
||
}
|
||
}
|
||
|
||
func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
|
||
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
|
||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||
EmojiID: 99,
|
||
BackgroundColors: []int{0xff3b30, 0x34c759},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateAvatarMarkup: %v", err)
|
||
}
|
||
if !domain.PhotoHasVideo(photo.Sizes) {
|
||
t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes)
|
||
}
|
||
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
|
||
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
|
||
}
|
||
|
||
// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为:
|
||
// 1. 普通彩色 emoji 合成进静态头像时保留原色(不得染白/变黑);
|
||
// 2. 贴图含非满 alpha 像素(抗锯齿常态)时不得因预乘溢出整片变黑——曾因把
|
||
// R=G=B=255、A<255 的非法预乘值喂给 draw.Over 溢出回绕,emoji 输出近黑色。
|
||
func TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
const emojiID = int64(99)
|
||
if err := media.PutDocument(ctx, domain.Document{
|
||
ID: emojiID,
|
||
MimeType: "application/x-tgsticker",
|
||
Thumbs: []domain.PhotoSize{{
|
||
Kind: domain.PhotoSizeKindCached,
|
||
Type: "m",
|
||
W: 64,
|
||
H: 64,
|
||
Bytes: testTransparentThumbPNG(t),
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("PutDocument: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
|
||
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
|
||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||
EmojiID: emojiID,
|
||
BackgroundColors: []int{0x112233, 0x445566},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateAvatarMarkup: %v", err)
|
||
}
|
||
|
||
r, g, b, a := avatarStillCenterPixel(t, svc, photo.ID)
|
||
if a < 250 {
|
||
t.Fatalf("center pixel alpha=%d, want opaque still", a)
|
||
}
|
||
if r < 200 || g > 90 || b > 90 {
|
||
t.Fatalf("center pixel rgb=(%d,%d,%d), want red emoji color preserved", r, g, b)
|
||
}
|
||
}
|
||
|
||
// TestCreateAvatarMarkupTintsTextColorEmojiWhite 守护 text_color custom emoji 的
|
||
// 白色剪影呈现:染色必须写合法预乘值(R=G=B=A),非满 alpha 像素不得溢出变黑。
|
||
func TestCreateAvatarMarkupTintsTextColorEmojiWhite(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
const emojiID = int64(120)
|
||
if err := media.PutDocument(ctx, domain.Document{
|
||
ID: emojiID,
|
||
MimeType: "application/x-tgsticker",
|
||
Attributes: []domain.DocumentAttribute{{
|
||
Kind: domain.DocAttrCustomEmoji,
|
||
TextColor: true,
|
||
}},
|
||
Thumbs: []domain.PhotoSize{{
|
||
Kind: domain.PhotoSizeKindCached,
|
||
Type: "m",
|
||
W: 64,
|
||
H: 64,
|
||
Bytes: testTransparentThumbPNG(t),
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("PutDocument: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
|
||
photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{
|
||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||
EmojiID: emojiID,
|
||
BackgroundColors: []int{0x112233, 0x445566},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateAvatarMarkup: %v", err)
|
||
}
|
||
|
||
r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID)
|
||
if r < 230 || g < 230 || b < 230 {
|
||
t.Fatalf("center pixel rgb=(%d,%d,%d), want white silhouette for text_color emoji", r, g, b)
|
||
}
|
||
}
|
||
|
||
func avatarStillCenterPixel(t *testing.T, svc *Service, photoID int64) (r, g, b, a uint32) {
|
||
t.Helper()
|
||
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
|
||
LocationKey: fmt.Sprintf("photo:%d:c", photoID),
|
||
Offset: 0,
|
||
Limit: 1 << 20,
|
||
})
|
||
if err != nil || !found {
|
||
t.Fatalf("avatar c blob found=%v err=%v", found, err)
|
||
}
|
||
img, _, err := image.Decode(bytes.NewReader(chunk.Bytes))
|
||
if err != nil {
|
||
t.Fatalf("decode avatar still: %v", err)
|
||
}
|
||
r, g, b, a = img.At(avatarStillSize/2, avatarStillSize/2).RGBA()
|
||
return r >> 8, g >> 8, b >> 8, a >> 8
|
||
}
|
||
|
||
func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
svc := NewService(media, blobs, 2)
|
||
|
||
if _, err := svc.SaveFilePart(ctx, 10, 400, 0, []byte("fake-profile-video")); err != nil {
|
||
t.Fatalf("SaveFilePart: %v", err)
|
||
}
|
||
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
|
||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 400, Parts: 1, Name: "avatar.mp4"},
|
||
0.25,
|
||
domain.PhotoSize{
|
||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||
EmojiID: 100,
|
||
BackgroundColors: []int{0x536dfe, 0x26a69a},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||
}
|
||
assertDownloadableAvatarSize(t, svc, photo.ID, "a")
|
||
assertDownloadableAvatarSize(t, svc, photo.ID, "c")
|
||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||
LocationKey: fmt.Sprintf("photo:%d:u", photo.ID),
|
||
Offset: 0,
|
||
Limit: 1024,
|
||
})
|
||
if err != nil || !found {
|
||
t.Fatalf("video avatar blob found=%v err=%v", found, err)
|
||
}
|
||
if string(chunk.Bytes) != "fake-profile-video" {
|
||
t.Fatalf("video avatar bytes = %q", chunk.Bytes)
|
||
}
|
||
}
|
||
|
||
// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取
|
||
// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still。
|
||
func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
|
||
ctx := context.Background()
|
||
media := newFakeMediaStore()
|
||
blobs, err := NewLocalFS(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
frame := testJPEG(t, 640, 640)
|
||
thumbnailer := &fakeVideoThumbnailer{thumb: frame}
|
||
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
|
||
|
||
if _, err := svc.SaveFilePart(ctx, 10, 500, 0, []byte("fake-profile-video")); err != nil {
|
||
t.Fatalf("SaveFilePart: %v", err)
|
||
}
|
||
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
|
||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 500, Parts: 1, Name: "avatar.mp4"},
|
||
0,
|
||
domain.PhotoSize{
|
||
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
|
||
EmojiID: 77,
|
||
BackgroundColors: []int{0x112233},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||
}
|
||
if thumbnailer.calls != 1 {
|
||
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
|
||
}
|
||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||
LocationKey: fmt.Sprintf("photo:%d:a", photo.ID),
|
||
Offset: 0,
|
||
Limit: 1 << 20,
|
||
})
|
||
if err != nil || !found {
|
||
t.Fatalf("avatar a blob found=%v err=%v", found, err)
|
||
}
|
||
if !bytes.Equal(chunk.Bytes, frame) {
|
||
t.Fatalf("avatar still bytes != extracted first frame (got %d bytes, want %d)", len(chunk.Bytes), len(frame))
|
||
}
|
||
if chunk.MimeType != "image/jpeg" {
|
||
t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType)
|
||
}
|
||
}
|
||
|
||
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) {
|
||
t.Helper()
|
||
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
|
||
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType),
|
||
Offset: 0,
|
||
Limit: 1 << 20,
|
||
})
|
||
if err != nil || !found {
|
||
t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err)
|
||
}
|
||
if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" {
|
||
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes))
|
||
}
|
||
}
|
||
|
||
// testTransparentThumbPNG 构造红色方块贴图:周边透明、中心 alpha=250(模拟抗锯齿
|
||
// 的非满 alpha),用于守护预乘溢出回归——溢出代码会把 alpha≠255 的像素整片渲染成黑。
|
||
func testTransparentThumbPNG(t *testing.T) []byte {
|
||
t.Helper()
|
||
img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
|
||
for y := 8; y < 56; y++ {
|
||
for x := 8; x < 56; x++ {
|
||
img.SetNRGBA(x, y, color.NRGBA{R: 240, G: 30, B: 30, A: 250})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatalf("encode test thumb: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
type fakeVideoThumbnailer struct {
|
||
calls int
|
||
thumb []byte
|
||
err error
|
||
}
|
||
|
||
func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) {
|
||
f.calls++
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
return append([]byte(nil), f.thumb...), nil
|
||
}
|
||
|
||
func testJPEG(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
img.Set(x, y, color.RGBA{R: uint8(40 + x), G: uint8(80 + y), B: 120, A: 255})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||
t.Fatalf("encode jpeg: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|