import { describe, expect, it } from 'vitest'; import type { CommentReqBodyHeader, CommentReqBodyUserWrap, CommentReqPhotoInfo, DeleteMediasRequest, DeleteMediasResponse, DoQunCommentRequest, DoQunCommentResponse, GetAlbumListResponse, GetMediaListResponse, GetQunFeedDetailRequest, GetQunFeedDetailResponse, QunFeedCellCommon, } from '@snowluma/proto-defs/oidb-actions/group-album'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import { GroupAlbumApi } from '../../src/bridge/apis/group-album'; import { mockBridge } from './_helpers'; import type { GroupAlbumListRequestWireOracle, GroupAlbumListResponseWireOracle, GroupAlbumMediaListRequestWireOracle, GroupAlbumMediaListResponseWireOracle, } from './group-album-wire-fixture'; function albumListResponseWithCover(): Uint8Array { return protobuf_encode({ data: { albumList: [ { albumId: 'album-id', owner: '10001', name: '测试相册', description: 'desc', createTime: 1700000000n, lastUploadTime: 1700000123n, uploadNumber: 5n, cover: { type: 1, image: { name: 'cover.jpg', sloc: 'small-location', lloc: 'large-location', photoUrls: [ { spec: 3, url: { url: 'https://example.test/cover-320.jpg', width: 320, height: 180, }, }, ], defaultUrl: { url: 'https://example.test/cover.jpg', width: 1280, height: 720, }, isGif: false, hasRaw: true, }, }, }, ], }, }); } describe('apis/group-album', () => { it('uses QQ NT AlbumService and puts the pagination cursor in request body field 2', async () => { const bridge = mockBridge(); const cursor = '{"IndexType":"next"}'; await new GroupAlbumApi(bridge as never).listQun(12345, cursor); const [serviceCmd, requestBytes, timeoutMs] = bridge.sendRawPacket.mock.calls[0]!; expect(serviceCmd).toBe('QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetAlbumList'); expect(timeoutMs).toBe(15000); const request = protobuf_decode(requestBytes); expect(request.seq).toBe(3331); expect(request.data).toEqual({ groupId: '12345', cursor }); expect(request.extMap).toEqual([{ key: 'fc-appid', value: '100' }]); // QQ NT's codec writes the two empty common-envelope byte fields rather // than omitting them: field1=3331, field2="", field3="". expect(Buffer.from(requestBytes).subarray(0, 7).toString('hex')).toBe('08831a12001a00'); }); it('preserves Unicode emoji and literal QQ-em tags in the legacy creator nickname', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ seq: 3331, data: { albumList: [ { albumId: 'album-id', owner: '10001', name: '测试相册', description: 'desc', createTime: 1700000000n, uploadNumber: 5n, creator: { uid: 'u_creator', uin: '10001', nick: '😂[em]e328514[/em]', }, }, ], }, }), ), }); const result = await new GroupAlbumApi(bridge as never).list(12345); expect(result).toEqual([ { id: 'album-id', name: '测试相册', picNum: 5, createTime: 1700000000, desc: 'desc', owner: '10001', createuin: '10001', createnickname: '😂[em]e328514[/em]', last_upload_time: 0, cover: null, }, ]); }); it('fetches every AlbumService page for the legacy album list action', async () => { const bridge = mockBridge(); bridge.sendRawPacket .mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { albumList: [ { albumId: 'album-1', name: '第一页', createTime: 1n, uploadNumber: 1n, }, ], attachInfo: 'next-cursor', hasMore: true, }, }), ), }) .mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { albumList: [ { albumId: 'album-2', name: '第二页', createTime: 2n, uploadNumber: 2n, }, ], hasMore: false, }, }), ), }); const result = await new GroupAlbumApi(bridge as never).list(12345); expect(result.map((album) => album.id)).toEqual(['album-1', 'album-2']); expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2); const secondRequest = protobuf_decode( bridge.sendRawPacket.mock.calls[1]![1], ); expect(secondRequest.data?.cursor).toBe('next-cursor'); }); it('rejects a legacy album page that claims more data without a cursor', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { albumList: [], hasMore: true, }, }), ), }); await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow( 'hasMore=true but cursor is empty', ); }); it('rejects a repeated AlbumService cursor instead of looping or truncating', async () => { const bridge = mockBridge(); const repeatedPage = { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { albumList: [], attachInfo: 'same-cursor', hasMore: true, }, }), ), }; bridge.sendRawPacket .mockResolvedValueOnce(repeatedPage) .mockResolvedValueOnce(repeatedPage); await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow( 'repeated cursor at page 2', ); expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2); }); it('returns the native AlbumService cursor and creator fields for get_qun_album_list', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { albumList: [ { albumId: 'album-id', name: '测试相册', createTime: 1700000000n, uploadNumber: 5n, creator: { uin: '10001', nick: '😂' }, }, ], attachInfo: 'next-cursor', hasMore: true, }, }), ), }); const result = await new GroupAlbumApi(bridge as never).listQun(12345); expect(result.attachInfo).toBe('next-cursor'); expect(result.hasMore).toBe(true); expect(result.albumList[0]).toMatchObject({ album_id: 'album-id', name: '测试相册', create_time: '1700000000', upload_number: '5', creator: { uin: '10001', nick: '😂' }, }); }); it('decodes the native album cover field for get_qun_album_list', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from(albumListResponseWithCover()), }); const result = await new GroupAlbumApi(bridge as never).listQun(12345); expect(result.albumList[0]).toMatchObject({ album_id: 'album-id', last_upload_time: '1700000123', cover: { type: 1, image: { name: 'cover.jpg', sloc: 'small-location', lloc: 'large-location', photoUrls: [ { spec: 3, url: { url: 'https://example.test/cover-320.jpg', width: 320, height: 180, }, }, ], defaultUrl: { url: 'https://example.test/cover.jpg', width: 1280, height: 720 }, isGif: false, hasRaw: true, }, }, }); }); it('returns cover and last-upload metadata from the legacy album list action', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from(albumListResponseWithCover()), }); const result = await new GroupAlbumApi(bridge as never).list(12345); expect(result[0]).toMatchObject({ id: 'album-id', last_upload_time: 1700000123, cover: { type: 1, image: { name: 'cover.jpg', defaultUrl: { url: 'https://example.test/cover.jpg', width: 1280, height: 720 }, }, }, }); }); it('surfaces AlbumService response errors instead of falling back to the lossy web endpoint', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ seq: 3331, result: 1001, errorText: 'permission denied', }), ), }); await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow( 'result=1001, error=permission denied', ); }); it('encodes the next-page cursor in the page-info field', async () => { const bridge = mockBridge(); const cursor = '{"IndexType":"next","Loc":{"batch_id":2147483650}}'; await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id', cursor); const [, requestBytes] = bridge.sendRawPacket.mock.calls[0]!; const request = protobuf_decode(requestBytes); expect(request.reqInfo?.pageInfo).toBe(cursor); expect(request.reqInfo?.reserved).toBeNull(); }); it('accepts a successful media-list response with an omitted zero retCode', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { mediaList: [ { type: 1, uploader: '10001', batchId: 123n, uploadTime: 456n, }, ], nextAttachInfo: 'next-page', }, }), ), }); const result = await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id'); expect(result).toEqual({ mediaList: [ { type: 1, image: null, video: null, uploader: '10001', batchId: '123', uploadTime: '456', }, ], nextAttachInfo: 'next-page', }); }); it('returns video metadata from the group album media list', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockResolvedValueOnce({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ data: { mediaList: [ { type: 2, video: { id: 'video-id', url: 'https://example.test/video.mp4', cover: { name: 'cover.jpg', sloc: 'cover-small', lloc: 'cover-large', photoUrls: [ { spec: 3, url: { url: 'https://example.test/cover-320.jpg', width: 320, height: 180, }, }, ], defaultUrl: { url: 'https://example.test/cover.jpg', width: 1920, height: 1080, }, isGif: true, hasRaw: true, }, width: 1920, height: 1080, videoTime: 15n, videoUrl: [ { spec: 1, url: { url: 'https://example.test/video-720p.mp4', width: 1280, height: 720, }, }, ], }, uploader: '10001', batchId: 123n, uploadTime: 456n, }, ], nextAttachInfo: 'next-video-page', }, }), ), }); const result = await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id'); expect(result).toEqual({ mediaList: [ { type: 2, image: null, video: { id: 'video-id', url: 'https://example.test/video.mp4', cover: { name: 'cover.jpg', sloc: 'cover-small', lloc: 'cover-large', photoUrls: [ { spec: 3, url: { url: 'https://example.test/cover-320.jpg', width: 320, height: 180, }, }, ], defaultUrl: { url: 'https://example.test/cover.jpg', width: 1920, height: 1080, }, isGif: true, hasRaw: true, }, width: 1920, height: 1080, videoTime: '15', videoUrl: [ { spec: 1, url: { url: 'https://example.test/video-720p.mp4', width: 1280, height: 720, }, }, ], }, uploader: '10001', batchId: '123', uploadTime: '456', }, ], nextAttachInfo: 'next-video-page', }); }); function packDeleteOk() { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from(protobuf_encode({ field1: 8694 })), }; } function packMediaList(body: GetMediaListResponse['data']) { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from(protobuf_encode({ data: body })), }; } it('deletes a photo by lloc and includes the batch id', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation(async (cmd: string) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [{ type: 1, image: { lloc: 'photo-lloc' }, batchId: 77n }], }); } return packDeleteOk(); }); await expect( new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'photo-lloc'), ).resolves.toEqual({ success: true }); const deleteCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('DeleteMedias'), ); expect(deleteCall?.[0]).toBe('QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.DeleteMedias'); const request = protobuf_decode(deleteCall![1] as Uint8Array); expect(request.body).toMatchObject({ groupId: '12345', albumId: 'album-id', mediaIds: ['photo-lloc'], batchIds: ['77'], }); }); it('deletes a video by video id using the cover lloc', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation(async (cmd: string) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [ { type: 2, video: { id: 'video-id', cover: { lloc: 'cover-large' } }, batchId: 88n, }, ], }); } return packDeleteOk(); }); await expect( new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'video-id'), ).resolves.toEqual({ success: true }); const deleteCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('DeleteMedias'), ); const request = protobuf_decode(deleteCall![1] as Uint8Array); expect(request.body?.mediaIds).toEqual(['cover-large']); expect(request.body?.batchIds).toEqual(['88']); }); it('pages the media list until the video is found', async () => { const bridge = mockBridge(); let pages = 0; bridge.sendRawPacket.mockImplementation(async (cmd: string) => { if (cmd.endsWith('GetMediaList')) { pages += 1; if (pages === 1) { return packMediaList({ mediaList: [{ type: 1, image: { lloc: 'other' }, batchId: 1n }], nextAttachInfo: 'page-2', }); } return packMediaList({ mediaList: [ { type: 2, video: { id: 'video-id', cover: { lloc: 'cover-2' } }, batchId: 2n, }, ], }); } return packDeleteOk(); }); await new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'video-id'); expect(pages).toBe(2); const deleteCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('DeleteMedias'), ); const request = protobuf_decode(deleteCall![1] as Uint8Array); expect(request.body?.mediaIds).toEqual(['cover-2']); expect(request.body?.batchIds).toEqual(['2']); }); it('falls back to the raw id when the media list has no match', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation(async (cmd: string) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [] }); } return packDeleteOk(); }); await new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'unknown-id'); const deleteCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('DeleteMedias'), ); const request = protobuf_decode(deleteCall![1] as Uint8Array); expect(request.body?.mediaIds).toEqual(['unknown-id']); expect(request.body?.batchIds ?? []).toEqual([]); }); function packCommentOk( comment: DoQunCommentResponse['comment'], ): ReturnType { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ field1: 8527, comment, }), ), }; } function packFeedDetail( feedId = 'official-feed-id', time = 1700000123n, extra: { ownerUin?: string; cellCommon?: Omit; cellMedia?: { medias?: Array<{ type?: number; image?: { lloc?: string }; video?: { cover?: { lloc?: string } }; }>; albumId?: string; batchId?: bigint; }; } = {}, ): ReturnType { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ result: 0, data: { feed: { feed: { cellCommon: { time, feedId, ...extra.cellCommon }, ...(extra.ownerUin ? { cellUserInfo: { user: { uin: extra.ownerUin } } } : {}), ...(extra.cellMedia ? { cellMedia: extra.cellMedia } : {}), }, }, }, }), ), }; } function packPhotoMedia(lloc = 'photo-lloc', batchId = 77n) { return packMediaList({ mediaList: [{ type: 1, image: { lloc }, batchId }], }); } function commentMocks( extra: (cmd: string) => ReturnType | undefined = () => undefined, ) { return async (cmd: string) => { const override = extra(cmd); if (override) { return override; } if (cmd.endsWith('GetMediaList')) { return packPhotoMedia(); } if (cmd.endsWith('GetQunFeedDetail')) { return packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '3119936551', cellCommon: { type: 422, cellId: '421_1_0_12345|album-id|77^||^421_1_0_12345|album-id|photo-lloc^||^0', field6: 3, }, }); } return packCommentOk({ data: { id: 'cmt-1', user: { uin: '10001' }, content: [{ type: 0, content: 'hello' }], time: 1700000000n, clientKey: 'ck', }, }); }; } function commentCallBytes(bridge: ReturnType): Uint8Array { const commentCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('DoQunComment'), ); expect(commentCall?.[0]).toBe( 'QunAlbum.trpc.qzone.webapp_qun_operation.FeedsWriter.DoQunComment', ); return commentCall![1] as Uint8Array; } function commentRequestOf(bridge: ReturnType): DoQunCommentRequest { return protobuf_decode(commentCallBytes(bridge)); } function headerOf(request: DoQunCommentRequest): CommentReqBodyHeader { const raw = request.body?.reqBody?.field1; return raw ? protobuf_decode(raw) : {}; } function userWrapOf(request: DoQunCommentRequest): CommentReqBodyUserWrap { const raw = request.body?.reqBody?.field2; return raw ? protobuf_decode(raw) : {}; } function photoOf(request: DoQunCommentRequest): CommentReqPhotoInfo { const raw = request.body?.reqBody?.field5; return raw ? protobuf_decode(raw) : {}; } function feedDetailRequestOf(bridge: ReturnType): GetQunFeedDetailRequest { const feedCall = bridge.sendRawPacket.mock.calls.find((call) => String(call[0]).endsWith('GetQunFeedDetail'), ); expect(feedCall?.[0]).toBe( 'QunAlbum.trpc.qzone.webapp_qun_feeds.FeedsReader.GetQunFeedDetail', ); return protobuf_decode(feedCall![1] as Uint8Array); } it('looks up the official feed then comments with its header and slim photo cell', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation(commentMocks()); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).resolves.toEqual({ id: 'cmt-1', user: { uin: '10001' }, content: [{ type: 0, content: 'hello' }], time: '1700000000', clientKey: 'ck', }); expect(feedDetailRequestOf(bridge).data).toMatchObject({ groupId: '12345', commentCount: 20, albumId: 'album-id', batchId: '77', lloc: 'photo-lloc', }); expect(feedDetailRequestOf(bridge).data?.feedId ?? '').toBe(''); expect(feedDetailRequestOf(bridge).data?.attachInfo ?? '').toBe(''); const request = commentRequestOf(bridge); expect(request.field1).toBe(8527); expect(request.body).toMatchObject({ groupId: '12345', field3: 2, field5: { user: { uin: '10001', uinNum: 10001n }, contents: [{ type: 0, content: 'hello' }], }, }); expect(headerOf(request)).toMatchObject({ time: 1700000123n, feedId: 'official-feed-id', }); expect(userWrapOf(request).field1?.uin).toBe('3119936551'); expect(photoOf(request)).toMatchObject({ albumId: 'album-id', batchId: 77n, medias: [ { image: { lloc: 'photo-lloc' }, }, ], }); expect(photoOf(request).medias?.[0]?.type ?? 0).toBe(0); const contentBytes = request.body?.field5?.contents; expect(contentBytes?.[0]?.type).toBe(0); expect(Buffer.from(commentCallBytes(bridge)).subarray(0, 7).toString('hex')).toBe( '08cf4212001a00', ); }); it('falls back to the media uploader when the official feed has no owner cell', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [ { type: 1, image: { lloc: 'photo-lloc' }, batchId: 77n, uploader: '3119936551', }, ], }); } if (cmd.endsWith('GetQunFeedDetail')) { return packFeedDetail(); } return undefined; }), ); await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'); expect(userWrapOf(commentRequestOf(bridge)).field1?.uin).toBe('3119936551'); expect(commentRequestOf(bridge).body?.field5?.user).toMatchObject({ uin: '10001', uinNum: 10001n, }); }); it('copies the official feed media cell instead of a reconstructed lloc-only cell', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetQunFeedDetail')) { return packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '3119936551', cellMedia: { albumId: 'feed-album', batchId: 88n, medias: [ { type: 0, image: { lloc: 'feed-lloc' }, }, ], }, }); } return undefined; }), ); await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'); const request = commentRequestOf(bridge); expect(photoOf(request)).toMatchObject({ albumId: 'feed-album', batchId: 88n, medias: [ { image: { lloc: 'feed-lloc' }, }, ], }); const feedBytes = new Uint8Array( packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '3119936551', cellMedia: { albumId: 'feed-album', batchId: 88n, medias: [{ type: 0, image: { lloc: 'feed-lloc' } }], }, }).responseData, ); expect(Buffer.from(request.body?.reqBody?.field5 ?? [])).toEqual( Buffer.from(cellBytes(feedBytes, 5) ?? []), ); }); it.each([ { name: 'six-byte', value: [0x80, 0x80, 0x80, 0x80, 0x80, 0x20] }, { name: 'ten-byte', value: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01] }, ])('accepts a $name unknown integer before the feed data', async ({ value }) => { const packed = packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '10002' }); const responseData = Buffer.from( concatBytes([new Uint8Array([0x30, ...value]), new Uint8Array(packed.responseData)]), ); expect( protobuf_decode(responseData).data?.feed?.feed?.cellCommon ?.feedId, ).toBe('official-feed-id'); const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => cmd.endsWith('GetQunFeedDetail') ? { ...packed, responseData } : undefined, ), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).resolves.toMatchObject({ id: 'cmt-1' }); }); it('copies an unknown media-cell field through unchanged', async () => { const packed = packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '3119936551', cellMedia: { albumId: 'feed-album', batchId: 88n, medias: [{ type: 0, image: { lloc: 'feed-lloc' } }], }, }); const base = new Uint8Array(packed.responseData); const original = cellBytes(base, 5); expect(original).toBeDefined(); const injected = concatBytes([original!, pbVarintField(99, 7)]); const responseData = replaceLengthDelimited(base, [4, 2, 1, 5], injected); const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetQunFeedDetail')) { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from(responseData), }; } return undefined; }), ); await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'); expect(Buffer.from(commentRequestOf(bridge).body?.reqBody?.field5 ?? [])).toEqual( Buffer.from(injected), ); }); it('copies the official comment header, including locator fields', async () => { const cellId = '421_1_0_964445447|album-id|2147483665^||^421_1_0_964445447|album-id|photo-lloc^||^0'; const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetQunFeedDetail')) { return packFeedDetail('422_0_2147483665', 1789315793n, { ownerUin: '3119936551', cellCommon: { type: 422, cellId, field6: 3 }, }); } return undefined; }), ); await new GroupAlbumApi(bridge as never).comment( 964445447, 'album-id', 'photo-lloc', 'hello', ); const request = commentRequestOf(bridge); expect(headerOf(request)).toMatchObject({ type: 422, time: 1789315793n, feedId: '422_0_2147483665', cellId, field6: 3, }); const feedBytes = new Uint8Array( packFeedDetail('422_0_2147483665', 1789315793n, { ownerUin: '3119936551', cellCommon: { type: 422, cellId, field6: 3 }, }).responseData, ); expect(Buffer.from(request.body?.reqBody?.field1 ?? [])).toEqual( Buffer.from(cellBytes(feedBytes, 1) ?? []), ); }); it('comments a video with the cover location and video media type', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [ { type: 2, video: { id: 'video-id', cover: { lloc: 'cover-lloc' } }, batchId: 88n, }, ], }); } return undefined; }), ); await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'video-id', 'hello'); expect(feedDetailRequestOf(bridge).data?.lloc).toBe('cover-lloc'); expect(photoOf(commentRequestOf(bridge))).toMatchObject({ albumId: 'album-id', batchId: 88n, medias: [ { type: 1, video: { cover: { lloc: 'cover-lloc' } }, }, ], }); }); it('rejects a comment when the album media cannot be resolved', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetMediaList')) { return packMediaList({ mediaList: [] }); } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'missing-lloc', 'hello'), ).rejects.toThrow('comment album media error: media not found'); expect( bridge.sendRawPacket.mock.calls.some((call) => String(call[0]).endsWith('DoQunComment'), ), ).toBe(false); }); it('rejects a comment when the official feed lookup fails', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetQunFeedDetail')) { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ result: 10016, errorText: '服务器繁忙', }), ), }; } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).rejects.toThrow('fetch album feed error: retCode 10016, 服务器繁忙'); expect( bridge.sendRawPacket.mock.calls.some((call) => String(call[0]).endsWith('DoQunComment'), ), ).toBe(false); }); it('rejects a comment when the official feed has no id', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('GetQunFeedDetail')) { return packFeedDetail(''); } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).rejects.toThrow('comment album media error: empty feed'); expect( bridge.sendRawPacket.mock.calls.some((call) => String(call[0]).endsWith('DoQunComment'), ), ).toBe(false); }); it('rejects a seq-echo 8527 packet that has no comment body', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('DoQunComment')) { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ field1: 8527 }), ), }; } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).rejects.toThrow('comment album media error: empty comment'); }); it('surfaces the album result code instead of treating seq 8527 as success', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('DoQunComment')) { return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ field1: 8527, result: 1001, errorText: 'permission denied', }), ), }; } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).rejects.toThrow('comment album media error: retCode 1001, permission denied'); }); it('accepts a comment written directly on envelope field 4', async () => { const bridge = mockBridge(); bridge.sendRawPacket.mockImplementation( commentMocks((cmd) => { if (cmd.endsWith('DoQunComment')) { return packCommentOk({ id: 'flat-id', content: [{ type: 0, content: 'hello' }], time: 123n, clientKey: 'ck', }); } return undefined; }), ); await expect( new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'), ).resolves.toMatchObject({ id: 'flat-id', user: { uin: '10001' }, content: [{ type: 0, content: 'hello' }], time: '123', clientKey: 'ck', }); }); }); function concatBytes(parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } function pbVarint(n: number): Uint8Array { const bytes: number[] = []; let value = n; while (value > 0x7f) { bytes.push((value & 0x7f) | 0x80); value = Math.floor(value / 128); } bytes.push(value); return Uint8Array.from(bytes); } function pbVarintField(field: number, n: number): Uint8Array { return concatBytes([pbVarint((field << 3) | 0), pbVarint(n)]); } function readTestVarint(buf: Uint8Array, offset: number): { value: number; next: number } { let value = 0; let shift = 0; let next = offset; while (next < buf.length && shift <= 28) { const byte = buf[next++]; value += (byte & 0x7f) * 2 ** shift; if ((byte & 0x80) === 0) { return { value, next }; } shift += 7; } throw new Error('truncated protobuf varint'); } function cellBytes(buf: Uint8Array, fieldNo: number): Uint8Array | undefined { return lengthDelimitedAt(buf, [4, 2, 1, fieldNo]); } function lengthDelimitedAt(buf: Uint8Array, path: number[]): Uint8Array | undefined { let current: Uint8Array | undefined = buf; for (const fieldNo of path) { if (!current) { return undefined; } current = firstDelimited(current, fieldNo); } return current; } function firstDelimited(buf: Uint8Array, fieldNo: number): Uint8Array | undefined { let offset = 0; while (offset < buf.length) { const key = readTestVarint(buf, offset); offset = key.next; const field = Math.floor(key.value / 8); const wire = key.value % 8; if (wire === 0) { offset = readTestVarint(buf, offset).next; } else if (wire === 1) { offset += 8; } else if (wire === 5) { offset += 4; } else if (wire === 2) { const len = readTestVarint(buf, offset); offset = len.next; const end = offset + len.value; if (end > buf.length) { return undefined; } if (field === fieldNo) { return buf.slice(offset, end); } offset = end; } else { return undefined; } } return undefined; } function replaceLengthDelimited( buf: Uint8Array, path: number[], replacement: Uint8Array, ): Uint8Array { const [fieldNo, ...rest] = path; if (fieldNo === undefined) { return replacement; } const parts: Uint8Array[] = []; let offset = 0; let replaced = false; while (offset < buf.length) { const keyStart = offset; const key = readTestVarint(buf, offset); offset = key.next; const field = Math.floor(key.value / 8); const wire = key.value % 8; if (wire === 2) { const len = readTestVarint(buf, offset); const innerStart = len.next; const innerEnd = innerStart + len.value; const inner = buf.slice(innerStart, innerEnd); if (!replaced && field === fieldNo) { const nextInner = rest.length > 0 ? replaceLengthDelimited(inner, rest, replacement) : replacement; parts.push( concatBytes([ buf.slice(keyStart, offset), pbVarint(nextInner.length), nextInner, ]), ); replaced = true; } else { parts.push(buf.slice(keyStart, innerEnd)); } offset = innerEnd; } else if (wire === 0) { offset = readTestVarint(buf, offset).next; parts.push(buf.slice(keyStart, offset)); } else if (wire === 1) { offset += 8; parts.push(buf.slice(keyStart, offset)); } else if (wire === 5) { offset += 4; parts.push(buf.slice(keyStart, offset)); } else { throw new Error(`unsupported wire type ${wire}`); } } return concatBytes(parts); }