import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { NTQQWebApi } from '@/napcat-core/apis/webapi'; import { nextQunAlbumBatchId, qunAlbumControl, qunAlbumVideoControl, qunAlbumVideoCoverControl } from '@/napcat-core/data/webapi'; const GROUP_ID = '2161015335'; const ALBUM_ID = 'V624Oe3c1Dk7pX4et6lh4703uM49iGAR'; const ALBUM_NAME = '水哥'; const UIN = '2965296625'; // control_req 在严格模式(noUncheckedIndexedAccess)下是可选元素,这里统一收敛掉 const firstReq = (body: { control_req: unknown[] }) => { const req = body.control_req[0]; if (!req) throw new Error('control_req 为空'); return req as NonNullable['control_req'][0]>; }; const imageReq = (overrides: Record = {}) => firstReq(qunAlbumControl({ uin: UIN, group_id: GROUP_ID, pskey: 'PSKEY', pic_md5: 'MD5A', img_size: 12345, img_name: 'a.jpg', sAlbumName: ALBUM_NAME, sAlbumID: ALBUM_ID, ...overrides, })); describe('群相册图片组包', () => { it('单图:字段与改动前一致,批次号为秒级时间戳', () => { const req = imageReq(); expect(req.appid).toBe('qun'); expect(req.cmd).toBe('FileUpload'); expect(req.check_type).toBe(0); expect(req.checksum).toBe('MD5A'); expect(req.env).toEqual({ refer: 'qzone', deviceInfo: 'h5' }); expect(req.biz_req?.sPicTitle).toBe('a.jpg'); expect(req.biz_req?.sAlbumID).toBe(ALBUM_ID); expect(req.biz_req?.sAlbumName).toBe(ALBUM_NAME); expect(req.biz_req?.iUploadType).toBe(0); expect(req.biz_req?.iNeedFeeds).toBe(1); expect(req.biz_req?.stExtendInfo?.mapParams).toEqual({ photo_num: '1', video_num: '0', batch_num: '1' }); // 每张图各自成批时,批次号仍是 10 位秒级时间戳 expect(String(req.biz_req?.iBatchID)).toHaveLength(10); }); it('同一秒内的两次上传批次号不同,不会被相册并成一条', () => { const first = imageReq().biz_req?.iBatchID; const second = imageReq().biz_req?.iBatchID; expect(second).toBeGreaterThan(first ?? Infinity); expect(nextQunAlbumBatchId()).toBeGreaterThan(second ?? Infinity); }); it('多图同批:共用批次号,张数与序号按批上报', () => { const batchId = 1791038000; const total = '3'; const reqs = [0, 1, 2].map(i => imageReq({ pic_md5: `MD5${i}`, batchId, batchIndex: String(i), photo_num: total, batch_num: total, })); expect(new Set(reqs.map(r => r.biz_req?.iBatchID)).size).toBe(1); expect(reqs[0]?.biz_req?.iBatchID).toBe(batchId); reqs.forEach((req, i) => { expect(req.biz_req?.stExtendInfo?.mapParams).toEqual({ photo_num: total, video_num: '0', batch_num: total }); expect(req.biz_req?.mutliPicInfo).toEqual({ iBatUploadNum: total, iCurUpload: String(i), iSuccNum: 0, iFailNum: 0, }); expect(req.checksum).toBe(`MD5${i}`); }); }); }); describe('群相册视频组包', () => { it('视频主体:video_qun / FileUploadVideo / SHA1,且不带相册与批次号', () => { const req = firstReq(qunAlbumVideoControl({ uin: UIN, group_id: GROUP_ID, pskey: 'PSKEY', video_sha1: 'SHA1A', video_size: 1048576, play_time: 37, })); expect(req.appid).toBe('video_qun'); expect(req.cmd).toBe('FileUploadVideo'); expect(req.check_type).toBe(1); expect(req.checksum).toBe('SHA1A'); expect(req.file_len).toBe(1048576); expect(req.env).toEqual({ refer: 'qzone', deviceInfo: 'h5' }); expect(req.biz_req?.iUploadType).toBe(3); expect(req.biz_req?.iPlayTime).toBe(37); expect(req.biz_req?.iIsNew).toBe(111); // 视频主体不参与批次:iBatchID 固定 0,相册留空,也没有 mutliPicInfo expect(req.biz_req?.iBatchID).toBe(0); expect(req.biz_req?.sAlbumID).toBe(''); expect(req.biz_req?.sAlbumName).toBe(''); expect(req.biz_req?.mutliPicInfo).toBeUndefined(); expect(req.biz_req?.stExtendInfo).toBeUndefined(); expect(req.biz_req?.mapExt).toBeUndefined(); expect(req.biz_req?.extend_info).toEqual({ video_type: '3', domainid: '5', photo_num: '0', video_num: '1', batch_num: '1', qun_id: GROUP_ID, }); }); it('封面二段:走图片口径带相册与批次号,并用 vid 绑定视频', () => { const batchId = 1791038000; const req = firstReq(qunAlbumVideoCoverControl({ uin: UIN, group_id: GROUP_ID, pskey: 'PSKEY', sAlbumName: ALBUM_NAME, sAlbumID: ALBUM_ID, cover_md5: 'COVERMD5', cover_size: 20000, vid: 'SV123', batchId, })); expect(req.appid).toBe('qun'); expect(req.cmd).toBeUndefined(); expect(req.check_type).toBe(0); expect(req.checksum).toBe('COVERMD5'); // 封面这一步才带相册和批次号——视频靠它进相册 expect(req.env).toEqual({ refer: 'huodong', deviceInfo: 'h5' }); expect(req.biz_req?.iUploadType).toBe(2); expect(req.biz_req?.iBatchID).toBe(batchId); expect(req.biz_req?.sAlbumID).toBe(ALBUM_ID); expect(req.biz_req?.sAlbumName).toBe(ALBUM_NAME); expect(req.biz_req?.iNeedFeeds).toBe(1); expect(req.biz_req?.stExtendInfo?.mapParams).toEqual({ vid: 'SV123', photo_num: '0', video_num: '1', batch_num: '1', }); expect(req.biz_req?.stExternalMapExt).toEqual({ is_client_upload_cover: '1', is_pic_video_mix_feeds: '1', }); expect(req.biz_req?.mutliPicInfo).toEqual({ iBatUploadNum: '1', iCurUpload: '0', iSuccNum: 0, iFailNum: 0, }); expect(req.biz_req?.mapExt).toEqual({ appid: 'qun', userid: GROUP_ID }); }); }); describe('群相册分片上传', () => { let tempDir: string; let sliceFile: string; const createApi = () => new NTQQWebApi( { logger: { log: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() } } as never, { NapCatTempPath: tempDir } as never ); beforeEach(async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'napcat-album-slice-')); sliceFile = path.join(tempDir, 'payload.bin'); // 40000 字节 / 16384 分片 = 3 片 await writeFile(sliceFile, Buffer.alloc(40000, 7)); }); afterEach(async () => { vi.unstubAllGlobals(); await rm(tempDir, { recursive: true, force: true }); }); it('按分片大小切片,把 appid/cmd 透传到每个分片请求,并回收 sVid', async () => { const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => new Response(JSON.stringify({ ret: 0, msg: '', data: { biz: { sVid: 'SV123' } }, }), { status: 200 })); vi.stubGlobal('fetch', fetchMock); const result = await createApi().uploadQunAlbumSlice( sliceFile, 'SESSION', 'SKEY', 'PSKEY', UIN, 16384, { appid: 'video_qun', cmd: 'FileUploadVideo' } ); expect(fetchMock).toHaveBeenCalledTimes(3); expect(result).toMatchObject({ success: true, sVid: 'SV123' }); // 分片是并发发出的,调用顺序不确定,按 seq 建索引再断言 const forms = new Map(fetchMock.mock.calls.map((call) => { const form = call[1]?.body as unknown as FormData; return [form.get('seq'), form] as const; })); const first = forms.get('0'); expect(first).toBeDefined(); expect(first?.get('appid')).toBe('video_qun'); expect(first?.get('cmd')).toBe('FileUploadVideo'); expect(first?.get('session')).toBe('SESSION'); expect(first?.get('offset')).toBe('0'); expect(first?.get('end')).toBe('16384'); expect(first?.get('slice_size')).toBe('16384'); const urls = fetchMock.mock.calls.map(call => String(call[0])); expect(urls.some(url => url.includes('seq=0&retry=0&offset=0&end=16384&total=40000'))).toBe(true); expect(urls.some(url => url.includes('seq=2&retry=0&offset=32768&end=40000&total=40000'))).toBe(true); // 路径必须跟随 cmd:视频走 /sliceUpload/FileUploadVideo,否则服务端不返回 sVid expect(urls.every(url => url.includes('/webapp/json/sliceUpload/FileUploadVideo?'))).toBe(true); }); it('不传 appid/cmd 时退回图片口径(路径也是 FileUpload)', async () => { const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => new Response(JSON.stringify({ ret: 0, msg: '' }), { status: 200 })); vi.stubGlobal('fetch', fetchMock); const result = await createApi().uploadQunAlbumSlice(sliceFile, 'SESSION', 'SKEY', 'PSKEY', UIN, 16384); const form = fetchMock.mock.calls[0]?.[1]?.body as unknown as FormData; expect(form.get('appid')).toBe('qun'); expect(form.get('cmd')).toBe('FileUpload'); expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/webapp/json/sliceUpload/FileUpload?'); expect(result.sVid).toBeUndefined(); }); it('最后一片等其它分片全部传完才发', async () => { // 100000 字节 / 16384 = 7 片(seq 0~6) await writeFile(sliceFile, Buffer.alloc(100000, 7)); let finished = 0; let finishedWhenLastSent = -1; const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { const seq = Number((init.body as unknown as FormData).get('seq')); if (seq === 6) { finishedWhenLastSent = finished; } else { // 前面的分片故意慢一点、倒序完成 await new Promise(resolve => setTimeout(resolve, (6 - seq) * 5)); finished++; } return new Response(JSON.stringify({ ret: 0, msg: '' }), { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); await createApi().uploadQunAlbumSlice(sliceFile, 'SESSION', 'SKEY', 'PSKEY', UIN, 16384); expect(fetchMock).toHaveBeenCalledTimes(7); expect(finishedWhenLastSent).toBe(6); }); it('前面的分片失败时不再发最后一片', async () => { const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { const seq = (init.body as unknown as FormData).get('seq'); return new Response(JSON.stringify(seq === '1' ? { ret: -1, msg: 'boom' } : { ret: 0, msg: '' }), { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); await expect(createApi().uploadQunAlbumSlice(sliceFile, 'SESSION', 'SKEY', 'PSKEY', UIN, 16384)) .rejects.toThrow('分片 1 上传失败: boom'); const seqs = fetchMock.mock.calls.map(call => (call[1]?.body as unknown as FormData).get('seq')); expect(seqs).not.toContain('2'); }); it('分片失败时抛出带序号与原因的错误', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ret: -1, msg: 'boom' }), { status: 200 }))); // 分片是并发上传的,Promise.all 抛出的是最先失败的那一片,序号不确定 await expect(createApi().uploadQunAlbumSlice(sliceFile, 'SESSION', 'SKEY', 'PSKEY', UIN, 16384)) .rejects.toThrow(/分片 \d+ 上传失败: boom/); }); });