283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
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<ReturnType<typeof qunAlbumControl>['control_req'][0]>;
|
||
};
|
||
|
||
const imageReq = (overrides: Record<string, unknown> = {}) => 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/);
|
||
});
|
||
});
|