package postgres import ( "context" "testing" "telesrv/internal/domain" ) // TestChannelMediaIndexSearch 端到端验证频道共享媒体索引(迁移 0118):发送各类媒体消息 → // 写路径建索引 → SearchChannelMedia 按标签页类别返回正确消息。门控于 TELESRV_TEST_POSTGRES_DSN。 func TestChannelMediaIndexSearch(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{AccessHash: 771, Phone: "+1890" + suffix + "01", FirstName: "MediaOwner"}) if err != nil { t.Fatalf("create owner: %v", err) } channels := NewChannelStore(pool) var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) }) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Media " + suffix, Megagroup: true, Date: 1700002000, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID send := func(rnd int64, msg string, media *domain.MessageMedia, ents []domain.MessageEntity) int { res, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: rnd, Message: msg, Media: media, Entities: ents, Date: 1700002000 + int(rnd), }) if err != nil { t.Fatalf("send channel message %d: %v", rnd, err) } return res.Message.ID } docMedia := func(attrs ...domain.DocumentAttribute) *domain.MessageMedia { return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: 1, AccessHash: 1, Attributes: attrs}} } photoID := send(1, "pic", &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 11, AccessHash: 1}}, nil) fileID := send(2, "doc", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: "a.pdf"}), nil) videoID := send(3, "vid", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: 1, H: 1}), nil) urlID := send(4, "see https://x.test", nil, []domain.MessageEntity{{Type: domain.MessageEntityURL, Offset: 4, Length: 14}}) musicID := send(6, "song", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Title: "song"}), nil) _ = send(5, "plain text only", nil, nil) // 不进任何标签页 search := func(cats ...domain.MediaCategory) domain.ChannelHistory { h, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{Categories: cats, Limit: 50}) if err != nil { t.Fatalf("search media %v: %v", cats, err) } return h } wantIDs := func(name string, h domain.ChannelHistory, ids ...int) { if h.Count != len(ids) { t.Fatalf("%s: count = %d, want %d", name, h.Count, len(ids)) } if len(h.Messages) != len(ids) { t.Fatalf("%s: got %d messages, want %d", name, len(h.Messages), len(ids)) } for i, id := range ids { // newest-first if h.Messages[i].ID != id { t.Fatalf("%s: message[%d].ID = %d, want %d (order %v)", name, i, h.Messages[i].ID, id, ids) } } } wantCount := func(name string, counts domain.MediaCategoryCounts, category domain.MediaCategory, want int) { if got := counts[category]; got != want { t.Fatalf("%s: count[%d] = %d, want %d", name, category, got, want) } } wantIDs("photos", search(domain.MediaCategoryPhoto), photoID) wantIDs("files", search(domain.MediaCategoryFile), fileID) wantIDs("video", search(domain.MediaCategoryVideo), videoID) wantIDs("url", search(domain.MediaCategoryURL), urlID) wantIDs("music", search(domain.MediaCategoryMusic), musicID) wantIDs("photoVideo", search(domain.MediaCategoryPhoto, domain.MediaCategoryVideo), videoID, photoID) // newest-first wantIDs("voice empty", search(domain.MediaCategoryVoice)) combined, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{ Categories: []domain.MediaCategory{domain.MediaCategoryVideo}, Query: "VID", SenderUserID: owner.ID, MinDate: 1700002002, MaxDate: 1700002004, Limit: 50, }) if err != nil { t.Fatalf("combined channel media search: %v", err) } wantIDs("combined channel media", combined, videoID) countOnly, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{ Categories: []domain.MediaCategory{domain.MediaCategoryPhoto}, Limit: 0, }) if err != nil { t.Fatalf("search count-only media: %v", err) } if countOnly.Count != 1 || len(countOnly.Messages) != 0 { t.Fatalf("count-only media = count %d messages %d, want count 1 and no messages", countOnly.Count, len(countOnly.Messages)) } counts, err := channels.CountChannelMediaCategories(ctx, owner.ID, channelID) if err != nil { t.Fatalf("count channel media categories: %v", err) } wantCount("initial", counts, domain.MediaCategoryPhoto, 1) wantCount("initial", counts, domain.MediaCategoryFile, 1) wantCount("initial", counts, domain.MediaCategoryVideo, 1) wantCount("initial", counts, domain.MediaCategoryMusic, 1) wantCount("initial", counts, domain.MediaCategoryURL, 1) wantCount("initial", counts, domain.MediaCategoryVoice, 0) // 编辑把视频消息换成语音 → 索引类别应随之迁移(video 标签页不再含它,voice 含它)。 if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, ID: videoID, Message: "now voice", EditDate: 1700002999, Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), }); err != nil { t.Fatalf("edit channel message media: %v", err) } wantIDs("video after edit empty", search(domain.MediaCategoryVideo)) wantIDs("voice after edit", search(domain.MediaCategoryVoice), videoID) counts, err = channels.CountChannelMediaCategories(ctx, owner.ID, channelID) if err != nil { t.Fatalf("count channel media categories after edit: %v", err) } wantCount("after edit", counts, domain.MediaCategoryVideo, 0) wantCount("after edit", counts, domain.MediaCategoryVoice, 1) wantCount("after edit", counts, domain.MediaCategoryMusic, 1) if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ UserID: owner.ID, ChannelID: channelID, IDs: []int{videoID}, Date: 1700003001, }); err != nil { t.Fatalf("delete channel media message: %v", err) } counts, err = channels.CountChannelMediaCategories(ctx, owner.ID, channelID) if err != nil { t.Fatalf("count channel media categories after delete: %v", err) } wantCount("after delete", counts, domain.MediaCategoryVideo, 0) wantCount("after delete", counts, domain.MediaCategoryVoice, 0) wantCount("after delete", counts, domain.MediaCategoryMusic, 1) } func TestPrivateMediaCategoryCountsMaterialized(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) alice, err := users.Create(ctx, domain.User{AccessHash: 772, Phone: "+1890" + suffix + "11", FirstName: "MediaAlice"}) if err != nil { t.Fatalf("create alice: %v", err) } bob, err := users.Create(ctx, domain.User{AccessHash: 773, Phone: "+1890" + suffix + "12", FirstName: "MediaBob"}) if err != nil { t.Fatalf("create bob: %v", err) } ids := []int64{alice.ID, bob.ID} t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) }) messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) docMedia := func(attrs ...domain.DocumentAttribute) *domain.MessageMedia { return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: 2, AccessHash: 2, Attributes: attrs}} } sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 991, Message: "doc", Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: "a.pdf"}), Date: 1700003100, }) if err != nil { t.Fatalf("send private media: %v", err) } combined, err := messages.SearchPrivateMedia(ctx, bob.ID, alice.ID, domain.MediaSearchRequest{ Categories: []domain.MediaCategory{domain.MediaCategoryFile}, Query: "DOC", SenderUserID: alice.ID, MinDate: 1700003000, MaxDate: 1700003200, Limit: 10, }) if err != nil { t.Fatalf("combined private media search: %v", err) } if combined.Count != 1 || len(combined.Messages) != 1 || combined.Messages[0].ID != sent.RecipientMessage.ID { t.Fatalf("combined private media = count %d messages %+v", combined.Count, combined.Messages) } wantCount := func(name string, ownerID, peerID int64, category domain.MediaCategory, want int) { counts, err := messages.CountPrivateMediaCategories(ctx, ownerID, peerID) if err != nil { t.Fatalf("%s count private media categories: %v", name, err) } if got := counts[category]; got != want { t.Fatalf("%s count[%d] = %d, want %d", name, category, got, want) } } wantCount("alice initial", alice.ID, bob.ID, domain.MediaCategoryFile, 1) wantCount("bob initial", bob.ID, alice.ID, domain.MediaCategoryFile, 1) if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{ OwnerUserID: alice.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, ID: sent.SenderMessage.ID, Message: "voice", Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), EditDate: 1700003101, }); err != nil { t.Fatalf("edit private media: %v", err) } wantCount("alice file after edit", alice.ID, bob.ID, domain.MediaCategoryFile, 0) wantCount("alice voice after edit", alice.ID, bob.ID, domain.MediaCategoryVoice, 1) wantCount("bob file after edit", bob.ID, alice.ID, domain.MediaCategoryFile, 0) wantCount("bob voice after edit", bob.ID, alice.ID, domain.MediaCategoryVoice, 1) if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ OwnerUserID: alice.ID, IDs: []int{sent.SenderMessage.ID}, Revoke: true, Date: 1700003102, }); err != nil { t.Fatalf("delete private media: %v", err) } wantCount("alice voice after delete", alice.ID, bob.ID, domain.MediaCategoryVoice, 0) wantCount("bob voice after delete", bob.ID, alice.ID, domain.MediaCategoryVoice, 0) }