NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# NapCat-Test
|
||||
## 测试方向
|
||||
测试过程不涉及账号登录
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { encodeSilk } from '@/napcat-core/helper/audio';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import type { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
vi.mock('@/napcat-core/helper/ffmpeg/ffmpeg', () => ({
|
||||
FFmpegService: { isSilk: vi.fn(), convertToNTSilkTct: vi.fn(), getDuration: vi.fn() },
|
||||
}));
|
||||
|
||||
const logger = { log: vi.fn() } as unknown as LogWrapper;
|
||||
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
describe('SILK encoding', () => {
|
||||
it('reads duration from the encoded output including the final packet', async () => {
|
||||
vi.mocked(FFmpegService.isSilk).mockResolvedValue(false);
|
||||
vi.mocked(FFmpegService.getDuration).mockResolvedValue(0.02);
|
||||
const result = await encodeSilk('/short.wav', '/temp', logger);
|
||||
expect(result.converted).toBe(true);
|
||||
expect(FFmpegService.convertToNTSilkTct).toHaveBeenCalledWith('/short.wav', result.path);
|
||||
expect(FFmpegService.getDuration).toHaveBeenCalledWith(result.path);
|
||||
expect(result.duration).toBe(0.02);
|
||||
});
|
||||
|
||||
it('propagates a corrupt SILK error without estimating its duration', async () => {
|
||||
vi.mocked(FFmpegService.isSilk).mockResolvedValue(true);
|
||||
vi.mocked(FFmpegService.getDuration).mockRejectedValue(new Error('corrupt SILK packet'));
|
||||
await expect(encodeSilk('/corrupt.silk', '/temp', logger)).rejects.toThrow('corrupt SILK packet');
|
||||
expect(FFmpegService.convertToNTSilkTct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates conversion errors to the caller', async () => {
|
||||
vi.mocked(FFmpegService.isSilk).mockResolvedValue(false);
|
||||
vi.mocked(FFmpegService.convertToNTSilkTct).mockRejectedValue(new Error('encoder unavailable'));
|
||||
await expect(encodeSilk('/audio.wav', '/temp', logger)).rejects.toThrow('encoder unavailable');
|
||||
expect(FFmpegService.getDuration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { MessageUnique } from '../napcat-common/src/message-unique';
|
||||
import DelEssenceMsg from '../napcat-onebot/action/group/DelEssenceMsg';
|
||||
|
||||
function createAction (essenceLRUValue: string | undefined) {
|
||||
const removeGroupEssenceBySeq = vi.fn().mockResolvedValue({ result: 0 });
|
||||
const action = new DelEssenceMsg(
|
||||
undefined as never,
|
||||
{
|
||||
apis: {
|
||||
GroupApi: {
|
||||
essenceLRU: { getValue: vi.fn().mockReturnValue(essenceLRUValue) },
|
||||
removeGroupEssenceBySeq,
|
||||
},
|
||||
},
|
||||
} as never
|
||||
);
|
||||
return { action, removeGroupEssenceBySeq };
|
||||
}
|
||||
|
||||
describe('delete_essence_msg argument order', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('passes msg_random before msg_seq for explicitly supplied parameters', async () => {
|
||||
const { action, removeGroupEssenceBySeq } = createAction(undefined);
|
||||
|
||||
await action._handle({ group_id: '33', msg_seq: '11', msg_random: '22' });
|
||||
|
||||
// GroupApi.removeGroupEssenceBySeq(groupCode, msgRandom, msgSeq)
|
||||
expect(removeGroupEssenceBySeq).toHaveBeenCalledWith('33', '22', '11');
|
||||
});
|
||||
|
||||
test('passes msg_random before msg_seq for the essence LRU fallback', async () => {
|
||||
// get_essence_msg_list stores this payload under a synthetic message_id when the
|
||||
// original message can no longer be resolved from the local message cache.
|
||||
const { action, removeGroupEssenceBySeq } = createAction(JSON.stringify({
|
||||
msg_seq: '11',
|
||||
msg_random: '22',
|
||||
group_id: '33',
|
||||
}));
|
||||
vi.spyOn(MessageUnique, 'getMsgIdAndPeerByShortId').mockReturnValue(undefined);
|
||||
|
||||
await action._handle({ message_id: 12345 });
|
||||
|
||||
// GroupApi.removeGroupEssenceBySeq(groupCode, msgRandom, msgSeq)
|
||||
expect(removeGroupEssenceBySeq).toHaveBeenCalledWith('33', '22', '11');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { FFmpegAdapterFactory } from '@/napcat-core/helper/ffmpeg/ffmpeg-adapter-factory';
|
||||
import { FFmpegAddonAdapter } from '@/napcat-core/helper/ffmpeg/ffmpeg-addon-adapter';
|
||||
import { FFmpegExecAdapter } from '@/napcat-core/helper/ffmpeg/ffmpeg-exec-adapter';
|
||||
import { FFmpegService, getFFmpegPath } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import type { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
dlopen: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('fs')>();
|
||||
return { ...original, existsSync: mocks.exists };
|
||||
});
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...original,
|
||||
execFile: Object.assign(vi.fn(), {
|
||||
[Symbol.for('nodejs.util.promisify.custom')]: mocks.execute,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:process', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:process')>();
|
||||
return { ...original, dlopen: mocks.dlopen };
|
||||
});
|
||||
|
||||
const logger = { log: vi.fn(), logError: vi.fn() } as unknown as LogWrapper;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!;
|
||||
|
||||
beforeEach(() => {
|
||||
FFmpegAdapterFactory.reset();
|
||||
vi.stubEnv('FFMPEG_PATH', '');
|
||||
vi.stubEnv('FFPROBE_PATH', '');
|
||||
mocks.execute.mockReset().mockResolvedValue({ stdout: '', stderr: '' });
|
||||
mocks.dlopen.mockReset();
|
||||
mocks.exists.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
Object.defineProperty(process, 'arch', archDescriptor);
|
||||
});
|
||||
|
||||
describe('FFmpeg platform paths', () => {
|
||||
it.each(['arm64', 'x64'])('finds Homebrew for macOS %s outside GUI PATH', (arch) => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
vi.stubEnv('PATH', '/usr/bin:/bin');
|
||||
const prefix = arch === 'arm64' ? '/opt/homebrew/bin' : '/usr/local/bin';
|
||||
mocks.exists.mockImplementation(candidate => String(candidate).startsWith(path.join(prefix)));
|
||||
expect(getFFmpegPath('ffmpeg')).toBe(path.join(prefix, 'ffmpeg'));
|
||||
expect(getFFmpegPath('ffprobe')).toBe(path.join(prefix, 'ffprobe'));
|
||||
});
|
||||
|
||||
it('preserves explicit paths containing spaces without searching alternatives', () => {
|
||||
vi.stubEnv('FFMPEG_PATH', '/private/My Tools/ffmpeg');
|
||||
vi.stubEnv('FFPROBE_PATH', '/private/My Tools/ffprobe');
|
||||
const exists = mocks.exists.mockReturnValue(true);
|
||||
expect(getFFmpegPath('ffmpeg', '/app')).toBe('/private/My Tools/ffmpeg');
|
||||
expect(getFFmpegPath('ffprobe', '/app')).toBe('/private/My Tools/ffprobe');
|
||||
expect(exists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses packaged Windows executables before PATH', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
mocks.exists.mockReturnValue(true);
|
||||
expect(getFFmpegPath('ffmpeg', '/app')).toBe(path.join('/app', 'ffmpeg', 'ffmpeg.exe'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('FFmpeg adapter selection', () => {
|
||||
it.each(['arm64', 'x64'])('loads the packaged macOS %s addon', async (arch) => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
mocks.exists.mockReturnValue(true);
|
||||
mocks.dlopen.mockImplementation(module => { module.exports = {}; });
|
||||
const adapter = await FFmpegAdapterFactory.getAdapter(logger, 'ffmpeg', 'ffprobe', '/app');
|
||||
expect(adapter.name).toBe('FFmpegAddon');
|
||||
expect(mocks.dlopen).toHaveBeenCalledWith(expect.anything(), path.join('/app', 'native/ffmpeg', 'ffmpegAddon.darwin.' + arch + '.node'));
|
||||
expect(mocks.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not hide a broken packaged addon behind another adapter', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mocks.exists.mockReturnValue(true);
|
||||
mocks.dlopen.mockImplementation(() => { throw new Error('wrong architecture'); });
|
||||
await expect(FFmpegAdapterFactory.getAdapter(logger, 'ffmpeg', 'ffprobe', '/app')).rejects.toThrow('Native Addon');
|
||||
expect(mocks.execute).not.toHaveBeenCalled();
|
||||
expect(FFmpegAdapterFactory.getCurrentAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
it('honors an explicit command line configuration', async () => {
|
||||
vi.stubEnv('FFMPEG_PATH', '/tools/ffmpeg');
|
||||
mocks.exists.mockReturnValue(true);
|
||||
const adapter = await FFmpegAdapterFactory.getAdapter(logger, '/tools/ffmpeg', '/tools/ffprobe', '/app');
|
||||
expect(adapter.name).toBe('FFmpegExec');
|
||||
expect(mocks.dlopen).not.toHaveBeenCalled();
|
||||
expect(mocks.execute).toHaveBeenCalledWith('/tools/ffmpeg', ['-version'], expect.objectContaining({ timeout: 10000 }));
|
||||
expect(mocks.execute).toHaveBeenCalledWith('/tools/ffprobe', ['-version'], expect.objectContaining({ timeout: 10000 }));
|
||||
});
|
||||
|
||||
it('rejects a missing ffprobe and allows a corrected configuration', async () => {
|
||||
mocks.exists.mockReturnValue(false);
|
||||
mocks.execute.mockImplementation(async command => {
|
||||
if (command === 'ffprobe') throw new Error('ENOENT');
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
await expect(FFmpegAdapterFactory.getAdapter(logger)).rejects.toThrow('ffprobe');
|
||||
expect(FFmpegAdapterFactory.getCurrentAdapter()).toBeNull();
|
||||
mocks.execute.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
expect((await FFmpegAdapterFactory.getAdapter(logger)).name).toBe('FFmpegExec');
|
||||
});
|
||||
|
||||
it('validates replacement paths before changing the active adapter', async () => {
|
||||
const adapter = await FFmpegAdapterFactory.getAdapter(logger);
|
||||
mocks.execute.mockRejectedValue(new Error('EACCES'));
|
||||
await expect(FFmpegAdapterFactory.updateFFmpegPath(logger, '/bad/ffmpeg', '/bad/ffprobe')).rejects.toThrow('/bad/ffprobe');
|
||||
expect(FFmpegAdapterFactory.getCurrentAdapter()).toBe(adapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FFmpeg media operations', () => {
|
||||
it('transcodes encoded audio without treating its bytes as raw PCM', async () => {
|
||||
mocks.exists.mockReturnValue(true);
|
||||
await new FFmpegExecAdapter().convertFile('/record.wav', '/record.mp3', 'mp3');
|
||||
expect(mocks.execute).toHaveBeenCalledWith('ffmpeg', [
|
||||
'-i', '/record.wav', '-vn', '-ac', '1', '-y', '/record.mp3',
|
||||
], expect.objectContaining({ timeout: 120000, windowsHide: true }));
|
||||
});
|
||||
|
||||
it('reports a corrupt video instead of inventing dimensions and duration', async () => {
|
||||
mocks.execute.mockRejectedValue(new Error('invalid video'));
|
||||
await expect(new FFmpegExecAdapter().getVideoInfo('/video.mp4')).rejects.toThrow('invalid video');
|
||||
});
|
||||
|
||||
it('reads actual video metadata and JPEG bytes without a shared temporary file', async () => {
|
||||
const thumbnail = Buffer.from([255, 216, 255, 217]);
|
||||
mocks.execute.mockImplementation(async command => command === 'ffprobe'
|
||||
? { stdout: JSON.stringify({ streams: [{ width: 360, height: 480 }], format: { duration: '0.5', format_name: 'mov,mp4' } }) }
|
||||
: { stdout: thumbnail });
|
||||
await expect(new FFmpegExecAdapter().getVideoInfo('/clip with spaces.mp4')).resolves.toEqual({
|
||||
width: 360, height: 480, duration: 0.5, format: 'mov,mp4', thumbnail,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the native PNG encoder for legacy videos on macOS', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const adapter = new FFmpegAddonAdapter('/app');
|
||||
const extract = vi.spyOn(adapter, 'extractThumbnail').mockResolvedValue();
|
||||
vi.spyOn(FFmpegAdapterFactory, 'getAdapter').mockResolvedValue(adapter);
|
||||
await FFmpegService.init('/app', logger);
|
||||
await FFmpegService.extractLegacyVideoThumbnail('/video.mp4', '/thumbnail.png');
|
||||
expect(extract).toHaveBeenCalledWith('/video.mp4', '/thumbnail.png', 'png');
|
||||
expect(mocks.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps startup available and reports unavailable media until paths are corrected', async () => {
|
||||
vi.resetModules();
|
||||
const { FFmpegService: service } = await import('@/napcat-core/helper/ffmpeg/ffmpeg');
|
||||
mocks.exists.mockReturnValue(false);
|
||||
mocks.execute.mockRejectedValue(new Error('ENOENT'));
|
||||
await expect(service.init('/app', logger)).resolves.toBeUndefined();
|
||||
await expect(service.getDuration('/audio.wav')).rejects.toThrow('ffprobe');
|
||||
expect(logger.logError).toHaveBeenCalled();
|
||||
mocks.execute.mockResolvedValue({ stdout: '1.5', stderr: '' });
|
||||
await service.setFfmpegPath('/installed tools', logger);
|
||||
await expect(service.getDuration('/audio.wav')).resolves.toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { gunzipSync, inflateSync } from 'node:zlib';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { ChatType, ElementType, FaceType } from '@/napcat-core/types/msg';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { PacketMsgFaceElement } from '@/napcat-core/packet/message/element';
|
||||
import { LongMsgResult, QBigFaceExtra, SendLongMsgReq } from '@/napcat-core/packet/transformer/proto';
|
||||
import UploadForwardMsgV2 from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
import { OneBotMsgApi } from '@/napcat-onebot/api/msg';
|
||||
import { OB11MessageData, OB11MessageDataType } from '@/napcat-onebot/types/message';
|
||||
|
||||
// Keep the real message types without starting the adapter's WebUI/network modules.
|
||||
vi.mock('@/napcat-onebot/index', async () => await import('@/napcat-onebot/types'));
|
||||
vi.mock('napcat-core/packet/transformer/proto', async () => await import('@/napcat-core/packet/transformer/proto'));
|
||||
|
||||
const text = (value: string): OB11MessageData => ({
|
||||
type: OB11MessageDataType.text,
|
||||
data: { text: value },
|
||||
});
|
||||
const face = (id: string): OB11MessageData => ({
|
||||
type: OB11MessageDataType.face,
|
||||
data: { id },
|
||||
});
|
||||
|
||||
async function buildForwardElements (content: OB11MessageData[], groupUin = 0) {
|
||||
const api = new OneBotMsgApi(
|
||||
{} as ConstructorParameters<typeof OneBotMsgApi>[0],
|
||||
{} as ConstructorParameters<typeof OneBotMsgApi>[1]
|
||||
);
|
||||
const { sendElements } = await api.createSendElements(content, {
|
||||
chatType: groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C,
|
||||
peerUid: groupUin ? String(groupUin) : 'u_friend',
|
||||
guildId: '',
|
||||
});
|
||||
const msg = new PacketMsgConverter().rawMsgWithSendMsgToPacketMsg({
|
||||
senderUin: 123456,
|
||||
senderName: '测试',
|
||||
groupId: groupUin,
|
||||
time: 1700000000,
|
||||
msg: sendElements,
|
||||
});
|
||||
const packet = UploadForwardMsgV2.build('u_self', [{ actionCommand: 'MultiMsg', actionMsg: [msg] }], groupUin);
|
||||
const request = new NapProtoMsg(SendLongMsgReq).decode(packet.data);
|
||||
const payload = new NapProtoMsg(LongMsgResult).decode(gunzipSync(request.info.payload!));
|
||||
return payload.action[0]!.actionData.msgBody[0]!.body!.richText!.elems;
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ name: 'private', groupUin: 0 },
|
||||
{ name: 'group', groupUin: 654321 },
|
||||
])('$name forward face encoding', ({ groupUin }) => {
|
||||
it('keeps the text immediately after the animated face in issue #2097', async () => {
|
||||
const elems = await buildForwardElements([text('1234'), face('317'), text('5678'), text('在')], groupUin);
|
||||
|
||||
expect(elems).toMatchObject([
|
||||
{ text: { str: '1234' } },
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '/菜汪' } },
|
||||
{ text: { str: '5678' } },
|
||||
{ text: { str: '在' } },
|
||||
]);
|
||||
expect(new NapProtoMsg(QBigFaceExtra).decode(elems[1]!.commonElem!.pbElem).faceId).toBe(317);
|
||||
});
|
||||
|
||||
it('gives each consecutive animated face its own compatibility text', async () => {
|
||||
const elems = await buildForwardElements([face('317'), face('318'), text('after')], groupUin);
|
||||
|
||||
expect(elems).toMatchObject([
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '/菜汪' } },
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '/崇拜' } },
|
||||
{ text: { str: 'after' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes compatibility text when the animated face ends the node', async () => {
|
||||
const elems = await buildForwardElements([face('317')], groupUin);
|
||||
|
||||
expect(elems).toMatchObject([
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '/菜汪' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a non-text segment immediately after an animated face', async () => {
|
||||
const json = JSON.stringify({ app: 'test', prompt: 'card' });
|
||||
const elems = await buildForwardElements([
|
||||
face('317'),
|
||||
{ type: OB11MessageDataType.json, data: { data: json } },
|
||||
text('after'),
|
||||
], groupUin);
|
||||
|
||||
expect(elems).toMatchObject([
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '/菜汪' } },
|
||||
{ lightAppElem: {} },
|
||||
{ text: { str: 'after' } },
|
||||
]);
|
||||
expect(inflateSync(elems[2]!.lightAppElem!.data.subarray(1)).toString()).toBe(json);
|
||||
});
|
||||
|
||||
it('does not insert compatibility text after legacy or small faces', async () => {
|
||||
const elems = await buildForwardElements([face('14'), text('legacy'), face('264'), text('small')], groupUin);
|
||||
|
||||
expect(elems).toMatchObject([
|
||||
{ face: { index: 14 } },
|
||||
{ text: { str: 'legacy' } },
|
||||
{ commonElem: { serviceType: 33 } },
|
||||
{ text: { str: 'small' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, ''])('provides non-empty compatibility text when faceText is %s', (faceText) => {
|
||||
const face = new PacketMsgFaceElement({
|
||||
elementType: ElementType.FACE,
|
||||
elementId: '',
|
||||
faceElement: {
|
||||
faceIndex: 317,
|
||||
faceType: FaceType.AniSticke,
|
||||
faceText,
|
||||
},
|
||||
});
|
||||
|
||||
expect(face.buildElement()).toMatchObject([
|
||||
{ commonElem: { serviceType: 37 } },
|
||||
{ text: { str: '[表情]' } },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { NetworkAdapterConfig } from '../napcat-onebot/config/config';
|
||||
|
||||
import GetFriendMsgHistory from '../napcat-onebot/action/go-cqhttp/GetFriendMsgHistory';
|
||||
|
||||
// `GetFriendMsgHistory` imports `napcat-core/types` via the bare package subpath,
|
||||
// which Node exports resolution cannot map in this workspace (`./*` pattern cannot
|
||||
// target an extensionless directory). Remap it onto the aliased source tree.
|
||||
vi.mock('napcat-core/types', async () => await vi.importActual('@/napcat-core/types/index'));
|
||||
vi.mock('napcat-common/src/message-unique', async () => await vi.importActual('@/napcat-common/src/message-unique'));
|
||||
|
||||
const SAMPLE_MSG = {
|
||||
msgId: '100',
|
||||
msgSeq: '1',
|
||||
chatType: 1,
|
||||
peerUid: 'friend-uid',
|
||||
peerUin: '42',
|
||||
senderUin: '42',
|
||||
msgTime: '1700000000',
|
||||
msgRandom: '1',
|
||||
elements: [],
|
||||
} as never;
|
||||
|
||||
const TEST_ADAPTER = 'test';
|
||||
const TEST_CONFIG = { messagePostFormat: 'array' } as NetworkAdapterConfig;
|
||||
|
||||
function createHistoryAction () {
|
||||
const parseMessage = vi.fn().mockResolvedValue({ message: [], raw_message: '' });
|
||||
const core = {
|
||||
selfInfo: { uid: 'self-uid', uin: '1' },
|
||||
apis: {
|
||||
UserApi: { getUidByUinV2: vi.fn().mockResolvedValue('friend-uid') },
|
||||
FriendApi: { isBuddy: vi.fn().mockResolvedValue(true) },
|
||||
MsgApi: {
|
||||
getAioFirstViewLatestMsgs: vi.fn().mockResolvedValue({ msgList: [SAMPLE_MSG] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const obContext = {
|
||||
apis: { MsgApi: { parseMessage } },
|
||||
};
|
||||
const action = new GetFriendMsgHistory(obContext as never, core as never);
|
||||
return { action, parseMessage };
|
||||
}
|
||||
|
||||
describe('get_friend_msg_history quick_reply forwarding', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('forwards quick_reply: true to parseMessage', async () => {
|
||||
const { action, parseMessage } = createHistoryAction();
|
||||
|
||||
await action._handle(
|
||||
{ user_id: '42', count: 20, reverse_order: false, disable_get_url: false, parse_mult_msg: true, quick_reply: true, reverseOrder: false },
|
||||
TEST_ADAPTER,
|
||||
TEST_CONFIG
|
||||
);
|
||||
|
||||
expect(parseMessage).toHaveBeenCalledWith(
|
||||
SAMPLE_MSG,
|
||||
'array',
|
||||
true,
|
||||
false,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('forwards quick_reply: false (default) to parseMessage', async () => {
|
||||
const { action, parseMessage } = createHistoryAction();
|
||||
|
||||
await action._handle(
|
||||
{ user_id: '42', count: 20, reverse_order: false, disable_get_url: false, parse_mult_msg: true, quick_reply: false, reverseOrder: false },
|
||||
TEST_ADAPTER,
|
||||
TEST_CONFIG
|
||||
);
|
||||
|
||||
expect(parseMessage).toHaveBeenCalledWith(
|
||||
SAMPLE_MSG,
|
||||
'array',
|
||||
true,
|
||||
false,
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { GetGroupNotice } from '../napcat-onebot/action/group/GetGroupNotice';
|
||||
|
||||
function feed (fid: string, settings?: Record<string, number>) {
|
||||
return {
|
||||
u: 10001,
|
||||
fid,
|
||||
pubt: 1750000000,
|
||||
msg: { text: `notice ${fid}`, text_face: '', title: '' },
|
||||
type: 1,
|
||||
fn: 0,
|
||||
cn: 0,
|
||||
vn: 0,
|
||||
settings,
|
||||
read_num: 3,
|
||||
is_read: 0,
|
||||
is_all_confirm: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createAction (ret: unknown) {
|
||||
return new GetGroupNotice(
|
||||
undefined as never,
|
||||
{ apis: { WebApi: { getGroupNotice: vi.fn().mockResolvedValue(ret) } } } as never
|
||||
);
|
||||
}
|
||||
|
||||
describe('_get_group_notice', () => {
|
||||
test('returns "send to new member" notices from inst after feeds', async () => {
|
||||
const action = createAction({
|
||||
ec: 0,
|
||||
feeds: [feed('feed-1'), feed('feed-2')],
|
||||
inst: [feed('inst-1', { inst_no_pinned: 0 })],
|
||||
});
|
||||
|
||||
const notices = await action._handle({ group_id: '123' });
|
||||
|
||||
expect(notices.map(n => n.notice_id)).toEqual(['feed-1', 'feed-2', 'inst-1']);
|
||||
expect(notices.map(n => n.send_to_new_member)).toEqual([false, false, true]);
|
||||
expect(notices[2]?.message.text).toBe('notice inst-1');
|
||||
});
|
||||
|
||||
test('maps inst_no_pinned to pinned only when the field is present', async () => {
|
||||
const action = createAction({
|
||||
ec: 0,
|
||||
feeds: [feed('feed-1', { tip_window_type: 1 })],
|
||||
inst: [feed('pinned', { inst_no_pinned: 0 }), feed('unpinned', { inst_no_pinned: 1 })],
|
||||
});
|
||||
|
||||
const notices = await action._handle({ group_id: '123' });
|
||||
|
||||
expect(notices.find(n => n.notice_id === 'feed-1')?.pinned).toBeUndefined();
|
||||
expect(notices.find(n => n.notice_id === 'pinned')?.pinned).toBe(true);
|
||||
expect(notices.find(n => n.notice_id === 'unpinned')?.pinned).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps working when inst is missing and skips duplicated notices', async () => {
|
||||
const withoutInst = await createAction({ ec: 0, feeds: [feed('feed-1')] })._handle({ group_id: '123' });
|
||||
expect(withoutInst.map(n => n.notice_id)).toEqual(['feed-1']);
|
||||
|
||||
const duplicated = await createAction({
|
||||
ec: 0,
|
||||
feeds: [feed('same')],
|
||||
inst: [feed('same', { inst_no_pinned: 0 })],
|
||||
})._handle({ group_id: '123' });
|
||||
expect(duplicated.map(n => n.notice_id)).toEqual(['same']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { migrateLegacyGlobalPath } from '@/napcat-shell/global-migrate';
|
||||
|
||||
const noopLogger = { log: () => {}, logError: () => {} };
|
||||
|
||||
function withTempDir (fn: (dir: string) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'napcat-migrate-test-'));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('Linux legacy global migration', () => {
|
||||
it('moves nt_qq/global to global when the new path is absent', () => {
|
||||
withTempDir((dir) => {
|
||||
const legacy = path.join(dir, 'nt_qq', 'global');
|
||||
fs.mkdirSync(path.join(legacy, 'nt_data', 'msf'), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'nt_data', 'msf', 'machine-info'), Buffer.from('legacy-creds'));
|
||||
const target = path.join(dir, 'global');
|
||||
|
||||
expect(migrateLegacyGlobalPath(dir, target, noopLogger, 'linux')).toBe(true);
|
||||
expect(fs.existsSync(legacy)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(target, 'nt_data', 'msf', 'machine-info')).toString())
|
||||
.toBe('legacy-creds');
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when the new global path already exists (keeps both intact)', () => {
|
||||
withTempDir((dir) => {
|
||||
const legacy = path.join(dir, 'nt_qq', 'global');
|
||||
const target = path.join(dir, 'global');
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'marker'), Buffer.from('legacy'));
|
||||
fs.writeFileSync(path.join(target, 'marker'), Buffer.from('new'));
|
||||
|
||||
expect(migrateLegacyGlobalPath(dir, target, noopLogger, 'linux')).toBe(false);
|
||||
expect(fs.readFileSync(path.join(legacy, 'marker')).toString()).toBe('legacy');
|
||||
expect(fs.readFileSync(path.join(target, 'marker')).toString()).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when neither path exists', () => {
|
||||
withTempDir((dir) => {
|
||||
expect(migrateLegacyGlobalPath(dir, path.join(dir, 'global'), noopLogger, 'linux')).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, 'global'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent across repeated startups', () => {
|
||||
withTempDir((dir) => {
|
||||
const legacy = path.join(dir, 'nt_qq', 'global');
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'login.db'), Buffer.from('creds'));
|
||||
const target = path.join(dir, 'global');
|
||||
|
||||
expect(migrateLegacyGlobalPath(dir, target, noopLogger, 'linux')).toBe(true);
|
||||
expect(migrateLegacyGlobalPath(dir, target, noopLogger, 'linux')).toBe(false);
|
||||
expect(fs.readFileSync(path.join(target, 'login.db')).toString()).toBe('creds');
|
||||
});
|
||||
});
|
||||
|
||||
it('never touches non-Linux platforms', () => {
|
||||
withTempDir((dir) => {
|
||||
const legacy = path.join(dir, 'nt_qq', 'global');
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
expect(migrateLegacyGlobalPath(dir, path.join(dir, 'global'), noopLogger, 'win32')).toBe(false);
|
||||
expect(fs.existsSync(legacy)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { NTQQWebApi } from '@/napcat-core/apis/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumMediaFeed } from '@/napcat-core/data/album';
|
||||
import type { NodeIKernelAlbumService } from '@/napcat-core/services/NodeIKernelAlbumService';
|
||||
import { DoGroupAlbumComment } from '@/napcat-onebot/action/extends/DoGroupAlbumComment';
|
||||
|
||||
describe('do_group_album_comment native arguments', () => {
|
||||
it('submits the documented payload with an empty like object required by QQNT', async () => {
|
||||
const result = { result: 0, comment: { id: 'comment-id' } };
|
||||
const doQunComment = vi.fn<NodeIKernelAlbumService['doQunComment']>().mockResolvedValue(result);
|
||||
const core = { selfInfo: { uin: '10001' }, apis: {} as { WebApi: NTQQWebApi } };
|
||||
core.apis.WebApi = new NTQQWebApi({
|
||||
session: { getAlbumService: () => ({ doQunComment }) },
|
||||
} as never, core as never);
|
||||
const action = new DoGroupAlbumComment(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({
|
||||
group_id: '12345',
|
||||
album_id: 'album-id',
|
||||
lloc: 'photo-id',
|
||||
content: '测试评论',
|
||||
})).resolves.toBe(result);
|
||||
|
||||
expect(doQunComment).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(Number),
|
||||
{ map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
'12345',
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cell_media: expect.objectContaining({
|
||||
album_id: 'album-id',
|
||||
media_items: [{ image: { lloc: 'photo-id' } }],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
client_key: expect.any(Number),
|
||||
content: [expect.objectContaining({ type: 0, content: '测试评论' })],
|
||||
user: { uin: '10001' },
|
||||
like: { id: '', count: 0, status: 0 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Optional native regression: point this at a QQ installation's wrapper.node.
|
||||
// An unbound service parses arguments but cannot log in or submit a comment.
|
||||
it.skipIf(!process.env['NAPCAT_TEST_QQ_WRAPPER'])('passes native argument conversion (issue #1979)', () => {
|
||||
const wrapperPath = resolve(process.env['NAPCAT_TEST_QQ_WRAPPER']!);
|
||||
const args = [
|
||||
1234,
|
||||
{ map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
'12345',
|
||||
2,
|
||||
createAlbumMediaFeed('10001', 'album-id', 'photo-id'),
|
||||
createAlbumCommentRequest('10001', '测试评论', 1234567890),
|
||||
];
|
||||
const probe = spawnSync(process.execPath, ['-e', `
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const { wrapperPath, args } = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const native = { exports: {} };
|
||||
process.dlopen(native, wrapperPath);
|
||||
const service = new native.exports.NodeIKernelAlbumService();
|
||||
const legacyArgs = args.slice();
|
||||
legacyArgs[5] = { ...args[5] };
|
||||
delete legacyArgs[5].like;
|
||||
assert.throws(() => service.doQunComment(...legacyArgs), /Cannot convert undefined or null to object/);
|
||||
// Reaching the unbound-service check proves all six arguments were converted.
|
||||
assert.throws(() => service.doQunComment(...args), /implementation of IKernelAlbumService is not valid/);
|
||||
process.exit(0);
|
||||
`], {
|
||||
cwd: dirname(wrapperPath),
|
||||
input: JSON.stringify({ wrapperPath, args }),
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
expect(probe.error).toBeUndefined();
|
||||
expect(probe.status, probe.stderr || probe.stdout).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { GetGroupFilesByFolder } from '../napcat-onebot/action/go-cqhttp/GetGroupFilesByFolder';
|
||||
|
||||
const FOLDER_ITEM = {
|
||||
peerId: '123',
|
||||
folderInfo: {
|
||||
folderId: 'sub_folder_1',
|
||||
folderName: '子目录',
|
||||
createTime: 1,
|
||||
createUin: '9',
|
||||
creatorName: '创建者',
|
||||
totalFileCount: 2,
|
||||
modifyTime: 2,
|
||||
modifyUin: '9',
|
||||
modifyName: '修改者',
|
||||
},
|
||||
};
|
||||
|
||||
function createAction (items: unknown[]) {
|
||||
const getGroupFileList = vi.fn().mockResolvedValue(items);
|
||||
const action = new GetGroupFilesByFolder(
|
||||
undefined as never,
|
||||
{
|
||||
apis: {
|
||||
MsgApi: { getGroupFileList },
|
||||
},
|
||||
} as never
|
||||
);
|
||||
return { action, getGroupFileList };
|
||||
}
|
||||
|
||||
describe('get_group_files_by_folder subfolders', () => {
|
||||
test('maps folderInfo entries into folders', async () => {
|
||||
const { action } = createAction([FOLDER_ITEM]);
|
||||
|
||||
const res = await action._handle({ group_id: '123', file_count: 50 });
|
||||
|
||||
expect(res.folders).toHaveLength(1);
|
||||
expect(res.folders[0]).toMatchObject({
|
||||
group_id: 123,
|
||||
folder_id: 'sub_folder_1',
|
||||
folder_name: '子目录',
|
||||
total_file_count: 2,
|
||||
});
|
||||
expect(res.files).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty arrays when the folder holds nothing', async () => {
|
||||
const { action } = createAction([]);
|
||||
|
||||
const res = await action._handle({ group_id: '123', file_count: 50 });
|
||||
|
||||
expect(res.files).toHaveLength(0);
|
||||
expect(res.folders).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('passes the requested folder id through to the core API', async () => {
|
||||
const { action, getGroupFileList } = createAction([]);
|
||||
|
||||
await action._handle({ group_id: '123', file_count: 50, folder_id: 'parent_1' });
|
||||
|
||||
expect(getGroupFileList).toHaveBeenCalledWith('123', expect.objectContaining({ folderId: 'parent_1' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { OB11Construct } from '../napcat-onebot/helper/data';
|
||||
|
||||
describe('group file folder construction', () => {
|
||||
test('exposes the last modification time and modifier of a folder', () => {
|
||||
const folder = OB11Construct.folder('123', {
|
||||
folderId: '/folder-1',
|
||||
parentFolderId: '/',
|
||||
folderName: '资料',
|
||||
createTime: 1700000000,
|
||||
modifyTime: 1750000000,
|
||||
createUin: '10001',
|
||||
creatorName: 'creator',
|
||||
totalFileCount: 2,
|
||||
modifyUin: '10002',
|
||||
modifyName: 'uploader',
|
||||
usedSpace: '0',
|
||||
});
|
||||
|
||||
expect(folder).toMatchObject({
|
||||
group_id: 123,
|
||||
folder_id: '/folder-1',
|
||||
creator: 10001,
|
||||
modify_time: 1750000000,
|
||||
modifier: 10002,
|
||||
modifier_name: 'uploader',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { ChatType, type GroupMember, type RawMessage } from 'napcat-core';
|
||||
import { NTQQGroupApi } from '../napcat-core/apis/group';
|
||||
import { NTQQUserApi } from '../napcat-core/apis/user';
|
||||
import { OneBotMsgApi } from '../napcat-onebot/api/msg';
|
||||
|
||||
vi.mock('../napcat-webui-backend/index', () => ({
|
||||
pendingTokenToSend: undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../napcat-webui-backend/src/helper/Data', () => ({
|
||||
WebUiDataRuntime: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network', () => ({
|
||||
OB11HttpClientAdapter: class {},
|
||||
OB11WebSocketClientAdapter: class {},
|
||||
OB11NetworkManager: class {},
|
||||
OB11NetworkReloadType: {},
|
||||
OB11HttpServerAdapter: class {},
|
||||
OB11WebSocketServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/api', () => ({
|
||||
OneBotFriendApi: class {},
|
||||
OneBotGroupApi: class {},
|
||||
OneBotMsgApi: class {},
|
||||
OneBotQuickActionApi: class {},
|
||||
OneBotUserApi: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/action', () => ({
|
||||
createActionMap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network/http-server-sse', () => ({
|
||||
OB11HttpSSEServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network/plugin-manger', () => ({
|
||||
OB11PluginMangerAdapter: class {},
|
||||
}));
|
||||
|
||||
// msg.ts 按目录导入 proto,vitest 解析不了;这里用不到,给个空模块
|
||||
vi.mock('napcat-core/packet/transformer/proto', () => ({}));
|
||||
|
||||
function member (uid: string, uin: string, nick: string) {
|
||||
return { uid, uin, nick, cardName: '', role: 2 } as unknown as GroupMember;
|
||||
}
|
||||
|
||||
function memberList (...members: GroupMember[]) {
|
||||
return {
|
||||
errCode: 0,
|
||||
errMsg: '',
|
||||
result: { ids: [], infos: new Map(members.map(m => [m.uid, m])), finish: true, hasRobot: false },
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupApi (getAllMemberList: ReturnType<typeof vi.fn>) {
|
||||
const context = {
|
||||
session: { getGroupService: () => ({ getAllMemberList }) },
|
||||
logger: { logError: vi.fn() },
|
||||
};
|
||||
return new NTQQGroupApi(context as never, {} as never);
|
||||
}
|
||||
|
||||
describe('group member lookup', () => {
|
||||
test('returns undefined instead of throwing when the member list of a new group cannot be fetched', async () => {
|
||||
const getAllMemberList = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValueOnce({ errCode: 1, errMsg: 'not ready', result: undefined });
|
||||
const groupApi = createGroupApi(getAllMemberList);
|
||||
|
||||
await expect(groupApi.getGroupMember('1053882091', '2060875951')).resolves.toBeUndefined();
|
||||
await expect(groupApi.getGroupMember('1053882091', 'u_member')).resolves.toBeUndefined();
|
||||
// 拉不到的结果不能写进缓存,否则之后一直拿到空成员表
|
||||
expect(groupApi.groupMemberCache.has('1053882091')).toBe(false);
|
||||
// 每次调用只拉一次,不会为同一条消息重复强制刷新
|
||||
expect(getAllMemberList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('refreshes a cached group once when a new member is not in the cache yet', async () => {
|
||||
const oldMember = member('u_old', '10001', 'old');
|
||||
const newMember = member('u_new', '10002', 'new');
|
||||
const getAllMemberList = vi.fn().mockResolvedValue(memberList(oldMember, newMember));
|
||||
const groupApi = createGroupApi(getAllMemberList);
|
||||
groupApi.groupMemberCache.set('123', new Map([[oldMember.uid, oldMember]]));
|
||||
|
||||
await expect(groupApi.getGroupMember('123', '10002')).resolves.toBe(newMember);
|
||||
await expect(groupApi.getGroupMember('123', 'u_new')).resolves.toBe(newMember);
|
||||
expect(getAllMemberList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('uin to uid falls back to the cached group members before the network lookup', async () => {
|
||||
const groupApi = createGroupApi(vi.fn());
|
||||
groupApi.groupMemberCache.set('123', new Map([['u_new', member('u_new', '3680714873', 'new')]]));
|
||||
const callNoListenerEvent = vi.fn();
|
||||
const context = {
|
||||
session: {
|
||||
getUixConvertService: () => ({ getUid: vi.fn().mockResolvedValue({ uidInfo: new Map() }) }),
|
||||
getProfileService: () => ({ getUidByUin: vi.fn().mockReturnValue(new Map()) }),
|
||||
getGroupService: () => ({ getUidByUins: vi.fn().mockResolvedValue({ uids: new Map() }) }),
|
||||
},
|
||||
};
|
||||
const userApi = new NTQQUserApi(context as never, {
|
||||
apis: { GroupApi: groupApi },
|
||||
eventWrapper: { callNoListenerEvent },
|
||||
} as never);
|
||||
|
||||
await expect(userApi.getUidByUinV2('3680714873')).resolves.toBe('u_new');
|
||||
expect(callNoListenerEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('temp messages look up the sender in the source group instead of using the sender QQ as group', async () => {
|
||||
const getGroupMember = vi.fn().mockResolvedValue(member('u_temp', '3680714873', 'temp sender'));
|
||||
const msgApi = new OneBotMsgApi(undefined as never, {
|
||||
apis: {
|
||||
MsgApi: { getTempChatInfo: vi.fn().mockResolvedValue({ result: 0, tmpChatInfo: { groupCode: '603036233' } }) },
|
||||
GroupApi: { getGroupMember },
|
||||
},
|
||||
} as never);
|
||||
const resMsg = { sender: {} } as Record<string, unknown> & { sender: Record<string, unknown>; };
|
||||
const msg = {
|
||||
chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP,
|
||||
senderUid: 'u_temp',
|
||||
senderUin: '3680714873',
|
||||
peerUid: 'u_temp',
|
||||
peerUin: '3680714873',
|
||||
} as RawMessage;
|
||||
|
||||
await (msgApi as unknown as { handleTempGroupMessage (r: unknown, m: RawMessage): Promise<void>; })
|
||||
.handleTempGroupMessage(resMsg, msg);
|
||||
|
||||
expect(getGroupMember).toHaveBeenCalledWith('603036233', 'u_temp');
|
||||
expect(resMsg).toMatchObject({ sub_type: 'group', group_id: 603036233, sender: { nickname: 'temp sender' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { GeneralCallResultStatus } from '../napcat-core/services/common';
|
||||
import {
|
||||
applyGroupManagementSettings,
|
||||
createGroupDetailInfoV2Param,
|
||||
createGroupManagementRequests,
|
||||
} from '../napcat-core/data/group';
|
||||
import { assertGroupManagementResults } from '../napcat-onebot/action/group/GroupOperationHelpers';
|
||||
|
||||
describe('NTQQ group operations', () => {
|
||||
test('reports the exact native setting that failed', () => {
|
||||
expect(() => assertGroupManagementResults('设置群成员功能权限', [{
|
||||
setting: 'allow_member_create_group',
|
||||
// 枚举里只有 OK/ERROR,这里刻意用一个任意的原生错误码
|
||||
result: { result: 1006 as GeneralCallResultStatus, errMsg: '未知错误' },
|
||||
}])).toThrow(/allow_member_create_group.*1006.*未知错误/);
|
||||
expect(() => assertGroupManagementResults('设置群成员功能权限', []))
|
||||
.not.toThrow();
|
||||
});
|
||||
|
||||
test('builds the NTQQ permission masks without changing unrelated flags', async () => {
|
||||
const request = createGroupDetailInfoV2Param('123456');
|
||||
expect(applyGroupManagementSettings(request, {
|
||||
memberInvite: 'no_approval',
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: false,
|
||||
newMembersSeeRecentHistory: true,
|
||||
})).toBe(true);
|
||||
expect(request.groupCode).toBe('123456');
|
||||
expect(request.filter).toMatchObject({
|
||||
allowMemberInvite: 1,
|
||||
appPrivilegeFlag: 1,
|
||||
appPrivilegeMask: 1,
|
||||
groupFlagExt4: 1,
|
||||
groupFlagExt4Mask: 1,
|
||||
});
|
||||
expect(request.modifyInfo).toMatchObject({
|
||||
allowMemberInvite: 1,
|
||||
appPrivilegeFlag: 0x00108001,
|
||||
appPrivilegeMask: 0x06118001,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupFlagExt4: 0x4,
|
||||
groupFlagExt4Mask: 0x4,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps every member invite policy to the native app privilege value', async () => {
|
||||
const expected = {
|
||||
disabled: 0x04000000,
|
||||
require_approval: 0,
|
||||
no_approval: 0x00100000,
|
||||
no_approval_under_100: 0x02000000,
|
||||
} as const;
|
||||
|
||||
for (const [memberInvite, privilegeFlag] of Object.entries(expected)) {
|
||||
const request = createGroupDetailInfoV2Param('123456');
|
||||
applyGroupManagementSettings(request, { memberInvite: memberInvite as keyof typeof expected });
|
||||
expect(request.modifyInfo.allowMemberInvite).toBe(memberInvite === 'disabled' ? 0 : 1);
|
||||
expect(request.modifyInfo.appPrivilegeFlag).toBe(privilegeFlag);
|
||||
expect(request.modifyInfo.appPrivilegeMask).toBe(0x06100000);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses NTQQ privilege operation type 8 for album, temporary-session, and create-group flags', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
memberInvite: 'require_approval',
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
}, 0x18001);
|
||||
|
||||
expect(requests.map(request => request.operationType)).toEqual([0, 8, 8, 8]);
|
||||
expect(requests.map(request => request.setting)).toEqual([
|
||||
'member_invite',
|
||||
'allow_member_upload_album',
|
||||
'allow_member_temporary_session',
|
||||
'allow_member_create_group',
|
||||
]);
|
||||
expect(requests.slice(1).map(request => request.param.modifyInfo.appPrivilegeMask))
|
||||
.toEqual([0x1, 0x10000, 0x8000]);
|
||||
expect(requests[1]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0x18000,
|
||||
appPrivilegeMask: 0x1,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
expect(requests[2]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0x8000,
|
||||
appPrivilegeMask: 0x10000,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
expect(requests[3]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0x8000,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves the complete privilege value when clearing Linux NTQQ permission bits', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: false,
|
||||
allowMemberCreateGroup: false,
|
||||
}, 0);
|
||||
|
||||
expect(requests.map(request => request.param.modifyInfo.appPrivilegeFlag))
|
||||
.toEqual([0x1, 0x10001, 0x18001]);
|
||||
expect(requests.map(request => request.param.modifyInfo.appPrivilegeMask))
|
||||
.toEqual([0x1, 0x10000, 0x8000]);
|
||||
});
|
||||
|
||||
test('skips privilege requests whose effective value is already current', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
newMembersSeeRecentHistory: true,
|
||||
}, 0x00100000, 0x4);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbSvcTrpcTcpBase } from '@/napcat-core/packet/transformer/proto/oidb/OidbBase';
|
||||
import { OidbSvcTrpcTcp0XF90 } from '@/napcat-core/packet/transformer/proto/oidb/Oidb.0xf90';
|
||||
import SetGroupTodo from '@/napcat-core/packet/transformer/action/SetGroupTodo';
|
||||
import CompleteGroupTodo from '@/napcat-core/packet/transformer/action/CompleteGroupTodo';
|
||||
import CancelGroupTodo from '@/napcat-core/packet/transformer/action/CancelGroupTodo';
|
||||
|
||||
vi.mock('@/napcat-core/packet/transformer/base', () => ({
|
||||
PacketTransformer: class PacketTransformer {
|
||||
protected msgBuilder: unknown;
|
||||
|
||||
constructor () {
|
||||
this.msgBuilder = undefined;
|
||||
}
|
||||
},
|
||||
PacketBufBuilder: (str: Uint8Array) => Buffer.from(str),
|
||||
}));
|
||||
|
||||
const decodePacket = (data: Buffer) => {
|
||||
const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(data);
|
||||
const body = new NapProtoMsg(OidbSvcTrpcTcp0XF90).decode(base.body);
|
||||
return { base, body };
|
||||
};
|
||||
|
||||
describe('Group todo packet builder', () => {
|
||||
test('builds OIDB 0xF90 subCommand 1 for set', () => {
|
||||
const packet = SetGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_1');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(1);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
|
||||
test('builds OIDB 0xF90 subCommand 2 for complete', () => {
|
||||
const packet = CompleteGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_2');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(2);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
|
||||
test('builds OIDB 0xF90 subCommand 3 for cancel', () => {
|
||||
const packet = CancelGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_3');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(3);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
detectImageTypeFromBuffer,
|
||||
imageSizeFromBuffer,
|
||||
imageSizeFromBufferFallBack,
|
||||
imageSizeFromFile,
|
||||
matchMagic,
|
||||
ImageType,
|
||||
} from '@/napcat-image-size/src';
|
||||
|
||||
// resource 目录路径
|
||||
const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource');
|
||||
|
||||
// 测试用的 Buffer 数据
|
||||
const testBuffers = {
|
||||
// PNG 测试图片 (100x200)
|
||||
png: Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// JPEG 测试图片 (320x240)
|
||||
jpeg: Buffer.from([
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0xFF, 0xC0, 0x00, 0x0B, 0x08,
|
||||
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
|
||||
]),
|
||||
|
||||
// BMP 测试图片 (640x480)
|
||||
bmp: (() => {
|
||||
const buf = Buffer.alloc(54);
|
||||
buf.write('BM', 0);
|
||||
buf.writeUInt32LE(54, 2);
|
||||
buf.writeUInt32LE(0, 6);
|
||||
buf.writeUInt32LE(54, 10);
|
||||
buf.writeUInt32LE(40, 14);
|
||||
buf.writeUInt32LE(640, 18);
|
||||
buf.writeUInt32LE(480, 22);
|
||||
buf.writeUInt16LE(1, 26);
|
||||
buf.writeUInt16LE(24, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// GIF87a 测试图片 (800x600)
|
||||
gif87a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
|
||||
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// GIF89a 测试图片 (1024x768)
|
||||
gif89a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// WebP VP8 测试图片 (1920x1080)
|
||||
webpVP8: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8 ', 12);
|
||||
buf.writeUInt32LE(14, 16);
|
||||
buf.writeUInt8(0x9D, 20);
|
||||
buf.writeUInt8(0x01, 21);
|
||||
buf.writeUInt8(0x2A, 22);
|
||||
buf.writeUInt16LE(1920 & 0x3FFF, 26);
|
||||
buf.writeUInt16LE(1080 & 0x3FFF, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8L 测试图片 (256x128)
|
||||
webpVP8L: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8L', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8(0x2F, 20);
|
||||
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
|
||||
buf.writeUInt32LE(vp8lBits, 21);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8X 测试图片 (512x384)
|
||||
webpVP8X: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8X', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8((512 - 1) & 0xFF, 24);
|
||||
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
|
||||
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
|
||||
buf.writeUInt8((384 - 1) & 0xFF, 27);
|
||||
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
|
||||
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// TIFF Little Endian 测试图片
|
||||
tiffLE: Buffer.from([
|
||||
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
|
||||
0x02, 0x00, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// TIFF Big Endian 测试图片
|
||||
tiffBE: Buffer.from([
|
||||
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
|
||||
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
|
||||
0x00, 0x02, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
invalid: Buffer.from('This is not an image file'),
|
||||
empty: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
describe('napcat-image-size', () => {
|
||||
describe('matchMagic', () => {
|
||||
it('should match magic bytes at the beginning', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
|
||||
});
|
||||
|
||||
it('should match magic bytes at offset', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-matching magic', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for buffer too short', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for offset beyond buffer', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectImageTypeFromBuffer', () => {
|
||||
it('should detect PNG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
|
||||
});
|
||||
|
||||
it('should detect JPEG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
|
||||
});
|
||||
|
||||
it('should detect BMP image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
|
||||
});
|
||||
|
||||
it('should detect GIF87a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect GIF89a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8 image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8L image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8X image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect TIFF Little Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should detect TIFF Big Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for invalid data', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for empty buffer', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBuffer', () => {
|
||||
it('should parse PNG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse JPEG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
|
||||
});
|
||||
|
||||
it('should parse BMP image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('should parse GIF87a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('should parse GIF89a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8 image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8L image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8X image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Little Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Big Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return undefined for invalid data', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty buffer', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBufferFallBack', () => {
|
||||
it('should return actual size for valid image', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return default fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
|
||||
});
|
||||
|
||||
it('should return default fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageType enum', () => {
|
||||
it('should have correct enum values', () => {
|
||||
expect(ImageType.JPEG).toBe('jpeg');
|
||||
expect(ImageType.PNG).toBe('png');
|
||||
expect(ImageType.BMP).toBe('bmp');
|
||||
expect(ImageType.GIF).toBe('gif');
|
||||
expect(ImageType.WEBP).toBe('webp');
|
||||
expect(ImageType.TIFF).toBe('tiff');
|
||||
expect(ImageType.UNKNOWN).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real image files from resource directory', () => {
|
||||
it('should detect and parse test-20x20.jpg', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.jpg');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.png', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.png');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.tiff', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.tiff');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.webp', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.webp');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-490x498.gif', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-490x498.gif');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
|
||||
it('should parse real images using imageSizeFromFile', async () => {
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { loadQQWrapper } from '../napcat-core/index';
|
||||
|
||||
describe('loadQQWrapper', () => {
|
||||
const originalWrapperPath = process.env['NAPCAT_WRAPPER_PATH'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['NAPCAT_WRAPPER_PATH'] = 'E:\\NapCat\\wrapper.node';
|
||||
vi.spyOn(process, 'dlopen').mockImplementation(() => {
|
||||
throw new Error('The specified module could not be found.\r\n\\\\?\\E:\\NapCat\\wrapper.node');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env['NAPCAT_WRAPPER_PATH'];
|
||||
} else {
|
||||
process.env['NAPCAT_WRAPPER_PATH'] = originalWrapperPath;
|
||||
}
|
||||
});
|
||||
|
||||
test('explains the missing Media Foundation feature on Windows Server', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
expect(() => loadQQWrapper(undefined, '9.9.33-52230'))
|
||||
.toThrow(/媒体基础.*Install-WindowsFeature Server-Media-Foundation.*The specified module could not be found/s);
|
||||
});
|
||||
|
||||
test('keeps the original error when Media Foundation is installed', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
expect(() => loadQQWrapper(undefined, '9.9.33-52230')).toThrow(/^The specified module could not be found/);
|
||||
});
|
||||
|
||||
test('keeps the original error on other platforms', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
expect(() => loadQQWrapper(undefined, '3.2.32-52194')).toThrow(/^The specified module could not be found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const fixtureRoots: string[] = [];
|
||||
|
||||
function createFixture () {
|
||||
const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'napcat-loader-'));
|
||||
fixtureRoots.push(fixtureRoot);
|
||||
const installDir = path.join(fixtureRoot, '中文 space # percent%');
|
||||
mkdirSync(installDir);
|
||||
return { fixtureRoot, installDir };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixtureRoot of fixtureRoots.splice(0)) {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('loader paths', () => {
|
||||
test('Shell loads the flat package from an unrelated working directory', () => {
|
||||
const { fixtureRoot, installDir } = createFixture();
|
||||
const loaderPath = path.join(installDir, 'loadNapCat.cjs');
|
||||
copyFileSync(path.join(packageRoot, 'napcat-shell-loader/loadNapCat.js'), loaderPath);
|
||||
writeFileSync(path.join(installDir, 'napcat.mjs'), 'console.log("SHELL_LOADED");');
|
||||
const environment = { ...process.env };
|
||||
delete environment['NAPCAT_MAIN_PATH'];
|
||||
const output = execFileSync(process.execPath, [loaderPath], { cwd: fixtureRoot, env: environment, encoding: 'utf8' });
|
||||
expect(output).toContain('SHELL_LOADED');
|
||||
});
|
||||
|
||||
test('Shell uses the actual entry path when the loader is redirected into QQ', () => {
|
||||
const { fixtureRoot, installDir } = createFixture();
|
||||
const loaderPath = path.join(fixtureRoot, 'loadNapCat.cjs');
|
||||
const entryPath = path.join(installDir, 'napcat.mjs');
|
||||
copyFileSync(path.join(packageRoot, 'napcat-shell-loader/loadNapCat.js'), loaderPath);
|
||||
writeFileSync(entryPath, 'console.log("REDIRECTED_SHELL_LOADED");');
|
||||
const output = execFileSync(process.execPath, [loaderPath], {
|
||||
cwd: fixtureRoot,
|
||||
env: { ...process.env, NAPCAT_MAIN_PATH: entryPath },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(output).toContain('REDIRECTED_SHELL_LOADED');
|
||||
});
|
||||
|
||||
test('Framework native loader preserves URL-sensitive path characters', () => {
|
||||
const { fixtureRoot, installDir } = createFixture();
|
||||
const loaderPath = path.join(installDir, 'nativeLoader.cjs');
|
||||
copyFileSync(path.join(packageRoot, 'napcat-framework/nativeLoader.cjs'), loaderPath);
|
||||
writeFileSync(path.join(installDir, 'napcat.mjs'), 'export async function NCoreInitFramework () { console.log("FRAMEWORK_LOADED"); }');
|
||||
const output = execFileSync(process.execPath, ['-e', 'require(process.argv[1]).initializeNapCat({}, {}, () => {});', loaderPath], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(output).toContain('FRAMEWORK_LOADED');
|
||||
expect(output).not.toContain('[Error]');
|
||||
});
|
||||
|
||||
test('Framework dlopen loader preserves URL-sensitive path characters', () => {
|
||||
const { fixtureRoot, installDir } = createFixture();
|
||||
const loaderPath = path.join(installDir, 'napcat.cjs');
|
||||
copyFileSync(path.join(packageRoot, 'napcat-framework/napcat.cjs'), loaderPath);
|
||||
writeFileSync(path.join(installDir, 'napcat.mjs'), 'export async function NCoreInitFramework () { console.log("DLOPEN_FRAMEWORK_LOADED"); } export function getWebUiUrl () { return ""; }');
|
||||
const script = 'process.dlopen = (module) => { module.exports = { NodeIKernelLoginService: { get: () => ({}) }, NodeIQQNTWrapperSession: { create: () => ({}) } }; }; require(process.argv[1]); process.dlopen({ exports: {} }, "wrapper.node");';
|
||||
const output = execFileSync(process.execPath, ['-e', script, loaderPath], { cwd: fixtureRoot, encoding: 'utf8' });
|
||||
expect(output).toContain('DLOPEN_FRAMEWORK_LOADED');
|
||||
expect(output).not.toContain('[Error]');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('Node batch uses the bundled runtime, forwards arguments and returns the exit code', () => {
|
||||
const { fixtureRoot, installDir } = createFixture();
|
||||
copyFileSync(process.execPath, path.join(installDir, 'node.exe'));
|
||||
copyFileSync(path.join(packageRoot, 'napcat-develop/napcat.bat'), path.join(installDir, 'napcat.bat'));
|
||||
writeFileSync(path.join(installDir, 'index.js'), 'console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) })); process.exitCode = 7;');
|
||||
const result = spawnSync('cmd.exe', ['/d', '/s', '/c', `""${path.join(installDir, 'napcat.bat')}" -q 123456"`], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: 'utf8',
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(7);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ cwd: installDir, args: ['-q', '123456'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
teaEncryptBlock,
|
||||
teaDecryptBlock,
|
||||
containerEncrypt,
|
||||
containerDecrypt,
|
||||
MacMachineInfoUtils,
|
||||
MachineInfoUtils,
|
||||
Registry20Utils,
|
||||
resolveBackupPath,
|
||||
} from '@/napcat-webui-backend/src/utils/guid';
|
||||
|
||||
// 参考实现: qq_guid_tool/macos (逆向自 macOS wrapper.node)
|
||||
// 所有期望值均由该 Python 实现生成, 用于确保 TS 复刻逐字节一致。
|
||||
|
||||
const KEY = Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex');
|
||||
|
||||
function prefixRng (n: number): Buffer {
|
||||
return Buffer.from(Array.from({ length: n }, (_, i) => i));
|
||||
}
|
||||
|
||||
function teaEncryptBlockG (block: Buffer, key = KEY): Buffer {
|
||||
return teaEncryptBlock(block, key);
|
||||
}
|
||||
|
||||
function teaDecryptBlockG (block: Buffer, key = KEY): Buffer {
|
||||
return teaDecryptBlock(block, key);
|
||||
}
|
||||
|
||||
describe('macOS TEA block', () => {
|
||||
it('encrypts blocks identical to the python reference', () => {
|
||||
expect(teaEncryptBlockG(Buffer.from('0001020304050607', 'hex')).toString('hex'))
|
||||
.toBe('6328a50896bde3c8');
|
||||
expect(teaEncryptBlockG(Buffer.from('ffffffffffffffff', 'hex')).toString('hex'))
|
||||
.toBe('c79e81a2904419de');
|
||||
expect(teaEncryptBlockG(Buffer.from('0011223344556677', 'hex')).toString('hex'))
|
||||
.toBe('205f515574f0821c');
|
||||
});
|
||||
|
||||
it('decrypts the reference ciphertext', () => {
|
||||
expect(teaDecryptBlockG(Buffer.from('6328a50896bde3c8', 'hex')).toString('hex'))
|
||||
.toBe('0001020304050607');
|
||||
});
|
||||
|
||||
it('round-trips blocks', () => {
|
||||
const block = Buffer.from('a1b2c3d4e5f60718', 'hex');
|
||||
expect(teaDecryptBlockG(teaEncryptBlockG(block)).equals(block)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('macOS TEA container', () => {
|
||||
it('matches the reference for "hello world" with a deterministic rng', () => {
|
||||
const blob = containerEncrypt(Buffer.from('hello world'), KEY, prefixRng);
|
||||
expect(blob.toString('hex'))
|
||||
.toBe('5e56df427ca3a4f56dfc8cbbd3438a437050558841226bb4');
|
||||
expect(containerDecrypt(blob, KEY)!.toString('hex')).toBe('68656c6c6f20776f726c64');
|
||||
});
|
||||
|
||||
it('matches the reference for an empty payload', () => {
|
||||
const blob = containerEncrypt(Buffer.alloc(0), KEY, prefixRng);
|
||||
expect(blob.toString('hex')).toBe('b00e46ba3e788626272ec631c28ab195');
|
||||
expect(containerDecrypt(blob, KEY)!.length).toBe(0);
|
||||
});
|
||||
|
||||
it('matches the reference for a 200 byte payload', () => {
|
||||
const payload = Buffer.from(Array.from({ length: 200 }, (_, i) => i));
|
||||
const blob = containerEncrypt(payload, KEY, prefixRng);
|
||||
const expected = 'b00e46ba3e788626bea151d61ae65f4ee433279c69c420d5e86f2e9d9844f335' +
|
||||
'fc3387a05609b7b3b294a99f8d5eb630e205a6b2d2778c01c2c860fc9b5cbcdaf07aacdece0b3a3' +
|
||||
'760245fae521f96422044562bc913ce1ac90456d810a981430ed54f7cec55207b85d6b5d991b1aa0f' +
|
||||
'4b033f9f8d2d86a06577c621d197a15c4c416efa3860f47b25bf71d8cfb6a08a6b3b158e1df47f950' +
|
||||
'911ab355be47101f37f989b0fe0c8afc432891f761aeadc29a78eabfc8a0bbef190028f85357cbf' +
|
||||
'489bb0c4a7353967dddb0254fa85f82122d4a003637bdc35';
|
||||
expect(blob.toString('hex')).toBe(expected);
|
||||
expect(containerDecrypt(blob, KEY)!.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong key', () => {
|
||||
const blob = containerEncrypt(Buffer.from('hello world'), KEY, prefixRng);
|
||||
expect(containerDecrypt(blob, Buffer.alloc(16))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('macOS machineid-info', () => {
|
||||
// 真实样本: 取自 macOS 14.8.9 (x86_64 VM, IOPlatformUUID 全零),
|
||||
// 源文件 ~/Library/Containers/com.tencent.qq/Data/.../global/nt_data/msf/machineid-info
|
||||
const REAL_CIPHER = '062c59f4bd7972c3baeab0dd857d9482d57ecafe96b7ec4d566e416871d7e829' +
|
||||
'0d117016cd94f644ea968bc641bae40d';
|
||||
const REAL_PLAIN = '00000008525400c91827000000000014514d303030323120202020202020202020202020';
|
||||
const REAL_KEY_UUID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
it('decrypts a real QQ-generated machineid-info identically to the python reference', () => {
|
||||
const key = MacMachineInfoUtils.keyFromUuid(REAL_KEY_UUID);
|
||||
const plain = containerDecrypt(Buffer.from(REAL_CIPHER, 'hex'), key);
|
||||
expect(plain).not.toBeNull();
|
||||
expect(plain!.toString('hex')).toBe(REAL_PLAIN);
|
||||
const parsed = MacMachineInfoUtils.parseMachineIdInfo(plain!);
|
||||
expect(parsed.machineId.toString('hex')).toBe('525400c918270000');
|
||||
expect(parsed.sn).toBe('QM00021 ');
|
||||
expect(MacMachineInfoUtils.computeGuid(parsed.machineId, parsed.sn).toString('hex'))
|
||||
.toBe('110cac8ab81e1b7dca6e3f48b1127d92');
|
||||
});
|
||||
|
||||
it('re-encrypts the real payload to a byte-identical container', () => {
|
||||
// 期望容器由参考 python 实现以相同 rng 生成
|
||||
const key = MacMachineInfoUtils.keyFromUuid(REAL_KEY_UUID);
|
||||
const plain = MacMachineInfoUtils.buildMachineIdInfo(
|
||||
Buffer.from('525400c918270000', 'hex'), 'QM00021 ');
|
||||
expect(plain.toString('hex')).toBe(REAL_PLAIN);
|
||||
const blob = containerEncrypt(plain, key, prefixRng);
|
||||
expect(blob.toString('hex'))
|
||||
.toBe('b73dfc91350349448b9ca9a255de7bffd75a548719ab4fa6311a439386fed1c29fbf7a40d0b77b1c7699d27d11545ca7');
|
||||
});
|
||||
|
||||
it('builds and parses fields like the reference', () => {
|
||||
const plain = MacMachineInfoUtils.buildMachineIdInfo(
|
||||
Buffer.from('aabbccddeeff0011', 'hex'), 'C02TEST');
|
||||
expect(plain.toString('hex')).toBe('00000008aabbccddeeff00110000000743303254455354');
|
||||
const parsed = MacMachineInfoUtils.parseMachineIdInfo(plain);
|
||||
expect(parsed.machineId.toString('hex')).toBe('aabbccddeeff0011');
|
||||
expect(parsed.sn).toBe('C02TEST');
|
||||
});
|
||||
|
||||
it('computes the reference guid', () => {
|
||||
const guid = MacMachineInfoUtils.computeGuid(Buffer.from('aabbccddeeff0011', 'hex'), 'C02TEST');
|
||||
expect(guid.toString('hex')).toBe('99902c9e4658f3495eac9932da8de575');
|
||||
});
|
||||
|
||||
it('computes the MAC fallback guid', () => {
|
||||
const guid = MacMachineInfoUtils.computeGuid(
|
||||
Buffer.concat([Buffer.from('aabbccddeeff', 'hex'), Buffer.from([0, 0])]), 'C02TEST');
|
||||
expect(guid.toString('hex')).toBe('90daceec0ac283834cd1dba777e9d54a');
|
||||
});
|
||||
|
||||
it('derives the TEA key from IOPlatformUUID', () => {
|
||||
expect(MacMachineInfoUtils.keyFromUuid('0F1E2D3C-4B5A-6978-8E9F-A0B1C2D3E4F5').toString('hex'))
|
||||
.toBe('30463145324433432d344235412d3639');
|
||||
});
|
||||
|
||||
it('round-trips a container encrypt/decrypt', () => {
|
||||
const key = MacMachineInfoUtils.keyFromUuid('0F1E2D3C-4B5A-6978-8E9F-A0B1C2D3E4F5');
|
||||
const plain = MacMachineInfoUtils.buildMachineIdInfo(Buffer.from('0011223344556677', 'hex'), 'SN123456');
|
||||
const blob = containerEncrypt(plain, key);
|
||||
const back = containerDecrypt(blob, key);
|
||||
expect(back!.equals(plain)).toBe(true);
|
||||
const parsed = MacMachineInfoUtils.parseMachineIdInfo(back!);
|
||||
expect(parsed.machineId.toString('hex')).toBe('0011223344556677');
|
||||
expect(parsed.sn).toBe('SN123456');
|
||||
});
|
||||
});
|
||||
|
||||
function withTempDir (fn: (dir: string) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'napcat-guid-test-'));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('backup naming', () => {
|
||||
it('does not overwrite when creating backups in quick succession', () => {
|
||||
withTempDir((dir) => {
|
||||
const target = path.join(dir, 'machineid-info');
|
||||
fs.writeFileSync(target, Buffer.from('payload'));
|
||||
const first = MacMachineInfoUtils.backup(target);
|
||||
const second = MacMachineInfoUtils.backup(target);
|
||||
const third = MachineInfoUtils.backup(target);
|
||||
expect(new Set([first, second, third]).size).toBe(3);
|
||||
for (const backup of [first, second, third]) {
|
||||
expect(fs.existsSync(backup)).toBe(true);
|
||||
}
|
||||
expect(MacMachineInfoUtils.getBackups(target)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore path validation', () => {
|
||||
it('accepts legacy and new backup names present in the whitelist', () => {
|
||||
withTempDir((dir) => {
|
||||
const target = path.join(dir, 'Registry20');
|
||||
fs.writeFileSync(target, Buffer.from('x'));
|
||||
fs.writeFileSync(`${target}.bak.20251009123456`, Buffer.from('legacy'));
|
||||
const created = Registry20Utils.backup(target);
|
||||
const list = Registry20Utils.getBackups(target);
|
||||
expect(resolveBackupPath(target, path.basename(`${target}.bak.20251009123456`), list))
|
||||
.toBe(`${target}.bak.20251009123456`);
|
||||
expect(resolveBackupPath(target, path.basename(created), list)).toBe(created);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects directory traversal and unknown backup names', () => {
|
||||
withTempDir((dir) => {
|
||||
const target = path.join(dir, 'machine-info');
|
||||
fs.writeFileSync(target, Buffer.from('x'));
|
||||
const list = MachineInfoUtils.getBackups(target);
|
||||
expect(() => resolveBackupPath(target, '../../../non-backup.txt', list)).toThrow();
|
||||
expect(() => resolveBackupPath(target, '../machine-info.bak.20251009123456', list)).toThrow();
|
||||
expect(() => resolveBackupPath(target, 'machine-info.bak.20251009123456', list)).toThrow();
|
||||
expect(() => resolveBackupPath(target, '/etc/passwd', list)).toThrow();
|
||||
expect(() => resolveBackupPath(target, 'other.bak.20251009123456', list)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to restore a backup whose content is not a valid device file', () => {
|
||||
withTempDir((dir) => {
|
||||
const target = path.join(dir, 'machine-info');
|
||||
fs.writeFileSync(target, Buffer.from('x'));
|
||||
const evil = `${target}.bak.20251009123456`;
|
||||
fs.writeFileSync(evil, Buffer.from('not-a-machine-info'));
|
||||
|
||||
expect(() => MachineInfoUtils.restore(target, path.basename(evil))).toThrow();
|
||||
// 原始文件未被破坏
|
||||
expect(fs.readFileSync(target).toString()).toBe('x');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import { NativePacketHandler } from '@/napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from '@/napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { NativePacketClient } from '@/napcat-core/packet/client/nativeClient';
|
||||
import type { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import type { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import type { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import type { LogStack } from '@/napcat-core/packet/context/clientContext';
|
||||
import type { PacketBuf } from '@/napcat-core/packet/transformer/base';
|
||||
|
||||
const logger = { log: vi.fn(), logWarn: vi.fn(), logError: vi.fn() } as unknown as LogWrapper;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
Object.defineProperty(process, 'arch', archDescriptor);
|
||||
});
|
||||
|
||||
describe('macOS native loaders', () => {
|
||||
it.each(['arm64', 'x64'])('loads both packet modules for %s', async (arch) => {
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
const dlopen = vi.spyOn(process, 'dlopen').mockImplementation((module) => {
|
||||
Object.assign(module, { exports: { initHook: vi.fn().mockReturnValue('success') } });
|
||||
});
|
||||
const handler = new NativePacketHandler({ logger });
|
||||
expect(await handler.init('7.0.2-53644')).toBe(true);
|
||||
expect(dlopen.mock.calls[0]![1]).toContain('MoeHoo.darwin.' + arch + '.node');
|
||||
const loader = new Napi2NativeLoader({ logger });
|
||||
expect(loader.loaded).toBe(true);
|
||||
expect(dlopen.mock.calls[1]![1]).toContain('napi2native.darwin.' + arch + '.node');
|
||||
});
|
||||
|
||||
it.each(['error hook', 'error search', undefined])('does not report native failure %s as success', async (result) => {
|
||||
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
||||
vi.spyOn(process, 'dlopen').mockImplementation(module => {
|
||||
Object.assign(module, { exports: { initHook: vi.fn().mockReturnValue(result) } });
|
||||
});
|
||||
expect(await new NativePacketHandler({ logger }).init('7.0.2-53644')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not attempt dlopen when a native binary is absent', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const dlopen = vi.spyOn(process, 'dlopen').mockImplementation(() => {});
|
||||
const handler = new NativePacketHandler({ logger });
|
||||
expect(await handler.init('7.0.2-53644')).toBe(false);
|
||||
expect(new Napi2NativeLoader({ logger }).loaded).toBe(false);
|
||||
expect(dlopen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NativePacketClient', () => {
|
||||
function fixture () {
|
||||
const send = vi.fn();
|
||||
const minimum = vi.fn().mockImplementation(version => Number(version) <= 40768);
|
||||
const native = { initHook: vi.fn().mockReturnValue(true) };
|
||||
const core = { basicInfo: { requireMinNTQQBuild: minimum }, sendSsoCmdReqByContend: send };
|
||||
const logs = { pushLogWarn: vi.fn(), pushLogInfo: vi.fn(), pushLogError: vi.fn() };
|
||||
const client = new NativePacketClient(
|
||||
core as unknown as NapCoreContext,
|
||||
{ error: vi.fn() } as unknown as PacketLogger,
|
||||
logs as unknown as LogStack,
|
||||
native as unknown as Napi2NativeLoader
|
||||
);
|
||||
return { client, send, minimum, native };
|
||||
}
|
||||
|
||||
it('initializes the oldest supported build and clears availability after failure', async () => {
|
||||
const { client, minimum, native } = fixture();
|
||||
await client.init(0, '10', '20');
|
||||
expect(minimum).toHaveBeenCalledWith('40768');
|
||||
expect(client.available).toBe(true);
|
||||
native.initHook.mockReturnValue(false);
|
||||
await client.init(0, '10', '20');
|
||||
expect(client.available).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure', 'timeout'])('clears request timers after %s', async (outcome) => {
|
||||
vi.useFakeTimers();
|
||||
const { client, send } = fixture();
|
||||
const payload = Buffer.from([0, 255, 128]) as PacketBuf;
|
||||
if (outcome === 'success') send.mockResolvedValue({ rspbuffer: payload });
|
||||
if (outcome === 'failure') send.mockRejectedValue(new Error('network unavailable'));
|
||||
if (outcome === 'timeout') send.mockReturnValue(new Promise(() => {}));
|
||||
const pending = client.sendPacket('Test.Command', payload, true, 50);
|
||||
if (outcome === 'success') {
|
||||
await expect(pending).resolves.toEqual({ seq: 0, cmd: 'Test.Command', data: payload });
|
||||
} else {
|
||||
const assertion = expect(pending).rejects.toThrow(outcome === 'timeout' ? '超时' : 'network unavailable');
|
||||
if (outcome === 'timeout') await vi.advanceTimersByTimeAsync(50);
|
||||
await assertion;
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(send).toHaveBeenCalledWith('Test.Command', payload);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createOfflineRecovery } from '../napcat-core/helper/offline-recovery';
|
||||
|
||||
function setup (restartResult: boolean | Error = true) {
|
||||
const state = { online: false };
|
||||
const onOffline = vi.fn();
|
||||
const onRecovered = vi.fn();
|
||||
const restart = vi.fn(async () => {
|
||||
if (restartResult instanceof Error) throw restartResult;
|
||||
return restartResult;
|
||||
});
|
||||
const handleOffline = createOfflineRecovery({
|
||||
isOnline: () => state.online,
|
||||
onOffline,
|
||||
onRecovered,
|
||||
restart,
|
||||
});
|
||||
return { state, onOffline, onRecovered, restart, handleOffline };
|
||||
}
|
||||
|
||||
describe('offline recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('kicked offline restarts after a short delay', async () => {
|
||||
const { restart, onOffline, handleOffline } = setup();
|
||||
handleOffline('kicked', false);
|
||||
|
||||
expect(onOffline).toHaveBeenCalledWith('kicked');
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
expect(restart).toHaveBeenCalledWith('kicked');
|
||||
});
|
||||
|
||||
test('silent offline waits for the account to reconnect before restarting', async () => {
|
||||
const { restart, handleOffline } = setup();
|
||||
handleOffline('silent', true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('silent offline that recovers on its own does not restart', async () => {
|
||||
const { state, restart, onRecovered, handleOffline } = setup();
|
||||
handleOffline('silent', true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
state.online = true;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
expect(onRecovered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a kick during the silent-offline wait restarts without waiting the full minute', async () => {
|
||||
const { restart, handleOffline } = setup();
|
||||
handleOffline('silent', true);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
handleOffline('kicked', false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
expect(restart).toHaveBeenCalledWith('kicked');
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('repeated offline events schedule only one restart', async () => {
|
||||
const { restart, onOffline, handleOffline } = setup();
|
||||
handleOffline('kicked', false);
|
||||
handleOffline('kicked again', false);
|
||||
handleOffline('silent', true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
expect(onOffline).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test.each([false, new Error('boom')])('a failed restart (%s) can be retried by the next offline event', async (result) => {
|
||||
const { restart, handleOffline } = setup(result);
|
||||
handleOffline('kicked', false);
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(restart).toHaveBeenCalledTimes(1);
|
||||
|
||||
handleOffline('kicked', false);
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(restart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { NapCatCore } from '../napcat-core/index';
|
||||
import type { SelfStatusInfo, SelfInfo } from '../napcat-core/types';
|
||||
|
||||
interface FakeProfileListener {
|
||||
onSelfStatusChanged: (info: SelfStatusInfo) => void;
|
||||
}
|
||||
|
||||
interface FakeMessageListener {
|
||||
onKickedOffLine: (info: { tipsTitle: string; tipsDesc: string; }) => void;
|
||||
}
|
||||
|
||||
function status (value: number): SelfStatusInfo {
|
||||
return {
|
||||
uid: 'self-uid',
|
||||
status: value,
|
||||
extStatus: 0,
|
||||
termType: 0,
|
||||
netType: 0,
|
||||
iconType: 0,
|
||||
customStatus: undefined,
|
||||
setTime: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function createCore (online: boolean | undefined) {
|
||||
let profileListener: FakeProfileListener | undefined;
|
||||
let messageListener: FakeMessageListener | undefined;
|
||||
const session = {
|
||||
getMsgService: () => ({
|
||||
addKernelMsgListener: vi.fn((listener: FakeMessageListener) => {
|
||||
messageListener = listener;
|
||||
}),
|
||||
}),
|
||||
getProfileService: () => ({
|
||||
addKernelProfileListener: vi.fn((listener: FakeProfileListener) => {
|
||||
profileListener = listener;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const logger = {
|
||||
log: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
};
|
||||
const emit = vi.fn();
|
||||
// NapCatCore's constructor loads the native wrapper, which is unavailable in tests.
|
||||
// Only the listener wiring is under test, so the instance is built field-by-field.
|
||||
const core = Object.create(NapCatCore.prototype) as NapCatCore;
|
||||
Object.assign(core, {
|
||||
context: { logger, session },
|
||||
selfInfo: { uid: 'self-uid', uin: '1', online } as SelfInfo,
|
||||
event: { emit },
|
||||
});
|
||||
return {
|
||||
core,
|
||||
emit,
|
||||
profileListener: () => profileListener,
|
||||
messageListener: () => messageListener,
|
||||
};
|
||||
}
|
||||
|
||||
describe('silent offline (SelfOffline) event', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('emits SelfOffline when the account transitions online -> offline', async () => {
|
||||
const { core, emit, profileListener } = createCore(true);
|
||||
await core.initNapCatCoreListeners();
|
||||
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
expect(emit).toHaveBeenCalledWith('SelfOffline', expect.stringContaining('账号状态变更为离线'));
|
||||
expect(core.selfInfo.online).toBe(false);
|
||||
});
|
||||
|
||||
test('does not emit again while the account stays offline', async () => {
|
||||
const { core, emit, profileListener } = createCore(true);
|
||||
await core.initNapCatCoreListeners();
|
||||
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('emits again after the account recovered and went offline once more', async () => {
|
||||
const { core, emit, profileListener } = createCore(true);
|
||||
await core.initNapCatCoreListeners();
|
||||
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
profileListener()!.onSelfStatusChanged(status(10));
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(2);
|
||||
expect(core.selfInfo.online).toBe(false);
|
||||
});
|
||||
|
||||
test('does not emit during startup when the account was never online', async () => {
|
||||
const { core, emit, profileListener } = createCore(undefined);
|
||||
await core.initNapCatCoreListeners();
|
||||
|
||||
profileListener()!.onSelfStatusChanged(status(20));
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(core.selfInfo.online).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps emitting KickedOffLine as before', async () => {
|
||||
const { core, emit, messageListener } = createCore(true);
|
||||
await core.initNapCatCoreListeners();
|
||||
|
||||
messageListener()!.onKickedOffLine({ tipsTitle: '下线通知', tipsDesc: '当前账号已在其它设备登录' });
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
expect(emit).toHaveBeenCalledWith('KickedOffLine', expect.stringContaining('当前账号已在其它设备登录'));
|
||||
expect(core.selfInfo.online).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "napcat-test",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"vitest": "^4.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-image-size": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-rpc": "workspace:*",
|
||||
"napcat-protobuf": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
|
||||
vi.mock('@/napcat-core/index', () => ({ getMajorPath: vi.fn() }));
|
||||
|
||||
describe('QQ AppID lookup', () => {
|
||||
let info: QQBasicInfoWrapper;
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'x64' });
|
||||
info = Object.create(QQBasicInfoWrapper.prototype);
|
||||
info.context = { logger: { log: vi.fn() } } as unknown as QQBasicInfoWrapper['context'];
|
||||
vi.spyOn(info, 'getFullQQVersion');
|
||||
vi.spyOn(info, 'getAppidV2ByMajor').mockReturnValue('537999999');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'arch', archDescriptor);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['3.2.30-50969', 'x64', 537376344],
|
||||
['3.2.30-50969', 'arm64', 537376345],
|
||||
['3.2.32-52194', 'x64', 537379447],
|
||||
['3.2.32-52194', 'arm64', 537379448],
|
||||
['3.2.33-52892', 'x64', 537382855],
|
||||
['3.2.33-52892', 'arm64', 537382856],
|
||||
] as const)('uses the scanned AppID for Linux %s %s', (version, arch, appid) => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: arch });
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid,
|
||||
qua: `V1_LNX_NQ_${version.replace('-', '_')}_GW_B`,
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['9.9.33-52230', 537379411],
|
||||
['9.9.35-52892', 537382819],
|
||||
] as const)('finds Windows %s by its actual package version', (version, appid) => {
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid,
|
||||
qua: `V1_WIN_NQ_${version.replace('-', '_')}_GW_B`,
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves version-only entries when an architecture override is absent', () => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'arm64' });
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.29-49738');
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid: 537355867,
|
||||
qua: 'V1_LNX_NQ_3.2.29_49738_GW_B',
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still scans major.node for an unknown version', () => {
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.99-99999');
|
||||
vi.spyOn(info, 'getQUAFallback').mockReturnValue('fallback-qua');
|
||||
|
||||
expect(info.getAppidV2()).toEqual({ appid: '537999999', qua: 'fallback-qua' });
|
||||
expect(info.getAppidV2ByMajor).toHaveBeenCalledWith('3.2.99-99999');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzoneDeleteBody,
|
||||
buildQzonePublishBody,
|
||||
buildQzoneUploadImageBody,
|
||||
parseQzoneDeleteResponse,
|
||||
parseQzonePublishResponse,
|
||||
parseQzoneUploadImageResponseText,
|
||||
toQzoneRichval,
|
||||
} from '@/napcat-core/data/qzone';
|
||||
|
||||
describe('Qzone data helpers', () => {
|
||||
test('buildQzoneUploadImageBody encodes required fields', () => {
|
||||
const body = buildQzoneUploadImageBody({
|
||||
uin: '123456',
|
||||
skey: 'sk',
|
||||
pskey: 'pk',
|
||||
g_tk: '999',
|
||||
base64: 'AAAA',
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('uin')).toBe('123456');
|
||||
expect(params.get('skey')).toBe('sk');
|
||||
expect(params.get('p_skey')).toBe('pk');
|
||||
expect(params.get('picfile')).toBe('AAAA');
|
||||
expect(params.get('uploadtype')).toBe('1');
|
||||
expect(params.get('albumtype')).toBe('7');
|
||||
expect(params.get('base64')).toBe('1');
|
||||
expect(params.get('qzreferrer')).toContain('123456');
|
||||
});
|
||||
|
||||
test('parseQzoneUploadImageResponseText parses valid callback wrapper', () => {
|
||||
const raw = 'frameElement.callback({"code":0,"data":{"albumid":"a1","lloc":"l1","type":"22","height":"100","width":"200"}});</script>';
|
||||
const parsed = parseQzoneUploadImageResponseText(raw);
|
||||
expect(parsed.code).toBe(0);
|
||||
expect(parsed.data?.albumid).toBe('a1');
|
||||
expect(parsed.data?.lloc).toBe('l1');
|
||||
});
|
||||
|
||||
test('parseQzoneUploadImageResponseText throws on malformed response', () => {
|
||||
expect(() => parseQzoneUploadImageResponseText('not a valid response')).toThrow();
|
||||
});
|
||||
|
||||
test('toQzoneRichval builds expected format', () => {
|
||||
const richval = toQzoneRichval({ albumid: 'a1', lloc: 'l1', type: '22', height: '100', width: '200' });
|
||||
expect(richval).toBe(',a1,l1,l1,22,100,200,,100,200');
|
||||
});
|
||||
|
||||
test('buildQzonePublishBody without images omits richval/richtype', () => {
|
||||
const body = buildQzonePublishBody({
|
||||
hostuin: '123456',
|
||||
content: 'hello world',
|
||||
ugcRight: 1,
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('con')).toBe('hello world');
|
||||
expect(params.get('ugc_right')).toBe('1');
|
||||
expect(params.has('richval')).toBe(false);
|
||||
expect(params.has('richtype')).toBe(false);
|
||||
expect(params.has('allow_uins')).toBe(false);
|
||||
});
|
||||
|
||||
test('buildQzonePublishBody with images and allow_uins sets richtype and who', () => {
|
||||
const body = buildQzonePublishBody({
|
||||
hostuin: '123456',
|
||||
content: 'with pic',
|
||||
richval: ',a1,l1,l1,22,100,200,,100,200\t,a2,l2,l2,22,100,200,,100,200',
|
||||
ugcRight: 16,
|
||||
allowUins: '10001|10002',
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('richtype')).toBe('1');
|
||||
expect(params.get('richval')).toContain('\t');
|
||||
expect(params.get('ugc_right')).toBe('16');
|
||||
expect(params.get('allow_uins')).toBe('10001|10002');
|
||||
expect(params.get('who')).toBe('1');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse extracts tid on success', () => {
|
||||
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123' });
|
||||
expect(result.tid).toBe('tid123');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse prefers t1_tid over tid', () => {
|
||||
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123', t1_tid: 't1tid456' });
|
||||
expect(result.tid).toBe('t1tid456');
|
||||
});
|
||||
|
||||
test('keeps server publish time and the SL unknown-time sentinel', () => {
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '1700000000', now: 1700000001 })).toEqual({ tid: 'post', time: 1700000000 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', now: 1700000001 })).toEqual({ tid: 'post', time: 1700000001 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post' })).toEqual({ tid: 'post', time: 0 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08', t1_ntime: 1700000002 })).toEqual({ tid: 'post', time: 1700000002 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08', now: 1700000003 })).toEqual({ tid: 'post', time: 1700000003 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08' })).toEqual({ tid: 'post', time: 0 });
|
||||
expect(() => parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: 'bad' })).toThrow('勿自动重试');
|
||||
expect(() => parseQzonePublishResponse({ code: -1, subcode: 0, tid: 'post' })).toThrow('服务端拒绝');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when subcode indicates failure', () => {
|
||||
expect(() => parseQzonePublishResponse({ subcode: -200, message: 'private-response' })).toThrow('服务端拒绝请求');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when tid missing', () => {
|
||||
expect(() => parseQzonePublishResponse({ subcode: 0 })).toThrow();
|
||||
});
|
||||
|
||||
test('buildQzoneDeleteBody encodes required fields', () => {
|
||||
const body = buildQzoneDeleteBody({ hostuin: '123456', tid: 'tid123' });
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('hostuin')).toBe('123456');
|
||||
expect(params.get('tid')).toBe('tid123');
|
||||
expect(params.get('t1_source')).toBe('1');
|
||||
expect(params.get('code_version')).toBe('1');
|
||||
expect(params.get('format')).toBe('json');
|
||||
expect(params.get('qzreferrer')).toContain('123456');
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse succeeds when subcode is 0', () => {
|
||||
const result = parseQzoneDeleteResponse({ subcode: 0 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse throws when subcode indicates failure', () => {
|
||||
expect(() => parseQzoneDeleteResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse throws with default message when subcode indicates failure without message', () => {
|
||||
expect(() => parseQzoneDeleteResponse({ subcode: -200 })).toThrow('subcode=-200');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneFeeds, GetQzoneFeedsPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneFeeds';
|
||||
import { GetQzoneMsgList, GetQzoneMsgListPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgList';
|
||||
import { GetQzoneMsgDetail, GetQzoneMsgDetailPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail';
|
||||
import { LikeQzone } from '@/napcat-onebot/action/extends/qzone/LikeQzone';
|
||||
import { UnlikeQzone } from '@/napcat-onebot/action/extends/qzone/UnlikeQzone';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { QzoneDetailReturnSchema, QzoneFeedItemExample, QzoneFeedReferenceExample, QzoneParentCommentSchema } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getFriendFeeds: vi.fn().mockResolvedValue({ feeds: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgList: vi.fn().mockResolvedValue({ msglist: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgDetail: vi.fn().mockResolvedValue({ ...QzoneFeedItemExample, comments: [] }),
|
||||
setLike: vi.fn().mockResolvedValue(undefined),
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'new-comment', comment: { id: 'new-comment' } }),
|
||||
};
|
||||
return { apis: { QzoneApi }, context: { logger: { logError: vi.fn() } } };
|
||||
}
|
||||
|
||||
describe('QQ空间 OneBot actions', () => {
|
||||
it('passes the opaque cursor through and keeps raw feed fields in the result', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneFeeds(undefined as never, core as never);
|
||||
|
||||
const first = await action._handle({ format: 'native' });
|
||||
await action._handle({ cursor: 'next-page' });
|
||||
|
||||
expect(core.apis.QzoneApi.getFriendFeeds.mock.calls).toEqual([[undefined], ['next-page']]);
|
||||
expect(first).toHaveProperty('feeds.0.feed_ref', QzoneFeedReferenceExample);
|
||||
expect(first).toHaveProperty('feeds.0.feed', QzoneFeedItemExample.feed);
|
||||
expect(first).not.toHaveProperty('total');
|
||||
});
|
||||
|
||||
it('uses the current account by default and preserves numeric or string target account IDs', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgList(undefined as never, core as never);
|
||||
|
||||
await action._handle({ format: 'native' });
|
||||
await action._handle({ format: 'native', target_uin: 123456, cursor: 'next-page' });
|
||||
await action._handle({ format: 'native', target_uin: '9007199254740993' });
|
||||
|
||||
expect(core.apis.QzoneApi.getMsgList.mock.calls).toEqual([
|
||||
[undefined, undefined],
|
||||
['123456', 'next-page'],
|
||||
['9007199254740993', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards the complete reference to detail and both like operations', async () => {
|
||||
const core = createCore();
|
||||
const reference = {
|
||||
...QzoneFeedReferenceExample,
|
||||
cell_feed_transfer: { source_token: 'opaque-transfer', unknown_field: 7 },
|
||||
};
|
||||
|
||||
await new GetQzoneMsgDetail(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
await new LikeQzone(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
await new UnlikeQzone(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(reference);
|
||||
expect(core.apis.QzoneApi.setLike.mock.calls).toEqual([[reference, true], [reference, false]]);
|
||||
});
|
||||
|
||||
it('preserves opaque reference fields and empty auxiliary union keys through schema parsing', async () => {
|
||||
const core = createCore();
|
||||
const reference = {
|
||||
union_id: {
|
||||
uf_key: 'feed-key',
|
||||
ugc_id: '',
|
||||
feed_id: '',
|
||||
serialized_data: '',
|
||||
ext_ids: [{ ext_id_type: 5, str: 'tid', unknown_numeric: '9007199254740993' }],
|
||||
unknown_field: { keep: 'unchanged' },
|
||||
},
|
||||
cell_feed_transfer: { unknown_fields: [0, false, 'opaque-transfer'] },
|
||||
};
|
||||
const action = new GetQzoneMsgDetail(undefined as never, core as never);
|
||||
const expected = structuredClone(reference);
|
||||
|
||||
const result = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(expected);
|
||||
expect(reference).toEqual(expected);
|
||||
expect(reference.union_id.ext_ids[0]?.unknown_numeric).toBe('9007199254740993');
|
||||
});
|
||||
|
||||
it('preserves a complete parent comment and empty auxiliary UID through the real handle parser', async () => {
|
||||
const core = createCore();
|
||||
const parent = {
|
||||
id: '0',
|
||||
user: { uin: '10002', uid: '', nick: '评论作者', opaque_user: { keep: true } },
|
||||
content: [{ type: 0, content: '原评论', unknown_segment: 'keep' }],
|
||||
replys: [{ id: 'reply-1', content: [{ type: 0, content: '楼中楼' }] }],
|
||||
like: { id: 'comment-like-key', count: 2, status: 0 },
|
||||
unknown_field: { serialized: '9007199254740993' },
|
||||
};
|
||||
const expected = structuredClone(parent);
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent,
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(
|
||||
QzoneFeedReferenceExample, '回复', expected, undefined
|
||||
);
|
||||
expect(parent).toEqual(expected);
|
||||
});
|
||||
|
||||
it('adds optional detail pagination without changing raw comment shape or write parent requirements', () => {
|
||||
const detail = TypeCompiler.Compile(QzoneDetailReturnSchema);
|
||||
const parent = TypeCompiler.Compile(QzoneParentCommentSchema);
|
||||
expect(detail.Check({ ...QzoneFeedItemExample, comments: [] })).toBe(true);
|
||||
expect(detail.Check({
|
||||
...QzoneFeedItemExample,
|
||||
comments: [{ id: 0, user: { uid: 'u_commenter', portrait: { source: 'opaque' } }, replys: [{ id: 'reply' }], unknown_field: true }],
|
||||
comment_has_more: true,
|
||||
comment_next_cursor: 'opaque-cursor',
|
||||
})).toBe(true);
|
||||
expect(parent.Check({ id: 0, user: { uid: 'u_commenter' } })).toBe(false);
|
||||
expect(parent.Check({ id: '0', user: { uin: '10002', uid: '' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes root comments and replies without synthesizing parent identities', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const parent = { id: '0', user: { uin: '10002', uid: 'u_parent' }, opaque_field: 'keep' };
|
||||
const target = { uin: '10003', uid: 'u_reply', nick: '回复目标' };
|
||||
|
||||
const result = await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '正文' });
|
||||
await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent });
|
||||
await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '楼中楼', comment: parent, reply_user: target });
|
||||
|
||||
expect(core.apis.QzoneApi.comment.mock.calls).toEqual([
|
||||
[QzoneFeedReferenceExample, '正文', undefined, undefined],
|
||||
[QzoneFeedReferenceExample, '回复', parent, undefined],
|
||||
[QzoneFeedReferenceExample, '楼中楼', parent, target],
|
||||
]);
|
||||
expect(result.comment_id).toBe('new-comment');
|
||||
});
|
||||
|
||||
it('rejects empty comments and reply targets without a parent before invoking the API', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
|
||||
const blank = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: ' \n ' }, 'test', {} as never);
|
||||
const orphan = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample,
|
||||
content: '正文',
|
||||
reply_user: { uin: '10003' },
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(blank.status).toBe('failed');
|
||||
expect(orphan.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts both schema families while rejecting invalid references and scalar shapes', () => {
|
||||
const feeds = TypeCompiler.Compile(GetQzoneFeedsPayloadSchema);
|
||||
const list = TypeCompiler.Compile(GetQzoneMsgListPayloadSchema);
|
||||
const detail = TypeCompiler.Compile(GetQzoneMsgDetailPayloadSchema);
|
||||
|
||||
expect(feeds.Check({ cursor: 'a'.repeat(65537) })).toBe(false);
|
||||
expect(feeds.Check({ page_num: 1, count: 10 })).toBe(true);
|
||||
expect(list.Check({ pos: 0, num: 20 })).toBe(true);
|
||||
expect(list.Check({ target_uin: Number.MAX_SAFE_INTEGER + 1 })).toBe(false);
|
||||
expect(list.Check({ target_uin: 'not-a-qq-number' })).toBe(false);
|
||||
expect(detail.Check({ tid: 'shuoshuo-tid' })).toBe(true);
|
||||
expect(detail.Check({ feed_ref: { union_id: [] } })).toBe(false);
|
||||
expect(detail.Check({ feed_ref: { union_id: {} } })).toBe(false);
|
||||
expect(detail.Check({ feed_ref: { ...QzoneFeedReferenceExample, feed_action_domain_type: 0 } })).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces API errors as failed actions and only calls the write API once', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setLike.mockRejectedValue(new Error('QQ空间返回的点赞结果不完整'));
|
||||
const action = new LikeQzone(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('点赞结果不完整');
|
||||
expect(core.apis.QzoneApi.setLike).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns explicit null data for both successful like actions', async () => {
|
||||
const core = createCore();
|
||||
for (const Action of [LikeQzone, UnlikeQzone]) {
|
||||
const result = await new Action(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never
|
||||
);
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('declares the SnowLuma action names with schema-valid payload and result examples', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneFeeds(undefined as never, core as never),
|
||||
new GetQzoneMsgList(undefined as never, core as never),
|
||||
new GetQzoneMsgDetail(undefined as never, core as never),
|
||||
new LikeQzone(undefined as never, core as never),
|
||||
new UnlikeQzone(undefined as never, core as never),
|
||||
new CommentQzone(undefined as never, core as never),
|
||||
];
|
||||
|
||||
expect(actions.map(action => action.actionName)).toEqual([
|
||||
'get_qzone_feeds', 'get_qzone_msg_list', 'get_qzone_msg_detail', 'like_qzone', 'unlike_qzone', 'comment_qzone',
|
||||
]);
|
||||
for (const action of actions) {
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneCommentList } from '@/napcat-onebot/action/extends/qzone/GetQzoneCommentList';
|
||||
import { GetQzoneCommentReplies } from '@/napcat-onebot/action/extends/qzone/GetQzoneCommentReplies';
|
||||
import { QzoneFeedReferenceExample } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { QzoneCommentPageExample, QzoneReplyPageExample } from '@/napcat-onebot/action/extends/qzone/common-comment';
|
||||
|
||||
function createCore () {
|
||||
return {
|
||||
apis: {
|
||||
QzoneApi: {
|
||||
getComments: vi.fn().mockResolvedValue(QzoneCommentPageExample),
|
||||
getCommentReplies: vi.fn().mockResolvedValue(QzoneReplyPageExample),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('QQ空间评论读取 actions', () => {
|
||||
it('passes the complete reference and optional opaque cursor to comment reads', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentList(undefined as never, core as never);
|
||||
const reference = {
|
||||
...QzoneFeedReferenceExample,
|
||||
union_id: { ...QzoneFeedReferenceExample.union_id, unknown_field: { large_id: '9007199254740993' } },
|
||||
cell_feed_transfer: { opaque: [0, false, 'keep'] },
|
||||
};
|
||||
const expected = structuredClone(reference);
|
||||
|
||||
const first = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
await action.handle({ feed_ref: reference, cursor: 'next-comment-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.getComments.mock.calls).toEqual([[expected, undefined], [expected, 'next-comment-cursor']]);
|
||||
expect(first.data).toBe(QzoneCommentPageExample);
|
||||
expect(reference).toEqual(expected);
|
||||
});
|
||||
|
||||
it('retains raw parent identity types, UID-only authors and every unknown field through handle parsing', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
const parent = {
|
||||
id: 0,
|
||||
user: { uid: 'u_commenter', uin: '', nick: '', portrait: { source: 'opaque-portrait' }, unknown_user: 'keep' },
|
||||
content: [{ type: 0, content: '原评论', unknown_segment: true }],
|
||||
replys: [{ id: 'reply-1', content: [{ type: 0, content: '内联回复' }] }],
|
||||
reply_attach_info: 'server-reply-pointer',
|
||||
like: { id: 'like-key', count: 0, status: 0 },
|
||||
unknown_field: { large_id: '9007199254740993' },
|
||||
};
|
||||
const expected = structuredClone(parent);
|
||||
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: parent }, 'test', {} as never);
|
||||
await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: { id: '0' }, cursor: 'next-reply-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.getCommentReplies.mock.calls).toEqual([
|
||||
[QzoneFeedReferenceExample, expected, undefined],
|
||||
[QzoneFeedReferenceExample, { id: '0' }, 'next-reply-cursor'],
|
||||
]);
|
||||
expect(typeof core.apis.QzoneApi.getCommentReplies.mock.calls[0]?.[1]?.id).toBe('number');
|
||||
expect(typeof core.apis.QzoneApi.getCommentReplies.mock.calls[1]?.[1]?.id).toBe('string');
|
||||
expect(parent).toEqual(expected);
|
||||
});
|
||||
|
||||
it('allows an omitted author or empty auxiliary user object when the parent ID is exact', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
for (const parent of [{ id: 'parent' }, { id: 'parent', user: {} }, { id: 0, user: { uin: '', uid: '' } }]) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: parent }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
}
|
||||
expect(core.apis.QzoneApi.getCommentReplies).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects malformed parent IDs before TypeBox can coerce them or invoke the core', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
for (const id of [undefined, null, false, true, '', ' ', -1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, {}, []]) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: { id } } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
expect(core.apis.QzoneApi.getCommentReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed read payloads and unsupported page controls before invoking the core', async () => {
|
||||
const cases = [
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, page_size: 100 }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: false }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: '' }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: 'x'.repeat(16385) }],
|
||||
[GetQzoneCommentList, { feed_ref: { union_id: {} } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: [] }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', user: [] } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', user: null } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', content: '中'.repeat(22 * 1024) } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent' }, page_size: 20 }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent' }, target_uin: '10002' }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.getComments).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.getCommentReplies).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns partial and unknown metadata exactly instead of claiming complete comment history', async () => {
|
||||
const core = createCore();
|
||||
const page = {
|
||||
...QzoneCommentPageExample,
|
||||
comment_num: null,
|
||||
comments: [{ ...QzoneCommentPageExample.comments[0]!, reply_num: null, has_more_replies: null, replies: [] }],
|
||||
has_more: true,
|
||||
next_cursor: 'next-page',
|
||||
raw: { unknown_server_field: true },
|
||||
};
|
||||
core.apis.QzoneApi.getComments.mockResolvedValueOnce(page);
|
||||
|
||||
const result = await new GetQzoneCommentList(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never
|
||||
);
|
||||
|
||||
expect(result.data).toBe(page);
|
||||
expect(page.comments[0]?.has_more_replies).toBeNull();
|
||||
expect(page.raw).toEqual({ unknown_server_field: true });
|
||||
});
|
||||
|
||||
it('surfaces cursor failures without retrying the read', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.getComments.mockRejectedValueOnce(new Error('QQ空间评论游标无效或已过期'));
|
||||
|
||||
const result = await new GetQzoneCommentList(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample, cursor: 'expired-cursor' }, 'test', {} as never
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('游标无效或已过期');
|
||||
expect(core.apis.QzoneApi.getComments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares complete API metadata with schema-valid detailed examples', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneCommentList(undefined as never, core as never),
|
||||
new GetQzoneCommentReplies(undefined as never, core as never),
|
||||
];
|
||||
expect(actions.map(action => action.actionName)).toEqual(['get_qzone_comment_list', 'get_qzone_comment_replies']);
|
||||
for (const action of actions) {
|
||||
expect(action.actionSummary).not.toBe('');
|
||||
expect(action.actionDescription).not.toBe('');
|
||||
expect(action.actionTags).toContain('扩展接口');
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import type { QzoneComment, QzoneFeed, QzoneFeedReference } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (data: Record<string, unknown>, retcode = 0): Response {
|
||||
return new Response(JSON.stringify({ retcode, data }), { status: 200 });
|
||||
}
|
||||
|
||||
function reference (): QzoneFeedReference {
|
||||
return {
|
||||
union_id: { uf_key: 'old-key', ugc_id: 'old-ugc', feed_id: 'old-feed', serialized_data: 'old-opaque' },
|
||||
cell_feed_transfer: { nested: { value: 'old-transfer' } },
|
||||
feed_action_domain_type: 1,
|
||||
like_key: 'old-like',
|
||||
};
|
||||
}
|
||||
|
||||
function parent (): QzoneComment {
|
||||
return {
|
||||
id: 0,
|
||||
user: { uid: 'uid-only-author', nick: '父评论作者', portrait: 'https://example.com/avatar' },
|
||||
time: 1700000000,
|
||||
content: [{ type: 0, content: ' 原评论 ' }],
|
||||
like: { id: 'parent-like', count: 2, status: 1 },
|
||||
reply_num: 8,
|
||||
has_more_reply: true,
|
||||
reply_attach_info: 'inline-reply-end',
|
||||
replys: [{ id: 'inline-reply', content: [{ type: 0, content: '内联回复' }] }],
|
||||
opaque: { keep: 'parent-context' },
|
||||
};
|
||||
}
|
||||
|
||||
function detail (comments: QzoneComment[] = [parent()]): { feed: { feed: QzoneFeed }; attach_info: string; has_more: boolean; extra: string; } {
|
||||
return {
|
||||
feed: {
|
||||
feed: {
|
||||
cell_common: {
|
||||
union_id: { uf_key: 'fresh-key', ugc_id: 'fresh-ugc', feed_id: 'fresh-feed', serialized_data: 'fresh-opaque' },
|
||||
feed_type: 1,
|
||||
feed_sub_type: 1,
|
||||
domain_type: 2,
|
||||
cur_like_key: 'fresh-like',
|
||||
},
|
||||
cell_feed_transfer: { nested: { value: 'fresh-transfer' } },
|
||||
cell_comment: { num: 32, comments },
|
||||
cell_summary: { summary: [{ type: 0, content: '说说正文' }] },
|
||||
},
|
||||
},
|
||||
attach_info: 'comment-page-2',
|
||||
has_more: true,
|
||||
extra: 'retained-business-data',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Qzone comment and reply page transport', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('gets the first comment batch from detail and returns its updated full reference and raw response', async () => {
|
||||
const { api } = createApi();
|
||||
const business = detail();
|
||||
fetchMock.mockResolvedValueOnce(response(business));
|
||||
|
||||
const page = await api.getComments(reference());
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/GetFeedDetail?');
|
||||
expect(page).toMatchObject({
|
||||
comment_num: 32,
|
||||
has_more: true,
|
||||
next_cursor: expect.any(String),
|
||||
raw: business,
|
||||
feed_ref: {
|
||||
union_id: business.feed.feed.cell_common!.union_id,
|
||||
cell_feed_transfer: business.feed.feed.cell_feed_transfer,
|
||||
feed_action_domain_type: 2,
|
||||
like_key: 'fresh-like',
|
||||
},
|
||||
});
|
||||
expect(page.comments[0]).toMatchObject({
|
||||
comment_id: '0',
|
||||
user_id: null,
|
||||
uid: 'uid-only-author',
|
||||
nickname: '父评论作者',
|
||||
time: 1700000000,
|
||||
content: ' 原评论 ',
|
||||
like_count: 2,
|
||||
liked: true,
|
||||
reply_num: 8,
|
||||
has_more_replies: true,
|
||||
comment: parent(),
|
||||
});
|
||||
expect(page.comments[0]?.replies).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches exactly one continuation page with the fresh context and never invents a page-size or total', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [{ id: 'next-comment', opaque: true }] }, has_more: false, extra: 'next' }));
|
||||
const first = await api.getComments(reference());
|
||||
const next = await api.getComments(first.feed_ref, first.next_cursor!);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[1]!;
|
||||
expect(String(url)).toContain('/GetComment?');
|
||||
expect(options?.headers).toMatchObject({ 'x-oidb': '{"uint32_command":"0x9bfd","uint32_service_type":"1"}' });
|
||||
expect(JSON.parse(options?.body as string)).toEqual({
|
||||
union_id: first.feed_ref.union_id, attach_info: 'comment-page-2', cell_feed_transfer: first.feed_ref.cell_feed_transfer,
|
||||
});
|
||||
expect(next).toMatchObject({ comment_num: null, has_more: false, next_cursor: null, raw: { extra: 'next' } });
|
||||
expect(next.comments).toHaveLength(1);
|
||||
expect(next.comments[0]?.comment).toEqual({ id: 'next-comment', opaque: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('preserves old detail comments and exposes a usable continuation without another detail read', async () => {
|
||||
const { api } = createApi();
|
||||
const original = reference();
|
||||
original['opaque_entry'] = { preserve: 'internal-context' };
|
||||
const business = detail();
|
||||
delete business.feed.feed.cell_feed_transfer;
|
||||
fetchMock.mockResolvedValueOnce(response(business));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { num: 33, comments: [] }, has_more: false }));
|
||||
|
||||
const first = await api.getMsgDetail(original);
|
||||
expect(first.comments).toEqual([parent()]);
|
||||
expect(first.feed_ref).not.toHaveProperty('cell_feed_transfer');
|
||||
expect(first.feed_ref).not.toHaveProperty('opaque_entry');
|
||||
expect(first.comment_has_more).toBe(true);
|
||||
expect(first.comment_next_cursor).toEqual(expect.any(String));
|
||||
const next = await api.getComments(first.feed_ref!, first.comment_next_cursor!);
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain('/GetComment?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toMatchObject({
|
||||
union_id: business.feed.feed.cell_common!.union_id, cell_feed_transfer: original.cell_feed_transfer,
|
||||
});
|
||||
expect(next.feed_ref).toMatchObject({ cell_feed_transfer: original.cell_feed_transfer, opaque_entry: { preserve: 'internal-context' } });
|
||||
expect(next.comment_num).toBe(33);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not issue a detail continuation when its old normalized feed_ref is null', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: { cell_comment: { comments: [parent()] } } }, has_more: true, attach_info: 'next' }));
|
||||
const result = await api.getMsgDetail(reference());
|
||||
expect(result.feed_ref).toBeNull();
|
||||
expect(result.comments).toEqual([parent()]);
|
||||
expect(result.comment_has_more).toBe(true);
|
||||
expect(result.comment_next_cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('retains missing detail union/transfer from the supplied context for explicit comment reads', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: { cell_comment: { num: 0, comments: [] } } }, has_more: false }));
|
||||
await expect(api.getComments(reference())).resolves.toMatchObject({
|
||||
feed_ref: reference(), comment_num: 0, comments: [], has_more: false, next_cursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns additional replies using numeric parent id zero and a complete UID-only parent', async () => {
|
||||
const { api } = createApi();
|
||||
const originalParent = parent();
|
||||
const reply = { id: 1, user: { uid: 'reply-uid', nick: '' }, target_user: { uid: 'uid-only-author' }, time: 1700000001, content: [{ type: 0, content: '追加回复' }], opaque: true };
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [reply], has_more: true, reply_attach_info: 'reply-page-2', opaque: 'kept' }));
|
||||
|
||||
const page = await api.getCommentReplies(reference(), originalParent);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toContain('/GetReplies?');
|
||||
expect(options?.headers).toMatchObject({ 'x-oidb': '{"uint32_command":"0x9bfe","uint32_service_type":"1"}' });
|
||||
const request = JSON.parse(options?.body as string);
|
||||
expect(request).toEqual({
|
||||
union_id: reference().union_id,
|
||||
cur_comment: originalParent,
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: 'inline-reply-end',
|
||||
page_size: 20,
|
||||
cell_feed_transfer: reference().cell_feed_transfer,
|
||||
});
|
||||
expect(request.cur_comment.id).toBe(0);
|
||||
expect(page).toMatchObject({ comment_id: '0', reply_num: 8, has_more: true, next_cursor: expect.any(String), raw: { opaque: 'kept' } });
|
||||
expect(page.replies).toHaveLength(1);
|
||||
expect(page.replies[0]).toMatchObject({ reply_id: '1', uid: 'reply-uid', target_uid: 'uid-only-author', content: '追加回复', reply });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports read parents without user and preserves string id zero without write-author validation', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: false }));
|
||||
const result = await api.getCommentReplies(reference(), { id: '0', opaque: { data: true } });
|
||||
expect(result).toMatchObject({ comment_id: '0', reply_num: null, replies: [], has_more: false, next_cursor: null });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
cur_comment: { id: '0', opaque: { data: true } }, reply_attach_info: '', comment_attach_info: '', page_size: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('snapshots the parent and transfer before awaiting auth and uses stored context on continuation', async () => {
|
||||
const { api } = createApi();
|
||||
const inputRef = reference();
|
||||
const savedRef = structuredClone(inputRef);
|
||||
const inputParent = parent();
|
||||
const savedParent = structuredClone(inputParent);
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [{ id: 'page-one' }], has_more: true, reply_attach_info: 'reply-page-2' }));
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [{ id: 'page-two' }], has_more: false, reply_num: 9 }));
|
||||
const pending = api.getCommentReplies(inputRef, inputParent);
|
||||
(inputRef.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'caller-transfer-mutation';
|
||||
inputParent.user!.nick = 'caller-user-mutation';
|
||||
inputParent.content![0]!.content = 'caller-content-mutation';
|
||||
inputParent.replys!.push({ id: 'caller-injected' });
|
||||
const first = await pending;
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({ cur_comment: savedParent, cell_feed_transfer: savedRef.cell_feed_transfer });
|
||||
// A different author identity is rejected, while modified display/body fields never replace the cached parent.
|
||||
await expect(api.getCommentReplies(savedRef, inputParent, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
const callerParent = structuredClone(savedParent);
|
||||
callerParent.content = [{ type: 0, content: 'ignored new body' }];
|
||||
callerParent.replys = [{ id: 'ignored extra reply' }];
|
||||
const last = await api.getCommentReplies(savedRef, callerParent, first.next_cursor!);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({
|
||||
union_id: savedRef.union_id,
|
||||
cur_comment: { ...savedParent, reply_attach_info: 'reply-page-2', has_more_reply: true },
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: 'reply-page-2',
|
||||
page_size: 20,
|
||||
cell_feed_transfer: savedRef.cell_feed_transfer,
|
||||
});
|
||||
expect(last.replies).toHaveLength(1);
|
||||
expect(last.reply_num).toBe(9);
|
||||
expect(last.next_cursor).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('protects cached comment context from mutations through returned feed_ref and raw objects', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: false }));
|
||||
const first = await api.getComments(reference());
|
||||
const savedRef = structuredClone(first.feed_ref);
|
||||
(first.feed_ref.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'response-mutation';
|
||||
((first.raw['feed'] as { feed: QzoneFeed; }).feed.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'raw-mutation';
|
||||
const next = await api.getComments(savedRef, first.next_cursor!);
|
||||
expect(next.feed_ref).toEqual(savedRef);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string).cell_feed_transfer).toEqual(savedRef.cell_feed_transfer);
|
||||
});
|
||||
|
||||
it('binds cursors to query kind, full feed reference and exact parent id/user', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const comments = await api.getComments(reference());
|
||||
const ref = comments.feed_ref;
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true, reply_attach_info: 'reply-next' }));
|
||||
const replies = await api.getCommentReplies(ref, parent());
|
||||
await expect(api.getCommentReplies(ref, parent(), comments.next_cursor!)).rejects.toThrow('游标无效');
|
||||
await expect(api.getComments(ref, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
for (const modified of [
|
||||
{ ...ref, union_id: { ...ref.union_id, serialized_data: 'different-union-context' } },
|
||||
{ ...ref, cell_feed_transfer: { different: true } },
|
||||
{ ...ref, feed_action_domain_type: 3 },
|
||||
]) {
|
||||
await expect(api.getComments(modified, comments.next_cursor!)).rejects.toThrow('游标无效');
|
||||
}
|
||||
await expect(api.getCommentReplies(ref, { ...parent(), id: '0' }, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
await expect(api.getCommentReplies(ref, { ...parent(), user: { uid: 'other-user' } }, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('allows JSON property reordering but rejects account changes and expired cursors before network IO', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000000);
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: false }));
|
||||
const first = await api.getComments(reference());
|
||||
const reordered = Object.fromEntries(Object.entries(first.feed_ref).reverse()) as QzoneFeedReference;
|
||||
await api.getComments(reordered, first.next_cursor!);
|
||||
core.selfInfo.uin = '20002';
|
||||
core.selfInfo.uid = 'another-uid';
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '10001';
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(1000000 + 10 * 60 * 1000);
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('已过期');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reuses the shared 128-entry cursor bound across existing feeds and new comment pages', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed: detail().feed.feed }], has_more: true, attach_info: 'feed-next' }));
|
||||
for (let i = 0; i < 128; i++) await api.getFriendFeeds();
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(129);
|
||||
});
|
||||
|
||||
it('rejects malformed parents and reference bodies before acquiring tickets', async () => {
|
||||
const { api, core } = createApi();
|
||||
const cycle: Record<string, unknown> = { id: 0 };
|
||||
cycle['loop'] = cycle;
|
||||
const badParents: unknown[] = [
|
||||
{}, { id: '' }, { id: true }, { id: null }, { id: -1 }, { id: 1.5 }, { id: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ id: 0, user: [] }, { id: 0, user: 'bad' }, { id: 0, opaque: 'x'.repeat(65536) },
|
||||
{ id: 0, number: Number.NaN }, { id: 0, method: () => {} }, cycle,
|
||||
];
|
||||
for (const invalid of badParents) await expect(api.getCommentReplies(reference(), invalid as QzoneComment)).rejects.toThrow();
|
||||
await expect(api.getComments({ union_id: {} })).rejects.toThrow();
|
||||
await expect(api.getCommentReplies(reference(), { id: 0, reply_attach_info: 12 })).rejects.toThrow('续页信息无效');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or non-advancing server pointers rather than repeating the same page', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ ...detail(), attach_info: undefined }));
|
||||
await expect(api.getComments(reference())).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: true, attach_info: 'comment-page-2' }));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true, reply_attach_info: 'inline-reply-end' }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('未推进');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('does not disguise malformed response objects as successful empty pages', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('cell_comment');
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [null] }, has_more: false }));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('评论响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [null], has_more: false }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应格式异常');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('accepts omitted empty lists only when the server explicitly marks the final page', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
for (const lastPage of [{ has_more: false }, { cell_comment: { num: 0 }, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(lastPage));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).resolves.toMatchObject({
|
||||
comments: [], has_more: false, next_cursor: null, raw: lastPage,
|
||||
});
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(response({ has_more: false }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).resolves.toMatchObject({
|
||||
replies: [], has_more: false, next_cursor: null, raw: { has_more: false },
|
||||
});
|
||||
for (const badPage of [{ cell_comment: {} }, { has_more: true, attach_info: 'advanced' }, { cell_comment: null, has_more: false }, { cell_comment: { comments: null }, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(badPage));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('评论响应');
|
||||
}
|
||||
for (const badPage of [{ has_more: true, reply_attach_info: 'advanced' }, { replys: null, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(badPage));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('retains detail compatibility for omitted initial comment cells and never echoes private RPC errors', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: {} }, has_more: false }));
|
||||
const page = await api.getComments(reference());
|
||||
expect(page).toMatchObject({ comment_num: null, comments: [], has_more: false, next_cursor: null });
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ retcode: 4003, message: 'private credential', data: {} })));
|
||||
try {
|
||||
await api.getCommentReplies(reference(), parent());
|
||||
expect.fail('must reject business error');
|
||||
} catch (error) {
|
||||
expect(String(error)).toContain('4003');
|
||||
expect(String(error)).not.toContain('private');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { normalizeQzoneComment, normalizeQzoneReply } from '@/napcat-core/data/qzone-comments';
|
||||
import { normalizeQzoneFeed, normalizeQzoneMediaImages, normalizeQzoneRichContent, type QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
describe('Qzone comment and reply projections', () => {
|
||||
test('keeps unknown comment values null without inventing history or counts', () => {
|
||||
const comment = { future_field: { opaque: true } };
|
||||
const normalized = normalizeQzoneComment(comment);
|
||||
expect(normalized).toEqual({
|
||||
comment_id: null,
|
||||
user_id: null,
|
||||
uid: null,
|
||||
nickname: null,
|
||||
time: null,
|
||||
content: null,
|
||||
images: null,
|
||||
like_count: null,
|
||||
liked: null,
|
||||
reply_num: null,
|
||||
has_more_replies: null,
|
||||
replies: [],
|
||||
comment,
|
||||
});
|
||||
expect(normalized.comment).toBe(comment);
|
||||
});
|
||||
|
||||
test('normalizes the observed parent fields and preserves its complete raw object', () => {
|
||||
const reply = {
|
||||
id: 'reply-1',
|
||||
user: { uin: '10002', uid: 'u_reply', nick: '回复者' },
|
||||
target_user: { uin: '10001', uid: 'u_parent', nick: '作者' },
|
||||
time: '1700000001',
|
||||
content: [{ type: 0, content: '原始回复' }],
|
||||
like: { id: 'reply-like-key', count: '3', status: 0 },
|
||||
future_reply_field: 'keep',
|
||||
};
|
||||
const comment: QzoneComment = {
|
||||
id: '0',
|
||||
user: { uin: '10001', uid: 'u_parent', nick: '作者', portrait: 'https://example.com/avatar' },
|
||||
time: 1700000000,
|
||||
content: [{ type: 0, content: '第一行\n' }, { type: 1, name: 'Qiao', uin: '10003' }],
|
||||
media_items: [{ type: 0, image: { photo_url: [{ spec: 6, url: { url: 'https://example.com/original' } }] } }],
|
||||
like: { id: 'parent-like-key', count: 7, status: 1 },
|
||||
reply_num: 12,
|
||||
has_more_reply: true,
|
||||
reply_attach_info: 'opaque-token',
|
||||
replys: [reply],
|
||||
unknown_field: [0, false, 'keep'],
|
||||
};
|
||||
const normalized = normalizeQzoneComment(comment);
|
||||
expect(normalized).toMatchObject({
|
||||
comment_id: '0',
|
||||
user_id: '10001',
|
||||
uid: 'u_parent',
|
||||
nickname: '作者',
|
||||
time: 1700000000,
|
||||
content: '第一行\n@Qiao',
|
||||
images: ['https://example.com/original'],
|
||||
like_count: 7,
|
||||
liked: true,
|
||||
reply_num: 12,
|
||||
has_more_replies: true,
|
||||
});
|
||||
expect(normalized.replies).toHaveLength(1);
|
||||
expect(normalized.replies[0]).toMatchObject({
|
||||
reply_id: 'reply-1',
|
||||
user_id: '10002',
|
||||
uid: 'u_reply',
|
||||
nickname: '回复者',
|
||||
time: 1700000001,
|
||||
content: '原始回复',
|
||||
like_count: 3,
|
||||
liked: false,
|
||||
target_user_id: '10001',
|
||||
target_uid: 'u_parent',
|
||||
target_nickname: '作者',
|
||||
});
|
||||
expect(normalized.comment).toBe(comment);
|
||||
expect(normalized.replies[0]?.reply).toBe(reply);
|
||||
expect(comment['reply_attach_info']).toBe('opaque-token');
|
||||
expect(comment.user?.['portrait']).toBe('https://example.com/avatar');
|
||||
expect(comment['unknown_field']).toEqual([0, false, 'keep']);
|
||||
});
|
||||
|
||||
test('uses time rather than create_time and does not turn display defaults into facts', () => {
|
||||
expect(normalizeQzoneComment({ create_time: 1700000000 }).time).toBeNull();
|
||||
expect(normalizeQzoneReply({ create_time: 1700000000 }).time).toBeNull();
|
||||
expect(normalizeQzoneComment({ time: 0, reply_num: 0, has_more_reply: false, like: { count: 0, status: 0 } }))
|
||||
.toMatchObject({ time: 0, reply_num: 0, has_more_replies: false, like_count: 0, liked: false });
|
||||
expect(normalizeQzoneReply({ time: '0', like: { count: '0', status: '1' } })).toMatchObject({ time: 0, like_count: 0, liked: null });
|
||||
expect(normalizeQzoneComment({ has_more_reply: 1, like: { status: 2 } })).toMatchObject({ has_more_replies: null, liked: null });
|
||||
});
|
||||
|
||||
test('accepts ID zero while never inventing a rounded numeric identity', () => {
|
||||
for (const id of [0, '0']) {
|
||||
const raw = { id };
|
||||
expect(normalizeQzoneComment(raw).comment_id).toBe('0');
|
||||
expect(normalizeQzoneReply(raw).reply_id).toBe('0');
|
||||
expect(normalizeQzoneComment(raw).comment.id).toBe(id);
|
||||
}
|
||||
expect(normalizeQzoneReply({ id: '9007199254740993' }).reply_id).toBe('9007199254740993');
|
||||
for (const id of [Number.MAX_SAFE_INTEGER + 1, -1, NaN, Infinity, 0.5, '', ' ']) {
|
||||
expect(normalizeQzoneComment({ id }).comment_id).toBeNull();
|
||||
expect(normalizeQzoneReply({ id }).reply_id).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves UID-only authors and separates author identity from reply target', () => {
|
||||
const reply: QzoneComment = {
|
||||
id: 0,
|
||||
user: { uid: 'uid-only', nick: '' },
|
||||
target_user: { uin: '9007199254740993', uid: 'target-uid', nick: '回复目标' },
|
||||
content: [{ type: 0, content: '仅原文' }],
|
||||
};
|
||||
expect(normalizeQzoneReply(reply)).toMatchObject({
|
||||
reply_id: '0',
|
||||
user_id: null,
|
||||
uid: 'uid-only',
|
||||
nickname: '',
|
||||
content: '仅原文',
|
||||
target_user_id: '9007199254740993',
|
||||
target_uid: 'target-uid',
|
||||
target_nickname: '回复目标',
|
||||
});
|
||||
expect(normalizeQzoneComment({ user: { uin: '', uid: 'uid-only' } })).toMatchObject({ user_id: null, uid: 'uid-only' });
|
||||
expect(normalizeQzoneReply({ user: { uin: '10001', uid: '' } })).toMatchObject({ user_id: '10001', uid: null, target_user_id: null });
|
||||
expect(normalizeQzoneReply({ user: { uin: Number.MAX_SAFE_INTEGER + 1 } } as never).user_id).toBeNull();
|
||||
});
|
||||
|
||||
test('does not infer reply totals or continuation from inline replies', () => {
|
||||
const comment = { replys: [{ id: 'a' }, { id: 'b' }] };
|
||||
expect(normalizeQzoneComment(comment)).toMatchObject({ reply_num: null, has_more_replies: null });
|
||||
expect(normalizeQzoneComment(comment).replies).toHaveLength(2);
|
||||
expect(normalizeQzoneComment({ replys: [] })).toMatchObject({ replies: [], reply_num: null, has_more_replies: null });
|
||||
expect(normalizeQzoneComment({ reply_num: 20, has_more_reply: true })).toMatchObject({ replies: [], reply_num: 20, has_more_replies: true });
|
||||
});
|
||||
|
||||
test('retains unknown reply media while projecting only known images', () => {
|
||||
const video = { type: 1, video: { url: 'https://example.com/video', cover: { default_url: { url: 'https://example.com/cover' } } } };
|
||||
const unknown = { type: 99, vendor_media: { raw: true } };
|
||||
const reply = { media_items: [video, unknown], unknown_reply_field: { keep: true } };
|
||||
const result = normalizeQzoneReply(reply);
|
||||
expect(result.images).toBeNull();
|
||||
expect(result.reply).toBe(reply);
|
||||
expect(result.reply['media_items']).toEqual([video, unknown]);
|
||||
expect(normalizeQzoneReply({ media_items: [video] }).images).toEqual([]);
|
||||
expect(normalizeQzoneReply({ media_items: [] }).images).toEqual([]);
|
||||
});
|
||||
|
||||
test('preserves unsupported text segments and invalid numbers as unknown projections', () => {
|
||||
const reply = { content: [{ type: 99, content: 'future segment' }], time: 'unknown', like: { count: -1, status: true } };
|
||||
expect(normalizeQzoneReply(reply)).toMatchObject({ content: null, time: null, like_count: null, liked: null });
|
||||
expect(normalizeQzoneReply(reply).reply).toBe(reply);
|
||||
expect(normalizeQzoneComment({ reply_num: '9007199254740993' }).reply_num).toBeNull();
|
||||
});
|
||||
|
||||
test('does not silently drop malformed inline reply entries', () => {
|
||||
expect(() => normalizeQzoneComment({ replys: [null] } as never)).toThrow('内联回复');
|
||||
expect(() => normalizeQzoneComment({ replys: 'bad-shape' } as never)).toThrow('内联回复');
|
||||
expect(() => normalizeQzoneReply(null as never)).toThrow('回复数据');
|
||||
expect(() => normalizeQzoneComment(null as never)).toThrow('评论数据');
|
||||
});
|
||||
|
||||
test('shares rich text and image projections with feed normalization without changing feed behavior', () => {
|
||||
const content = [{ type: 0, content: 'same ' }, { type: 1, name: 'Qiao' }];
|
||||
const media = [{ type: 0, image: { default_url: { url: '//example.com/image' } } }];
|
||||
const feed = normalizeQzoneFeed({ cell_summary: { summary: content }, cell_media: { media_items: media } });
|
||||
const comment = normalizeQzoneComment({ content, media_items: media });
|
||||
expect(normalizeQzoneRichContent(content)).toBe(feed.content);
|
||||
expect(normalizeQzoneMediaImages(media)).toEqual(feed.images);
|
||||
expect(comment.content).toBe(feed.content);
|
||||
expect(comment.images).toEqual(feed.images);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as fileUtils from '@/napcat-common/src/file';
|
||||
import { SendQzoneMsg } from '@/napcat-onebot/action/extends/SendQzoneMsg';
|
||||
import { DeleteQzoneMsg } from '@/napcat-onebot/action/extends/DeleteQzoneMsg';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createCore () {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'napcat-qzone-compat-'));
|
||||
tempDirs.push(directory);
|
||||
return {
|
||||
NapCatTempPath: directory,
|
||||
selfInfo: { uin: '10001', uid: 'u_current' },
|
||||
apis: {
|
||||
QzoneApi: { publishMsg: vi.fn().mockResolvedValue({ tid: 'modern-tid' }) },
|
||||
WebApi: {
|
||||
uploadImageToQzone: vi.fn().mockResolvedValue('legacy-richval'),
|
||||
publishQzoneMsg: vi.fn().mockResolvedValue({ tid: 'legacy-tid' }),
|
||||
deleteQzoneMsg: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Qzone existing action compatibility', () => {
|
||||
it('keeps send_qzone_msg parameters and tid response for pure text', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '朋友可见', ugc_right: '16', target_uins: ['10002'] }))
|
||||
.resolves.toEqual({ tid: 'modern-tid', time: 0 });
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('朋友可见', 16, ['10002']);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an uncertain modern publish through the legacy CGI', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.publishMsg.mockRejectedValue(new Error('发表结果未知,请查询后确认'));
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '仅发送一次', images: [] })).rejects.toThrow('结果未知');
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves decimal target identifiers without a lossy numeric conversion', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await action._handle({ content: '指定好友', ugc_right: 16, target_uins: ['9007199254740993'] });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('指定好友', 16, ['9007199254740993']);
|
||||
});
|
||||
|
||||
it.each([{ images: [] }, { images: ['base64://aGVsbG8='] }])('rejects an invalid exclusion list before any write ($images)', async ({ images }) => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '排除指定好友', ugc_right: 128, target_uins: ['10002', 'invalid'], images }))
|
||||
.rejects.toThrow('target_uins');
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps legacy image publishing and cleans decoded images without deleting user files', async () => {
|
||||
const core = createCore();
|
||||
const localImage = join(core.NapCatTempPath, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({
|
||||
content: '带图说说',
|
||||
images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
ugc_right: 64,
|
||||
})).resolves.toEqual({ tid: 'legacy-tid', time: 0 });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone.mock.calls.map(args => args[0])).toEqual([
|
||||
Buffer.from('hello').toString('base64'),
|
||||
Buffer.from('user image').toString('base64'),
|
||||
]);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).toHaveBeenCalledExactlyOnceWith(
|
||||
'带图说说', ['legacy-richval', 'legacy-richval'], 64, undefined
|
||||
);
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
});
|
||||
|
||||
it('cleans temporary images and does not publish after an upload failure', async () => {
|
||||
const core = createCore();
|
||||
core.apis.WebApi.uploadImageToQzone.mockRejectedValue(new Error('upload failed'));
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '带图', images: ['base64://aGVsbG8='] })).rejects.toThrow('upload failed');
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops before upload and cleans decoded images if the account changes during image decoding', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const decode = fileUtils.uriToLocalFile;
|
||||
const decoder = vi.spyOn(fileUtils, 'uriToLocalFile').mockImplementationOnce(async (...args) => {
|
||||
const result = await decode(...args);
|
||||
Object.assign(core.selfInfo, change);
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8='],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual([]);
|
||||
} finally {
|
||||
decoder.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stops the upload chain without publishing to a changed account and preserves user images', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const localImage = join(core.NapCatTempPath, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
core.apis.WebApi.uploadImageToQzone.mockImplementationOnce(async () => {
|
||||
Object.assign(core.selfInfo, change);
|
||||
return 'legacy-richval';
|
||||
});
|
||||
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates existing selective visibility before uploading or publishing', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '指定好友', ugc_right: 128, images: ['base64://aGVsbG8='] }))
|
||||
.rejects.toThrow('target_uins 不能为空');
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps delete_qzone_msg on its existing tid-based CGI path', async () => {
|
||||
const core = createCore();
|
||||
const action = new DeleteQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ tid: 'existing-tid' })).resolves.toBeNull();
|
||||
expect(core.apis.WebApi.deleteQzoneMsg).toHaveBeenCalledExactlyOnceWith('existing-tid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { parseQzoneWebResponse } from '@/napcat-core/data/qzone-web';
|
||||
|
||||
const DOMAIN = 'document.domain="h5.qzone.qq.com";';
|
||||
const ASSIGN_CALLBACK = 'cb=frameElement.callback;';
|
||||
const TRY_SETUP = `var cb;try{${DOMAIN}${ASSIGN_CALLBACK}}catch(e){try{${DOMAIN}${ASSIGN_CALLBACK}}catch(e){${DOMAIN}${ASSIGN_CALLBACK}}}`;
|
||||
|
||||
function html (script: string, doctype = true): string {
|
||||
return `${doctype ? '<!DOCTYPE html>\n' : ''}<html>\n<head>\n<meta http-equiv="content-type" content="text/html; charset=utf-8" />\n</head>\n<body>\n<script type="text/javascript">\n${script}\n</script>\n</body>\n</html>`;
|
||||
}
|
||||
|
||||
function callback (value: unknown): string {
|
||||
return `frameElement.callback(${JSON.stringify(value)});`;
|
||||
}
|
||||
|
||||
describe('QQ空间 inert FormSender envelopes', () => {
|
||||
test.each([
|
||||
{ code: 0, subcode: 0, data: '' },
|
||||
{ code: 0, subcode: 0, data: { name: 'Ack' } },
|
||||
{ code: 0, subcode: 0, data: { id: 0, content: 'synthetic reply', postTime: 1700000000 }, err: { code: 0 } },
|
||||
])('accepts the observed three-domain try/catch wrapper with a synthetic response (%j)', (response) => {
|
||||
for (const doctype of [true, false]) {
|
||||
expect(parseQzoneWebResponse(html(`${TRY_SETUP}\n${callback(response)}`, doctype), true)).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the upload domain preamble and explicit ret=0 without inventing code=0', () => {
|
||||
const response = { ret: 0, data: { url: 'https://images.example.test/upload.png', width: 20, height: 10 } };
|
||||
const body = html(`${DOMAIN}${callback(response)}`, false);
|
||||
|
||||
expect(parseQzoneWebResponse(body)).toEqual(response);
|
||||
expect(() => parseQzoneWebResponse(body, true)).toThrow('成功状态');
|
||||
});
|
||||
|
||||
test('supports formatting and tag case without accepting extra attributes or syntax', () => {
|
||||
const setup = 'var cb ; try { document.domain = "h5.qzone.qq.com" ; cb = frameElement.callback ; } catch (e) { try { document.domain="h5.qzone.qq.com"; cb=frameElement.callback; } catch(e) { document.domain="h5.qzone.qq.com"; cb=frameElement.callback; } }';
|
||||
const body = html(`${setup}\n frameElement.callback ( { "code": 0 } ) ;`)
|
||||
.replace('<html>', '<HTML>')
|
||||
.replace('http-equiv="content-type"', 'http-equiv="Content-Type"')
|
||||
.replace('charset=utf-8', 'charset=UTF-8')
|
||||
.replace(' />', '/>');
|
||||
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0 });
|
||||
});
|
||||
|
||||
test('treats callback-looking content as data and preserves existing Ack normalization', () => {
|
||||
const response = { code: 0, data: { content: 'var cb; document.domain="other.test"; callback({"code":-1});', braces: '{}[]()' } };
|
||||
expect(parseQzoneWebResponse(html(`${TRY_SETUP}${callback(response)}`))).toEqual(response);
|
||||
expect(parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback({"ret":0,"data":{name:"Ack"}});`)))
|
||||
.toEqual({ ret: 0, data: { name: 'Ack' } });
|
||||
});
|
||||
|
||||
test.each(['Example Space', '', '合成空间名称', 'quote " and slash \\', 'line one\nline two', '}; callback({"code":-1});'])('accepts the observed bare name string key without changing its value (%j)', (name) => {
|
||||
const payload = `{"code":0,"subcode":0,"message":"","default":0,"data":{name:${JSON.stringify(name)}}}`;
|
||||
const body = html(`${TRY_SETUP}frameElement.callback(${payload});`);
|
||||
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0, subcode: 0, message: '', default: 0, data: { name } });
|
||||
});
|
||||
|
||||
test('normalizes only object keys and preserves name-looking text inside JSON strings', () => {
|
||||
const content = '{name:"Example"}, name:"Ack", escaped quote \\"';
|
||||
const payload = `{"code":0,"data":{"content":${JSON.stringify(content)},name:"Example","nested":[{name:"Nested"}]}}`;
|
||||
|
||||
expect(parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback(${payload});`)))
|
||||
.toEqual({ code: 0, data: { content, name: 'Example', nested: [{ name: 'Nested' }] } });
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0,"data":{name:1}}',
|
||||
'{"code":0,"data":{name:null}}',
|
||||
'{"code":0,"data":{name:false}}',
|
||||
'{"code":0,"data":{name:["Example"]}}',
|
||||
'{"code":0,"data":{name:{"value":"Example"}}}',
|
||||
'{"code":0,"data":{name:someName}}',
|
||||
'{"code":0,"data":{name:"Example"+"suffix"}}',
|
||||
'{"code":0,"data":{name:("Example")}}',
|
||||
'{"code":0,"data":{name:\'Example\'}}',
|
||||
'{"code":0,"data":{other:"Example"}}',
|
||||
'{"code":0,"data":{namex:"Example"}}',
|
||||
'{"code":0,"data":[name:"Example"]}',
|
||||
'{"code":0,"data":{name:"Example",other:"value"}}',
|
||||
'{"code":-1,"\\u0063ode":0,"data":{name:"Example"}}',
|
||||
'{"code":0,"subcode":-1,"data":{name:"Example"}}',
|
||||
])('does not extend bare-name compatibility to other syntax or failed statuses (%s)', (payload) => {
|
||||
expect(() => parseQzoneWebResponse(html(`${TRY_SETUP}frameElement.callback(${payload});`))).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('<body>', '<body onload="alert(1)">'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('<head>', '<head><script>alert(1)</script>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('</body>', '<img src="x" onerror="alert(1)"></body>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('type="text/javascript"', 'type="text/javascript" src="https://example.test/x.js"'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('type="text/javascript"', 'type="text/javascript" onload="alert(1)"'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('</script>', '</script><script>callback({"code":0});</script>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('charset=utf-8', 'charset=gbk'),
|
||||
html(`alert(1);${TRY_SETUP}${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}alert(1);${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}alert(1);`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('"h5.qzone.qq.com"', '"other.example.test"'),
|
||||
html(`${DOMAIN}document.domain="other.example.test";${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}_Callback({"code":0});`),
|
||||
html(`${TRY_SETUP}cb({"code":0});`),
|
||||
html(callback({ code: 0 })),
|
||||
html(`${DOMAIN}frameElement.callback((function(){return {"code":0};})());`),
|
||||
html(`${DOMAIN}frameElement.callback({"code":(0,1)});`),
|
||||
'<html><body>login required</body></html>',
|
||||
])('rejects unobserved HTML, setup and executable additions', (body) => {
|
||||
expect(() => parseQzoneWebResponse(body)).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0,"subcode":-1}',
|
||||
'{"code":0,"ret":-1}',
|
||||
'{"ret":0,"code":-1}',
|
||||
'{"ret":"0"}',
|
||||
'{"code":-1,"code":0}',
|
||||
'{"code":-1,"\\u0063ode":0}',
|
||||
'{"ret":-1,"ret":0}',
|
||||
'{"code":0,"data":{}};alert(1);frameElement.callback({"code":0}',
|
||||
'{"message":"success without numeric acknowledgement"}',
|
||||
])('retains strict success and duplicate-code checks inside HTML (%s)', (payload) => {
|
||||
expect(() => parseQzoneWebResponse(html(`${TRY_SETUP}frameElement.callback(${payload});`))).toThrow();
|
||||
});
|
||||
|
||||
test('never exposes callback contents when an HTML response cannot be parsed', () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback({"code":0,"private":"synthetic-private",broken});`));
|
||||
} catch (value) {
|
||||
error = value;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('QQ空间响应JSON格式异常');
|
||||
expect((error as Error).message).not.toContain('synthetic-private');
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0}',
|
||||
'frameElement.callback({"code":0});',
|
||||
'_Callback({"code":0});',
|
||||
'<script>frameElement.callback({"code":0});</script>',
|
||||
'frameElement.callback({"code":0});</script>',
|
||||
])('preserves the original accepted response envelopes (%s)', (body) => {
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneProfile } from '@/napcat-onebot/action/extends/qzone/GetQzoneProfile';
|
||||
import { SetQzoneProfile } from '@/napcat-onebot/action/extends/qzone/SetQzoneProfile';
|
||||
import { SetQzoneMsgRight } from '@/napcat-onebot/action/extends/qzone/SetQzoneMsgRight';
|
||||
import { SetQzoneBan } from '@/napcat-onebot/action/extends/qzone/SetQzoneBan';
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getProfile: vi.fn().mockResolvedValue({ name: '', description: '' }),
|
||||
setProfile: vi.fn().mockResolvedValue(undefined),
|
||||
setMsgRight: vi.fn().mockImplementation(async (_tid: string, right: number) => ({ ugc_right: right })),
|
||||
setBan: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { apis: { QzoneApi }, context: { logger: { logError: vi.fn() } } };
|
||||
}
|
||||
|
||||
describe('QQ空间资料和权限 actions', () => {
|
||||
it('reads the current profile by default and preserves large decimal target strings', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ target_uin: 10002 }, 'test', {} as never);
|
||||
await action.handle({ target_uin: '9007199254740993' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { name: '', description: '' } });
|
||||
expect(core.apis.QzoneApi.getProfile.mock.calls).toEqual([
|
||||
[undefined], ['10002'], ['9007199254740993'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('distinguishes omitted profile fields from explicit empty strings through handle parsing', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({ name: '' }, 'test', {} as never);
|
||||
await action.handle({ description: '' }, 'test', {} as never);
|
||||
await action.handle({ name: undefined, description: '说明' }, 'test', {} as never);
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
expect(core.apis.QzoneApi.setProfile.mock.calls).toEqual([
|
||||
[{ name: '' }], [{ description: '' }], [{ description: '说明' }],
|
||||
]);
|
||||
expect(Object.keys(core.apis.QzoneApi.setProfile.mock.calls[0]?.[0] ?? {})).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('normalizes supported profile scalars to strings without trimming content', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
await action.handle({ name: 0, description: false }, 'test', {} as never);
|
||||
await action.handle({ name: true, description: ' 保留空白\r\n ' }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setProfile.mock.calls).toEqual([
|
||||
[{ name: '0', description: 'false' }],
|
||||
[{ name: 'true', description: ' 保留空白\r\n ' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes right codes and target IDs before a single own-post permission request', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneMsgRight(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({
|
||||
tid: 'existing-tid',
|
||||
ugc_right: '16',
|
||||
target_uins: [10002, '9007199254740993', '10002'],
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setMsgRight).toHaveBeenCalledExactlyOnceWith(
|
||||
'existing-tid', 16, ['10002', '9007199254740993']
|
||||
);
|
||||
expect(result).toMatchObject({ status: 'ok', data: { ugc_right: 16 } });
|
||||
});
|
||||
|
||||
it('keeps enable false and serializes successful ban writes with explicit null data', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneBan(undefined as never, core as never);
|
||||
|
||||
const disabled = await action.handle({ user_id: '9007199254740993', enable: false }, 'test', {} as never);
|
||||
const enabled = await action.handle({ user_id: 10002 }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setBan.mock.calls).toEqual([
|
||||
['9007199254740993', false], ['10002', true],
|
||||
]);
|
||||
expect(JSON.parse(JSON.stringify(disabled))).toMatchObject({ status: 'ok', data: null });
|
||||
expect(JSON.parse(JSON.stringify(enabled))).toMatchObject({ status: 'ok', data: null });
|
||||
});
|
||||
|
||||
it('normalizes safe numeric permission-update IDs without accepting lossy values', async () => {
|
||||
const core = createCore();
|
||||
const result = await new SetQzoneMsgRight(undefined as never, core as never).handle({ tid: 0, ugc_right: '4' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { ugc_right: 4 } });
|
||||
expect(core.apis.QzoneApi.setMsgRight).toHaveBeenCalledExactlyOnceWith('0', 4, []);
|
||||
});
|
||||
|
||||
it('normalizes supported SnowLuma boolean spellings without treating arbitrary text as true', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneBan(undefined as never, core as never);
|
||||
for (const enable of ['false', '0', ' NO ', 'Off', 0]) {
|
||||
const result = await action.handle({ user_id: '10002', enable }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.setBan).toHaveBeenLastCalledWith('10002', false);
|
||||
}
|
||||
for (const enable of ['true', '1', ' YES ', 'On', 1]) {
|
||||
const result = await action.handle({ user_id: '10002', enable }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.setBan).toHaveBeenLastCalledWith('10002', true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed or lossy values before any core call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneProfile, { target_uin: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[GetQzoneProfile, { target_uin: false }],
|
||||
[GetQzoneProfile, { target_uin: 'bad-uin' }],
|
||||
[SetQzoneProfile, {}],
|
||||
[SetQzoneProfile, { name: undefined }],
|
||||
[SetQzoneProfile, { name: null }],
|
||||
[SetQzoneProfile, { name: ['text'] }],
|
||||
[SetQzoneProfile, { description: { content: 'text' } }],
|
||||
[SetQzoneProfile, { name: Infinity }],
|
||||
[SetQzoneProfile, { name: NaN }],
|
||||
[SetQzoneProfile, { name: 'x'.repeat(8193) }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 2 }],
|
||||
[SetQzoneMsgRight, { tid: '', ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: ' ', ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: true, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: Number.MAX_SAFE_INTEGER + 1, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: Infinity, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: true }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16 }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 128, target_uins: [] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: [Number.MAX_SAFE_INTEGER + 1] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: ['10002', false] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: '10002' }],
|
||||
[SetQzoneBan, { user_id: 0 }],
|
||||
[SetQzoneBan, { user_id: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: 'unknown' }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: 2 }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: null }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
for (const method of Object.values(core.apis.QzoneApi)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown parameters and current-account overrides before any core call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneProfile, { target_uin: '10002', unknown: true }],
|
||||
[SetQzoneProfile, { name: 'text', target_uin: '10002' }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 1, target_uin: '10002' }],
|
||||
[SetQzoneBan, { user_id: '10002', unknown: true }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
for (const method of Object.values(core.apis.QzoneApi)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a failed write without retrying or claiming null success', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setProfile.mockRejectedValue(new Error('QQ空间未明确确认资料修改成功'));
|
||||
const result = await new SetQzoneProfile(undefined as never, core as never).handle({ name: 'text' }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('未明确确认');
|
||||
expect(core.apis.QzoneApi.setProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has complete API metadata and schema-valid examples for all four actions', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneProfile(undefined as never, core as never),
|
||||
new SetQzoneProfile(undefined as never, core as never),
|
||||
new SetQzoneMsgRight(undefined as never, core as never),
|
||||
new SetQzoneBan(undefined as never, core as never),
|
||||
];
|
||||
|
||||
expect(actions.map(action => action.actionName)).toEqual([
|
||||
'get_qzone_profile', 'set_qzone_profile', 'set_qzone_msg_right', 'set_qzone_ban',
|
||||
]);
|
||||
for (const action of actions) {
|
||||
expect(action.actionSummary.length).toBeGreaterThan(0);
|
||||
expect(action.actionDescription.length).toBeGreaterThan(0);
|
||||
expect(action.actionTags).toContain('扩展接口');
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { QzoneFeedReferenceExample, QzoneParentCommentSchema, QzoneUserSchema } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { normalizeQzoneCommentPayload } from '@/napcat-onebot/action/extends/qzone/common-snowluma';
|
||||
import type { QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
function createCore () {
|
||||
return {
|
||||
apis: {
|
||||
QzoneApi: {
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'new-reply', comment: { id: 'new-reply' } }),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
function parentComment () {
|
||||
return {
|
||||
id: '0',
|
||||
user: { uin: '10002', uid: null, nick: '作者', portrait: { type: 1, opaque: 'keep' } },
|
||||
content: [{ type: 0, content: '原评论', extra: { keep: true } }],
|
||||
replys: [{ id: 'inline-reply', user: { uid: null }, opaque_reply: [false, 0] }],
|
||||
like: { id: 'raw-like-key', status: 0, count: 1 },
|
||||
opaque_parent: { keep: ['9007199254740993', null] },
|
||||
} satisfies QzoneComment;
|
||||
}
|
||||
|
||||
describe('Qzone native parent with nullable raw UID', () => {
|
||||
it('retains null UID and complete raw parent fields through the real handle parser', async () => {
|
||||
const core = createCore();
|
||||
const parent = parentComment();
|
||||
const snapshot = structuredClone(parent);
|
||||
const payload = { feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent };
|
||||
const expectedPayload = structuredClone(payload);
|
||||
|
||||
const normalized = normalizeQzoneCommentPayload(payload);
|
||||
expect(normalized.format).toBe('native');
|
||||
if (normalized.format === 'native') expect(normalized.comment).toBe(parent);
|
||||
const result = await new CommentQzone(undefined as never, core as never).handle(payload, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { comment_id: 'new-reply' } });
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '回复', snapshot, undefined);
|
||||
expect(core.apis.QzoneApi.comment.mock.calls[0]?.[2]).toHaveProperty('user.uid', null);
|
||||
expect(parent).toEqual(snapshot);
|
||||
expect(parent.user.uid).toBeNull();
|
||||
expect(payload).toEqual(expectedPayload);
|
||||
});
|
||||
|
||||
it('keeps a nullable raw parent separate from an explicit reply target', async () => {
|
||||
const core = createCore();
|
||||
const parent = parentComment();
|
||||
const snapshot = structuredClone(parent);
|
||||
const target = { uin: '10003', uid: 'u_target', nick: '接收者', extra: { keep: true } };
|
||||
const expectedTarget = structuredClone(target);
|
||||
|
||||
const result = await new CommentQzone(undefined as never, core as never).handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '指定回复', comment: parent, reply_user: target,
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '指定回复', snapshot, expectedTarget);
|
||||
expect(parent).toEqual(snapshot);
|
||||
expect(target).toEqual(expectedTarget);
|
||||
});
|
||||
|
||||
it('allows null only in raw parent UID without relaxing parent identity or explicit target validation', async () => {
|
||||
const parentSchema = TypeCompiler.Compile(QzoneParentCommentSchema);
|
||||
const userSchema = TypeCompiler.Compile(QzoneUserSchema);
|
||||
expect(parentSchema.Check(parentComment())).toBe(true);
|
||||
expect(userSchema.Check({ uin: '10002', uid: null })).toBe(false);
|
||||
const invalidParents = [
|
||||
{ id: '0', user: { uin: '10002', uid: false } },
|
||||
{ id: '0', user: { uin: '10002', uid: 0 } },
|
||||
{ id: '0', user: { uin: '10002', uid: {} } },
|
||||
{ id: 0, user: { uin: '10002', uid: null } },
|
||||
{ id: '0', user: { uid: null } },
|
||||
{ id: '0', user: { uin: Number.MAX_SAFE_INTEGER + 1, uid: null } },
|
||||
];
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
for (const comment of invalidParents) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: '回复', comment } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
const invalidTarget = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parentComment(), reply_user: { uin: '10003', uid: null },
|
||||
} as never, 'test', {} as never);
|
||||
expect(invalidTarget.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because they import each other.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import {
|
||||
CommentQzonePublicFeed,
|
||||
DeleteQzonePublicComment,
|
||||
DeleteQzonePublicReply,
|
||||
ForwardQzonePublicFeed,
|
||||
GetQzonePublicCapabilities,
|
||||
GetQzonePublicComments,
|
||||
GetQzonePublicFeedDetail,
|
||||
GetQzonePublicFeeds,
|
||||
GetQzonePublicReplies,
|
||||
ReplyQzonePublicFeed,
|
||||
SetQzonePublicCommentLike,
|
||||
SetQzonePublicFollow,
|
||||
SetQzonePublicLike,
|
||||
SetQzonePublicUpvote,
|
||||
} from '../napcat-onebot/action/extends/qzone-public';
|
||||
|
||||
const FEED = {
|
||||
cell_id: { id: 'feed-id', extra_id: 'raw-id' },
|
||||
cell_feed_transfer: { transfer: 'raw-transfer' },
|
||||
extra_feed: { nested: [1, true, null, { opaque: '001' }] },
|
||||
};
|
||||
const COMMENT = {
|
||||
id: 'comment-id',
|
||||
like: { id: 'comment-like-id', extra_like: true },
|
||||
reply_attach_info: 'reply-cursor',
|
||||
extra_comment: { opaque: '002' },
|
||||
};
|
||||
const REPLY = { id: 'reply-id', like: { id: 'reply-like-id' }, extra_reply: [false, 3] };
|
||||
const CONTENT = '保留评论正文';
|
||||
|
||||
const ACTION_CASES = [
|
||||
{ Action: GetQzonePublicCapabilities, name: 'get_qzone_public_capabilities', method: 'getCapabilities', payload: {}, args: [] },
|
||||
{ Action: GetQzonePublicFeeds, name: 'get_qzone_public_feeds', method: 'getFeeds', payload: { cursor: 'feeds-cursor' }, args: ['feeds-cursor'] },
|
||||
{ Action: GetQzonePublicFeedDetail, name: 'get_qzone_public_feed_detail', method: 'getFeedDetail', payload: { feed: FEED }, args: [FEED] },
|
||||
{ Action: GetQzonePublicComments, name: 'get_qzone_public_comments', method: 'getComments', payload: { feed: FEED, cursor: 'detail-cursor' }, args: [FEED, 'detail-cursor'] },
|
||||
{ Action: GetQzonePublicReplies, name: 'get_qzone_public_replies', method: 'getReplies', payload: { feed: FEED, comment: COMMENT, cursor: 'next-replies', page_size: '7' }, args: [FEED, COMMENT, 'next-replies', 7] },
|
||||
{ Action: SetQzonePublicUpvote, name: 'set_qzone_public_upvote', method: 'setUpvote', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicLike, name: 'set_qzone_public_like', method: 'setLike', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicFollow, name: 'set_qzone_public_follow', method: 'setFollow', payload: { feed: FEED, enable: false }, args: [FEED, false] },
|
||||
{ Action: SetQzonePublicCommentLike, name: 'set_qzone_public_comment_like', method: 'setCommentLike', payload: { feed: FEED, comment: COMMENT, reply: REPLY, enable: false }, args: [FEED, COMMENT, false, REPLY] },
|
||||
{ Action: CommentQzonePublicFeed, name: 'comment_qzone_public_feed', method: 'addComment', payload: { feed: FEED, content: CONTENT }, args: [FEED, CONTENT] },
|
||||
{ Action: ReplyQzonePublicFeed, name: 'reply_qzone_public_feed', method: 'addReply', payload: { feed: FEED, comment: COMMENT, content: CONTENT, reply: REPLY }, args: [FEED, COMMENT, CONTENT, REPLY] },
|
||||
{ Action: DeleteQzonePublicComment, name: 'delete_qzone_public_comment', method: 'deleteComment', payload: { feed: FEED, comment: COMMENT }, args: [FEED, COMMENT] },
|
||||
{ Action: DeleteQzonePublicReply, name: 'delete_qzone_public_reply', method: 'deleteReply', payload: { feed: FEED, comment: COMMENT, reply: REPLY }, args: [FEED, COMMENT, REPLY] },
|
||||
{ Action: ForwardQzonePublicFeed, name: 'forward_qzone_public_feed', method: 'forwardFeed', payload: { feed: FEED, reason: '转发理由' }, args: [FEED, '转发理由'] },
|
||||
] as const;
|
||||
|
||||
type ActionConstructor = typeof ACTION_CASES[number]['Action'];
|
||||
|
||||
function createAction (Action: ActionConstructor) {
|
||||
const nativeResult = {
|
||||
feeds: [FEED],
|
||||
has_more: true,
|
||||
next_cursor: 'next-feeds',
|
||||
extra_result: { untouched: [null, 1, false, '001'] },
|
||||
};
|
||||
const methods = Object.fromEntries(ACTION_CASES.map(({ method }) => [method, vi.fn().mockResolvedValue(nativeResult)]));
|
||||
const action = new Action(undefined as never, {
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
apis: { QzonePublicApi: methods },
|
||||
} as never);
|
||||
return { action, methods, nativeResult };
|
||||
}
|
||||
|
||||
describe('Qzone public OneBot actions', () => {
|
||||
test.each(ACTION_CASES)('$name delegates complete raw objects and returns the original JSON object', async ({ Action, name, method, payload, args }) => {
|
||||
const { action, methods, nativeResult } = createAction(Action);
|
||||
const response = await action.handle(structuredClone(payload) as never, 'test', {} as never);
|
||||
|
||||
expect(action.actionName).toBe(name);
|
||||
expect(response.status).toBe('ok');
|
||||
expect(response.data).toBe(nativeResult);
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...args);
|
||||
for (const [otherMethod, mock] of Object.entries(methods)) {
|
||||
if (otherMethod !== method) expect(mock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(ACTION_CASES)('$name provides compilable OpenAPI schemas and examples', ({ Action }) => {
|
||||
const { action, nativeResult } = createAction(Action);
|
||||
|
||||
expect(action.actionSummary).not.toBe('');
|
||||
expect(action.actionDescription).not.toBe('');
|
||||
expect(action.actionTags.length).toBeGreaterThan(0);
|
||||
expect(action.payloadSchema).toBeDefined();
|
||||
expect(action.returnSchema).toBeDefined();
|
||||
expect(action.payloadExample).toBeDefined();
|
||||
expect(action.returnExample).toBeDefined();
|
||||
expect(TypeCompiler.Compile(action.payloadSchema!).Check(action.payloadExample)).toBe(true);
|
||||
const checkResult = TypeCompiler.Compile(action.returnSchema!);
|
||||
expect(checkResult.Check(action.returnExample)).toBe(true);
|
||||
expect(checkResult.Check(nativeResult)).toBe(true);
|
||||
});
|
||||
|
||||
test('reply pagination uses the SL empty cursor default for parent-cursor fallback', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
|
||||
await action.handle({ feed: FEED, comment: COMMENT } as never, 'test', {} as never);
|
||||
await action.handle({ feed: FEED, comment: COMMENT, cursor: '' } as never, 'test', {} as never);
|
||||
|
||||
expect(methods['getReplies']).toHaveBeenNthCalledWith(1, FEED, COMMENT, '', 20);
|
||||
expect(methods['getReplies']).toHaveBeenNthCalledWith(2, FEED, COMMENT, '', 20);
|
||||
});
|
||||
|
||||
test('comment pagination requires a non-empty detail cursor', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicComments);
|
||||
|
||||
const missing = await action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
expect(missing).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getComments']).not.toHaveBeenCalled();
|
||||
|
||||
const empty = await action.handle({ feed: FEED, cursor: '' } as never, 'test', {} as never);
|
||||
expect(empty).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getComments']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ value: true, expected: true },
|
||||
{ value: false, expected: false },
|
||||
{ value: 1, expected: true },
|
||||
{ value: 0, expected: false },
|
||||
{ value: 'true', expected: true },
|
||||
{ value: 'false', expected: false },
|
||||
{ value: '1', expected: true },
|
||||
{ value: '0', expected: false },
|
||||
{ value: ' TRUE ', expected: true },
|
||||
{ value: ' FALSE ', expected: false },
|
||||
{ value: 'yes', expected: true },
|
||||
{ value: 'no', expected: false },
|
||||
{ value: ' ON ', expected: true },
|
||||
{ value: ' OFF ', expected: false },
|
||||
{ value: 2, expected: true },
|
||||
{ value: -1, expected: true },
|
||||
{ value: 0.1, expected: true },
|
||||
])('normalizes enable=$value without merging upvote, like and follow', async ({ value, expected }) => {
|
||||
for (const { Action, method } of ACTION_CASES.filter(({ method }) => ['setUpvote', 'setLike', 'setFollow', 'setCommentLike'].includes(method))) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, enable: value } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'setCommentLike' ? [FEED, COMMENT, expected, undefined] : [FEED, expected]));
|
||||
}
|
||||
});
|
||||
|
||||
test('uses documented defaults for optional mutation parameters', async () => {
|
||||
for (const { Action, method } of ACTION_CASES.filter(({ method }) => ['setUpvote', 'setLike', 'setFollow', 'setCommentLike'].includes(method))) {
|
||||
const { action, methods } = createAction(Action);
|
||||
await action.handle({ feed: FEED, comment: COMMENT } as never, 'test', {} as never);
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'setCommentLike' ? [FEED, COMMENT, true, undefined] : [FEED, true]));
|
||||
}
|
||||
|
||||
const forward = createAction(ForwardQzonePublicFeed);
|
||||
await forward.action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
expect(forward.methods['forwardFeed']).toHaveBeenCalledExactlyOnceWith(FEED, '');
|
||||
|
||||
const reply = createAction(ReplyQzonePublicFeed);
|
||||
await reply.action.handle({ feed: FEED, comment: COMMENT, content: CONTENT } as never, 'test', {} as never);
|
||||
expect(reply.methods['addReply']).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, CONTENT, undefined);
|
||||
});
|
||||
|
||||
test.each(['maybe', '', '2', null, {}, []].map(enable => ({ enable })))('rejects unsupported enable=$enable', async ({ enable }) => {
|
||||
const { action, methods } = createAction(SetQzonePublicLike);
|
||||
const response = await action.handle({ feed: FEED, enable } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['setLike']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pageSize: 1, expected: 1 },
|
||||
{ pageSize: '1', expected: 1 },
|
||||
{ pageSize: 20, expected: 20 },
|
||||
{ pageSize: '37', expected: 37 },
|
||||
{ pageSize: 1.5, expected: 1 },
|
||||
{ pageSize: '1.9', expected: 1 },
|
||||
{ pageSize: ' 2e1 ', expected: 20 },
|
||||
{ pageSize: '0x10', expected: 16 },
|
||||
{ pageSize: Number.MAX_SAFE_INTEGER + 1, expected: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])('normalizes SL-compatible page_size=$pageSize to $expected', async ({ pageSize, expected }) => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, page_size: pageSize } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods['getReplies']).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, '', expected);
|
||||
});
|
||||
|
||||
test.each([0, -1, 0.9, '', ' ', '0', '-1', 'abc', true, null, Infinity, 'Infinity'])('rejects invalid page_size=%j', async (pageSize) => {
|
||||
const { action, methods } = createAction(GetQzonePublicReplies);
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, page_size: pageSize } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['getReplies']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([CommentQzonePublicFeed, ReplyQzonePublicFeed])('preserves arbitrary rich content objects', async (Action) => {
|
||||
const { action, methods } = createAction(Action);
|
||||
const content = [{ type: 0, content: '正文', unknown: { id: '001', list: [null, false] } }, { type: 1, uid: '123', extra: ['a'] }];
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
const method = Action === CommentQzonePublicFeed ? 'addComment' : 'addReply';
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'addComment' ? [FEED, content] : [FEED, COMMENT, content, undefined]));
|
||||
});
|
||||
|
||||
test.each(['', '[]', '[1]', 123, false, null, {}, [], ['text'], [1], [null], [[]]].map(content => ({ content })))('rejects invalid rich content=$content before conversion', async ({ content }) => {
|
||||
const { action, methods } = createAction(CommentQzonePublicFeed);
|
||||
const response = await action.handle({ feed: FEED, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['addComment']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([CommentQzonePublicFeed, ReplyQzonePublicFeed])('normalizes JSON-array content strings using the SL consumer contract', async (Action) => {
|
||||
const { action, methods } = createAction(Action);
|
||||
const content = [{ type: 0, content: '正文', future: { opaque: '001' } }];
|
||||
const method = Action === CommentQzonePublicFeed ? 'addComment' : 'addReply';
|
||||
const response = await action.handle({ feed: FEED, comment: COMMENT, content: ` ${JSON.stringify(content)} ` } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...(method === 'addComment' ? [FEED, content] : [FEED, COMMENT, content, undefined]));
|
||||
});
|
||||
|
||||
test.each([' ', '[invalid]'])('preserves non-empty SL text content %j', async (content) => {
|
||||
const { action, methods } = createAction(CommentQzonePublicFeed);
|
||||
const response = await action.handle({ feed: FEED, content } as never, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods['addComment']).toHaveBeenCalledExactlyOnceWith(FEED, content);
|
||||
});
|
||||
|
||||
test('feed cursors are optional, non-empty and limited to 65536 characters', async () => {
|
||||
const { action, methods } = createAction(GetQzonePublicFeeds);
|
||||
const first = await action.handle({} as never, 'test', {} as never);
|
||||
expect(first.status).toBe('ok');
|
||||
expect(methods['getFeeds']).toHaveBeenCalledExactlyOnceWith(undefined);
|
||||
methods['getFeeds']!.mockClear();
|
||||
|
||||
for (const cursor of ['', 'a'.repeat(65537)]) {
|
||||
const response = await action.handle({ cursor } as never, 'test', {} as never);
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
}
|
||||
expect(methods['getFeeds']).not.toHaveBeenCalled();
|
||||
const cursor = 'a'.repeat(65536);
|
||||
expect((await action.handle({ cursor } as never, 'test', {} as never)).status).toBe('ok');
|
||||
expect(methods['getFeeds']).toHaveBeenCalledExactlyOnceWith(cursor);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ Action: GetQzonePublicFeeds, method: 'getFeeds', field: 'cursor', payload: {}, args: [] },
|
||||
{ Action: GetQzonePublicComments, method: 'getComments', field: 'cursor', payload: { feed: FEED }, args: [FEED] },
|
||||
{ Action: GetQzonePublicReplies, method: 'getReplies', field: 'cursor', payload: { feed: FEED, comment: COMMENT }, args: [FEED, COMMENT] },
|
||||
{ Action: ForwardQzonePublicFeed, method: 'forwardFeed', field: 'reason', payload: { feed: FEED }, args: [FEED] },
|
||||
])('$method uses SL string coercion for $field', async ({ Action, method, field, payload, args }) => {
|
||||
for (const value of [42, false]) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ ...payload, [field]: value } as never, 'test', {} as never);
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods[method]).toHaveBeenCalledExactlyOnceWith(...args, String(value), ...(method === 'getReplies' ? [20] : []));
|
||||
}
|
||||
for (const value of [null, {}, []]) {
|
||||
const { action, methods } = createAction(Action);
|
||||
const response = await action.handle({ ...payload, [field]: value } as never, 'test', {} as never);
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods[method]).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ feed: null, comment: COMMENT, reply: REPLY },
|
||||
{ feed: [], comment: COMMENT, reply: REPLY },
|
||||
{ feed: 'feed-id', comment: COMMENT, reply: REPLY },
|
||||
{ feed: FEED, comment: null, reply: REPLY },
|
||||
{ feed: FEED, comment: 'comment-id', reply: REPLY },
|
||||
{ feed: FEED, comment: COMMENT, reply: [] },
|
||||
{ feed: FEED, comment: COMMENT, reply: 'reply-id' },
|
||||
])('requires raw objects for feed, comment and reply: %j', async (payload) => {
|
||||
const { action, methods } = createAction(DeleteQzonePublicReply);
|
||||
const response = await action.handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods['deleteReply']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('WebSocket validation preserves echo and prevents writes', async () => {
|
||||
const { action, methods } = createAction(DeleteQzonePublicReply);
|
||||
const response = await action.websocketHandle({ feed: FEED, comment: COMMENT } as never, 'public-echo', 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 1400, echo: 'public-echo' });
|
||||
expect(methods['deleteReply']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reports core failures through the existing OneBot error response', async () => {
|
||||
const { action, methods } = createAction(SetQzonePublicUpvote);
|
||||
methods['setUpvote']!.mockRejectedValue(new Error('public interaction rejected'));
|
||||
const response = await action.handle({ feed: FEED } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', message: 'public interaction rejected' });
|
||||
expect(methods['setUpvote']).toHaveBeenCalledExactlyOnceWith(FEED, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import { NTQQQzonePublicApi } from '@/napcat-core/apis/qzone-public';
|
||||
|
||||
const feed = {
|
||||
cell_common: { union_id: { uf_key: 'synthetic-public-feed', opaque: { preserve: true } }, domain_type: 3, feed_type: 1, cur_like_key: 'synthetic-feed-like' },
|
||||
cell_feed_transfer: { opaque: 'synthetic-transfer' },
|
||||
cell_user_info: { user: { uin: '20002', uid: 'u_author', nick: 'Synthetic author' } },
|
||||
cell_summary: { summary: [{ type: 0, content: 'Synthetic public feed' }] },
|
||||
};
|
||||
const comment = { id: 'synthetic-comment', user: { uin: '10001', uid: null, nick: 'Synthetic' }, like: { id: 'synthetic-comment-like' }, reply_attach_info: 'parent-reply-cursor' };
|
||||
const reply = { id: 'synthetic-reply', user: { uin: '10001', uid: 'u_self', nick: 'Synthetic' }, like: { id: 'synthetic-reply-like' } };
|
||||
|
||||
function setup () {
|
||||
const user = { uin: '10001', uid: 'u_self', nick: 'Synthetic' };
|
||||
const userApi = {
|
||||
getSKey: vi.fn().mockResolvedValue('synthetic-skey'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ domainPskeyMap: new Map([['qzone.qq.com', 'synthetic-pskey']]) }),
|
||||
};
|
||||
const core = { selfInfo: user, apis: { UserApi: userApi } } as unknown as NapCatCore;
|
||||
const fetchMock = vi.fn<typeof fetch>().mockImplementation(async () => new Response(JSON.stringify({
|
||||
retcode: 0, data: { like: true, upvote: true, comment: { id: 'new' }, reply: { id: 'new-reply' }, ret_code: 0 },
|
||||
})));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return { api: new NTQQQzonePublicApi({} as InstanceContext, core), core, user, userApi, fetchMock };
|
||||
}
|
||||
|
||||
function response (data: Record<string, unknown>) {
|
||||
return new Response(JSON.stringify({ retcode: 0, data }));
|
||||
}
|
||||
|
||||
function requestBody (fetchMock: ReturnType<typeof setup>['fetchMock'], index = 0) {
|
||||
return JSON.parse(fetchMock.mock.calls[index]![1]!.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
|
||||
describe('public Qzone HTTP integration', () => {
|
||||
test('uses scoped native tickets and the fixed JSON gateway and metadata', async () => {
|
||||
const { api, userApi, fetchMock } = setup();
|
||||
await api.getCapabilities();
|
||||
expect(userApi.getPSkey).toHaveBeenCalledWith(['qzone.qq.com']);
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toMatch(/^https:\/\/h5\.qzone\.qq\.com\/http2rpc\/gotrpc\/v1\/trpc\.public_qzone\.entrance_ctrl\.EntranceCtrl\/BatchEntranceWhitelist\?g_tk=\d+$/);
|
||||
expect(options).toMatchObject({ method: 'POST', redirect: 'error', signal: expect.any(AbortSignal) });
|
||||
const headers = options!.headers as Record<string, string>;
|
||||
expect(headers['x-web-qua']).toBe('V1_HT5_QZ_9.9.32_001_IDC_B');
|
||||
expect(JSON.parse(headers['x-oidb']!)).toEqual({ uint32_command: '0x9741', uint32_service_type: '0' });
|
||||
expect(headers['Cookie']).toContain('p_skey=synthetic-pskey');
|
||||
expect(requestBody(fetchMock)).toEqual({ uins: ['10001'], entrance_types: [1, 15] });
|
||||
});
|
||||
|
||||
test('keeps page metadata and full identities and terminates only on has_more', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true, attach_info: 'attach-one', bottom_title: { title: 'More' } }));
|
||||
const first = await api.getFeeds();
|
||||
expect(first).toMatchObject({ feeds: [feed], has_more: true, bottom_title: { title: 'More' } });
|
||||
expect(first.next_cursor).toBeTypeOf('string');
|
||||
const initial = requestBody(fetchMock);
|
||||
expect(initial['refresh_id']).toMatch(/^10001_\d+$/);
|
||||
expect(initial).not.toHaveProperty('attach_info');
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false, attach_info: '', extra: true }));
|
||||
expect(await api.getFeeds(first.next_cursor!)).toEqual({ feeds: [], has_more: false, attach_info: '', extra: true, next_cursor: null });
|
||||
expect(requestBody(fetchMock, 1)).toEqual({ refresh_id: initial['refresh_id'], attach_info: 'attach-one', last_client_feed_union_id: feed.cell_common.union_id });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('an ad-only page retains the last non-ad full union object', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true, attach_info: 'one' }));
|
||||
const first = await api.getFeeds();
|
||||
const ad = { cell_common: { feed_type: 3, union_id: { uf_key: 'ad' } } };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed: ad }], has_more: true, attach_info: 'two' }));
|
||||
const second = await api.getFeeds(first.next_cursor!);
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false }));
|
||||
await api.getFeeds(second.next_cursor!);
|
||||
expect(requestBody(fetchMock, 2)['last_client_feed_union_id']).toEqual(feed.cell_common.union_id);
|
||||
});
|
||||
|
||||
test.each([8193, 65536])('continues an issued cursor with %s characters of upstream attach_info', async (length) => {
|
||||
const { api, fetchMock } = setup();
|
||||
const attach = 'x'.repeat(length);
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true, attach_info: attach }));
|
||||
const first = await api.getFeeds();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false }));
|
||||
await api.getFeeds(first.next_cursor!);
|
||||
expect(requestBody(fetchMock, 1)['attach_info']).toBe(attach);
|
||||
});
|
||||
|
||||
test('returned feed mutation cannot alter a stored continuation identity', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed: structuredClone(feed) }], has_more: true, attach_info: 'one' }));
|
||||
const first = await api.getFeeds();
|
||||
(first.feeds[0]!['cell_common'] as Record<string, unknown>)['union_id'] = { uf_key: 'changed-by-caller' };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false }));
|
||||
await api.getFeeds(first.next_cursor!);
|
||||
expect(requestBody(fetchMock, 1)['last_client_feed_union_id']).toEqual(feed.cell_common.union_id);
|
||||
});
|
||||
|
||||
test('rejects cross-account and expired cursors before fetching', async () => {
|
||||
const { api, user, fetchMock } = setup();
|
||||
let now = 1700000000000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true, attach_info: 'one' }));
|
||||
const first = await api.getFeeds();
|
||||
user.uid = 'other-session';
|
||||
await expect(api.getFeeds(first.next_cursor!)).rejects.toThrow('游标');
|
||||
user.uid = 'u_self';
|
||||
now += 10 * 60 * 1000 + 1;
|
||||
await expect(api.getFeeds(first.next_cursor!)).rejects.toThrow('游标');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('aborts before effects when the account switches while tickets are being read', async () => {
|
||||
const { api, user, userApi, fetchMock } = setup();
|
||||
userApi.getPSkey.mockImplementationOnce(async () => {
|
||||
user.uin = '30003';
|
||||
return { domainPskeyMap: new Map([['qzone.qq.com', 'synthetic-pskey']]) };
|
||||
});
|
||||
await expect(api.setUpvote(feed)).rejects.toThrow('切换');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects malformed write input before ticket or network activity', async () => {
|
||||
const { api, userApi, fetchMock } = setup();
|
||||
await expect(api.addComment({}, 'text')).rejects.toThrow();
|
||||
expect(userApi.getSKey).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['upvote', (api: NTQQQzonePublicApi) => api.setUpvote(feed), 'UFOComment/Upvote'],
|
||||
['downvote', (api: NTQQQzonePublicApi) => api.setUpvote(feed, false), 'UFOComment/Downvote'],
|
||||
['like', (api: NTQQQzonePublicApi) => api.setLike(feed), 'InteractionWriter/DoLike'],
|
||||
['unlike', (api: NTQQQzonePublicApi) => api.setLike(feed, false), 'InteractionWriter/UnLike'],
|
||||
['follow', (api: NTQQQzonePublicApi) => api.setFollow(feed), 'RelationService/Follow'],
|
||||
['unfollow', (api: NTQQQzonePublicApi) => api.setFollow(feed, false), 'RelationService/Unfollow'],
|
||||
['comment like', (api: NTQQQzonePublicApi) => api.setCommentLike(feed, comment), 'InteractionWriter/DoLike'],
|
||||
['reply unlike', (api: NTQQQzonePublicApi) => api.setCommentLike(feed, comment, false, reply), 'InteractionWriter/UnLike'],
|
||||
['comment', (api: NTQQQzonePublicApi) => api.addComment(feed, 'Comment'), 'InteractionWriter/AddComment'],
|
||||
['reply', (api: NTQQQzonePublicApi) => api.addReply(feed, comment, 'Reply', reply), 'InteractionWriter/AddReply'],
|
||||
['delete comment', (api: NTQQQzonePublicApi) => api.deleteComment(feed, comment), 'InteractionWriter/DeleteComment'],
|
||||
['delete reply', (api: NTQQQzonePublicApi) => api.deleteReply(feed, comment, reply), 'InteractionWriter/DeleteReply'],
|
||||
['forward', (api: NTQQQzonePublicApi) => api.forwardFeed(feed, 'Reason'), 'FeedsWriter/ForwardFeed'],
|
||||
])('dispatches %s once using its own business route', async (_label, invoke, suffix) => {
|
||||
const { api, fetchMock } = setup();
|
||||
await invoke(api);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain(`.${suffix}?`);
|
||||
});
|
||||
|
||||
test('keeps reply cursor defaults and explicit page_size and raw null-UID parent', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
await api.getReplies(feed, comment, undefined, 7);
|
||||
expect(requestBody(fetchMock)).toMatchObject({ cur_comment: comment, reply_attach_info: 'parent-reply-cursor', page_size: 7 });
|
||||
await api.getReplies(feed, comment, '', 20);
|
||||
expect(requestBody(fetchMock, 1)).toMatchObject({ cur_comment: comment, reply_attach_info: 'parent-reply-cursor', page_size: 20 });
|
||||
expect(comment.user.uid).toBeNull();
|
||||
});
|
||||
|
||||
test('does not retry or expose ticket values on HTTP, JSON or business failure', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
fetchMock.mockResolvedValueOnce(new Response('private ticket=synthetic-pskey', { status: 403 }));
|
||||
await expect(api.setLike(feed)).rejects.toThrow('勿自动重试');
|
||||
fetchMock.mockResolvedValueOnce(new Response('synthetic-pskey'));
|
||||
await expect(api.addComment(feed, 'Text')).rejects.toThrow('勿自动重试');
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ retcode: -1, message: 'synthetic-pskey' })));
|
||||
await expect(api.setFollow(feed)).rejects.not.toThrow('synthetic-pskey');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('bounds response streams and cancels oversized bodies', async () => {
|
||||
const { api, fetchMock } = setup();
|
||||
const cancel = vi.fn();
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start (controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)); },
|
||||
cancel,
|
||||
})));
|
||||
await expect(api.getCapabilities()).rejects.toThrow('请求失败');
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzonePublicRequest,
|
||||
parseQzonePublicResponse,
|
||||
QZONE_PUBLIC_ROUTES,
|
||||
type QzonePublicObject,
|
||||
type QzonePublicOperation,
|
||||
} from '@/napcat-core/data/qzone-public';
|
||||
|
||||
function feed (): QzonePublicObject {
|
||||
return {
|
||||
cell_common: {
|
||||
union_id: { uf_key: 'synthetic-feed', ugc_id: 'synthetic-ugc', auxiliary: { keep: 0 } },
|
||||
cur_like_key: 'feed-like',
|
||||
domain_type: 3,
|
||||
},
|
||||
cell_user_info: { user: { uin: '10002', uid: 'u_author', nick: 'Synthetic author' } },
|
||||
cell_feed_transfer: { opaque: { token: 'synthetic-context', flags: [false, 0, null] } },
|
||||
cell_summary: { summary: [{ type: 0, content: 'Synthetic content' }] },
|
||||
};
|
||||
}
|
||||
|
||||
function parent (): QzonePublicObject {
|
||||
return {
|
||||
id: 0,
|
||||
user: { uin: '10003', uid: null, nick: 'Synthetic parent' },
|
||||
content: [{ type: 0, content: 'Original parent' }],
|
||||
like: { id: 'parent-like', count: 4, status: 0 },
|
||||
reply_attach_info: 'parent-replies',
|
||||
has_more_reply: true,
|
||||
future: { zero: 0, nullable: null, flags: [true, false] },
|
||||
};
|
||||
}
|
||||
|
||||
function reply (): QzonePublicObject {
|
||||
return { id: 'reply-0', user: { uid: 'u_recipient', uin: '', nick: 'Synthetic recipient' }, like: { id: 'reply-like' }, future: { retain: true } };
|
||||
}
|
||||
|
||||
const currentUser = { uin: '10001', uid: 'u_current', nick: 'Synthetic current' };
|
||||
|
||||
describe('QQ public Qzone fixed protocol routes', () => {
|
||||
test('binds all operations to known service methods and OIDB hints', () => {
|
||||
expect(Object.keys(QZONE_PUBLIC_ROUTES)).toHaveLength(18);
|
||||
expect(QZONE_PUBLIC_ROUTES.capabilities).toEqual({ service: 'trpc.public_qzone.entrance_ctrl.EntranceCtrl', method: 'BatchEntranceWhitelist', oidb: '0x9741_0' });
|
||||
expect(QZONE_PUBLIC_ROUTES.feeds).toEqual({ service: 'trpc.qzone.feeds_reader.FeedsReader', method: 'GetPublicRecomFeeds', oidb: '0x9a38_0' });
|
||||
expect(QZONE_PUBLIC_ROUTES.follow).toEqual({ service: 'trpc.public_qzone.relation.RelationService', method: 'Follow', oidb: '0x9d1a_1' });
|
||||
expect(QZONE_PUBLIC_ROUTES.unfollow).toEqual({ service: 'trpc.public_qzone.relation.RelationService', method: 'Unfollow', oidb: '0x9d1b_1' });
|
||||
expect(QZONE_PUBLIC_ROUTES.commentLike).toEqual(QZONE_PUBLIC_ROUTES.like);
|
||||
expect(QZONE_PUBLIC_ROUTES.commentUnlike).toEqual(QZONE_PUBLIC_ROUTES.unlike);
|
||||
expect(QZONE_PUBLIC_ROUTES.deleteComment).toMatchObject({ method: 'DeleteComment', oidb: '0x9989_0' });
|
||||
expect(QZONE_PUBLIC_ROUTES.deleteReply).toMatchObject({ method: 'DeleteReply', oidb: '0x998a_0' });
|
||||
expect(QZONE_PUBLIC_ROUTES.forward).toEqual({ service: 'trpc.qzone.feeds_writer.FeedsWriter', method: 'ForwardFeed', oidb: '0x969a_1' });
|
||||
expect(Object.isFrozen(QZONE_PUBLIC_ROUTES)).toBe(true);
|
||||
expect(Object.values(QZONE_PUBLIC_ROUTES).every(Object.isFrozen)).toBe(true);
|
||||
expect(() => buildQzonePublicRequest('__proto__' as QzonePublicOperation, {})).toThrow();
|
||||
});
|
||||
|
||||
test('builds entrance and recommendation requests without a tid-derived union', () => {
|
||||
expect(buildQzonePublicRequest('capabilities', { selfUin: '10001' })).toEqual({ uins: ['10001'], entrance_types: [1, 15] });
|
||||
expect(buildQzonePublicRequest('feeds', { refreshId: '10001_1700000000' })).toEqual({ refresh_id: '10001_1700000000' });
|
||||
const last = { uf_key: 'last-real-feed', future: { preserve: true } };
|
||||
expect(buildQzonePublicRequest('feeds', { refreshId: 'r', cursor: 'opaque-attach', lastUnionId: last }))
|
||||
.toEqual({ refresh_id: 'r', attach_info: 'opaque-attach', last_client_feed_union_id: last });
|
||||
expect(() => buildQzonePublicRequest('feeds', { refreshId: 'r', lastUnionId: { tid: 'legacy-tid' } })).toThrow('身份');
|
||||
expect(() => buildQzonePublicRequest('capabilities', { selfUin: 10001 as never })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QQ public Qzone request business data', () => {
|
||||
test('builds public detail and comment continuation with full context', () => {
|
||||
const source = feed();
|
||||
const common = source['cell_common'] as QzonePublicObject;
|
||||
expect(buildQzonePublicRequest('detail', { feed: source })).toEqual({
|
||||
union_id: common['union_id'], cell_feed_transfer: source['cell_feed_transfer'], feed_action_domain_type: 3, from_page: 1,
|
||||
});
|
||||
expect(buildQzonePublicRequest('comments', { feed: source, cursor: 'next-comments' })).toEqual({
|
||||
union_id: common['union_id'], cell_feed_transfer: source['cell_feed_transfer'], attach_info: 'next-comments',
|
||||
});
|
||||
for (const cursor of [undefined, '']) expect(() => buildQzonePublicRequest('comments', { feed: source, cursor })).toThrow();
|
||||
expect(() => buildQzonePublicRequest('detail', { feed: { union_id: { uf_key: 'reference-only' } } })).toThrow('cell_common');
|
||||
});
|
||||
|
||||
test('replies retain the whole parent and honor fallback cursor and page size compatibility', () => {
|
||||
const source = feed();
|
||||
const comment = parent();
|
||||
const first = buildQzonePublicRequest('replies', { feed: source, comment });
|
||||
expect(first).toMatchObject({ cur_comment: comment, comment_attach_info: '', reply_attach_info: 'parent-replies', page_size: 20 });
|
||||
expect(first['cur_comment']).not.toBe(comment);
|
||||
expect((first['cur_comment'] as QzonePublicObject)['id']).toBe(0);
|
||||
expect(((first['cur_comment'] as QzonePublicObject)['user'] as QzonePublicObject)['uid']).toBeNull();
|
||||
expect(buildQzonePublicRequest('replies', { feed: source, comment, cursor: '', pageSize: 7.9 }))
|
||||
.toMatchObject({ reply_attach_info: 'parent-replies', page_size: 7 });
|
||||
expect(buildQzonePublicRequest('replies', { feed: source, comment, cursor: 'next-replies', pageSize: Number.MAX_SAFE_INTEGER + 1 }))
|
||||
.toMatchObject({ reply_attach_info: 'next-replies', page_size: Number.MAX_SAFE_INTEGER + 1 });
|
||||
expect(buildQzonePublicRequest('replies', { feed: source, comment: { id: 'parent-without-user' } }))
|
||||
.toMatchObject({ cur_comment: { id: 'parent-without-user' }, reply_attach_info: '' });
|
||||
for (const pageSize of [0, -1, NaN, Infinity, true, null]) {
|
||||
expect(() => buildQzonePublicRequest('replies', { feed: source, comment, pageSize: pageSize as number })).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('distinguishes feed, parent-comment and reply like contexts', () => {
|
||||
for (const operation of ['like', 'unlike'] as const) {
|
||||
expect(buildQzonePublicRequest(operation, { feed: feed() })).toMatchObject({ like_key: 'feed-like', like_type: 1 });
|
||||
}
|
||||
for (const operation of ['commentLike', 'commentUnlike'] as const) {
|
||||
expect(buildQzonePublicRequest(operation, { feed: feed(), comment: parent() })).toMatchObject({ like_key: 'parent-like', like_type: 2 });
|
||||
const body = buildQzonePublicRequest(operation, { feed: feed(), comment: parent(), reply: reply() });
|
||||
expect(body).toMatchObject({ like_key: 'reply-like', like_type: 3 });
|
||||
expect(body).not.toHaveProperty('comment');
|
||||
expect(body).not.toHaveProperty('reply');
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ domain: 3, author: { uin: '10002', uid: 'u_author' }, identity: { target_uid: 'u_author' } },
|
||||
{ domain: 3, author: { uin: '10002', uid: null }, identity: { target_uin: '10002' } },
|
||||
{ domain: 1, author: { uin: '10002', uid: 'u_author' }, identity: { target_uin: '10002' } },
|
||||
{ domain: 1, author: { uin: '', uid: 'u_author' }, identity: { target_uid: 'u_author' } },
|
||||
{ domain: 3, author: { uin: '0', uid: 'u_author' }, identity: { target_uid: 'u_author' } },
|
||||
{ domain: 3, author: { uin: 0, uid: 'u_author' }, identity: { target_uid: 'u_author' } },
|
||||
])('uses the observed feed follow identity rule ($domain/$author)', ({ domain, author, identity }) => {
|
||||
const source = feed();
|
||||
(source['cell_common'] as QzonePublicObject)['domain_type'] = domain;
|
||||
source['cell_user_info'] = { user: author };
|
||||
for (const operation of ['follow', 'unfollow'] as const) {
|
||||
expect(buildQzonePublicRequest(operation, { feed: source })).toEqual({
|
||||
follow_from_type: 1, ...identity, follow_feed_id: 'synthetic-feed', cell_feed_transfer: source['cell_feed_transfer'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects missing follow authors and does not substitute the ugc ID for follow_feed_id', () => {
|
||||
const source = feed();
|
||||
(source['cell_common'] as QzonePublicObject)['union_id'] = { ugc_id: 'ugc-only' };
|
||||
expect(buildQzonePublicRequest('follow', { feed: source })).not.toHaveProperty('follow_feed_id');
|
||||
source['cell_user_info'] = { user: { uin: '', uid: null } };
|
||||
expect(() => buildQzonePublicRequest('follow', { feed: source })).toThrow();
|
||||
});
|
||||
|
||||
test('builds comments from text or raw rich items and snapshots every preserved field', () => {
|
||||
const source = feed();
|
||||
const rich = [{ type: 0, content: ' text ', future: { color: 'blue' } }, { type: 1, uin: '10004', name: 'Mention', opaque: [0, false] }];
|
||||
const actor = { ...currentUser };
|
||||
const body = buildQzonePublicRequest('addComment', { feed: source, content: rich, user: actor });
|
||||
expect(body['comment']).toEqual({ user: currentUser, content: rich });
|
||||
(rich[0]!.future as QzonePublicObject)['color'] = 'changed';
|
||||
(source['cell_feed_transfer'] as QzonePublicObject)['opaque'] = 'changed';
|
||||
actor.nick = 'Changed after request';
|
||||
expect(((body['comment'] as QzonePublicObject)['content'] as QzonePublicObject[])[0]).toHaveProperty('future.color', 'blue');
|
||||
expect(body['cell_feed_transfer']).toHaveProperty('opaque.token', 'synthetic-context');
|
||||
expect(body['comment']).toHaveProperty('user.nick', 'Synthetic current');
|
||||
expect(buildQzonePublicRequest('addComment', { feed: feed(), content: ' \n ', user: currentUser }))
|
||||
.toHaveProperty('comment.content', [{ type: 0, content: ' \n ' }]);
|
||||
const unknownRich = [{}, { type: 'unknown', content: 42, future: { preserve: true } }];
|
||||
expect(buildQzonePublicRequest('addComment', { feed: feed(), content: unknownRich, user: currentUser }))
|
||||
.toHaveProperty('comment.content', unknownRich);
|
||||
});
|
||||
|
||||
test('targets the parent or selected reply author while preserving nullable UID and raw ID zero', () => {
|
||||
const comment = parent();
|
||||
const parentReply = buildQzonePublicRequest('addReply', { feed: feed(), comment, content: 'reply', user: currentUser });
|
||||
expect(parentReply).toHaveProperty('comment.id', 0);
|
||||
expect(parentReply).toHaveProperty('reply.target_user', comment['user']);
|
||||
expect(parentReply).toHaveProperty('reply.target_user.uid', null);
|
||||
const selected = reply();
|
||||
expect(buildQzonePublicRequest('addReply', { feed: feed(), comment, reply: selected, content: [{ type: 0, content: 'reply' }], user: currentUser }))
|
||||
.toHaveProperty('reply.target_user', selected['user']);
|
||||
expect(comment).not.toHaveProperty('reply');
|
||||
});
|
||||
|
||||
test('deletes using full original parent and reply snapshots', () => {
|
||||
const comment = parent();
|
||||
const selected = reply();
|
||||
expect(buildQzonePublicRequest('deleteComment', { feed: feed(), comment })).toHaveProperty('comment', comment);
|
||||
const body = buildQzonePublicRequest('deleteReply', { feed: feed(), comment, reply: selected });
|
||||
expect(body).toHaveProperty('comment', comment);
|
||||
expect(body).toHaveProperty('reply', selected);
|
||||
expect(body['comment']).not.toBe(comment);
|
||||
expect(body['reply']).not.toBe(selected);
|
||||
});
|
||||
|
||||
test('forwards with an empty or exact text reason and no implicit second write', () => {
|
||||
expect(buildQzonePublicRequest('forward', { feed: feed() })).toHaveProperty('reason', '');
|
||||
expect(buildQzonePublicRequest('forward', { feed: feed(), reason: ' @{uin:10002,nick:Synthetic} \n' })).toHaveProperty('reason', ' @{uin:10002,nick:Synthetic} \n');
|
||||
expect(buildQzonePublicRequest('forward', { feed: feed() })).not.toHaveProperty('comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QQ public Qzone JSON and identity boundaries', () => {
|
||||
test.each([null, [{ key: 'synthetic-extension', future: null }]])('preserves nullable protobuf defaults in full feed identity (%j)', (extIds) => {
|
||||
const union = { uf_key: 'synthetic-public', ugc_id: 'synthetic-ugc', feed_id: 'synthetic-feed', serialized_data: null, ext_ids: extIds };
|
||||
const source = feed();
|
||||
(source['cell_common'] as QzonePublicObject)['union_id'] = union;
|
||||
expect(buildQzonePublicRequest('detail', { feed: source })).toHaveProperty('union_id', union);
|
||||
expect(buildQzonePublicRequest('feeds', { refreshId: 'synthetic-refresh', cursor: 'next', lastUnionId: union }))
|
||||
.toHaveProperty('last_client_feed_union_id', union);
|
||||
expect(union.serialized_data).toBeNull();
|
||||
});
|
||||
|
||||
test.each([null, true, -1, Number.MAX_SAFE_INTEGER + 1, '', 'bad\nidentity'])('rejects invalid parent identities without changing their types (%j)', (id) => {
|
||||
expect(() => buildQzonePublicRequest('deleteComment', { feed: feed(), comment: { id } })).toThrow();
|
||||
});
|
||||
|
||||
test('rejects non-JSON, circular, oversized and excessively nested raw context', () => {
|
||||
const circular: QzonePublicObject = {};
|
||||
circular['self'] = circular;
|
||||
const nested: QzonePublicObject = {};
|
||||
let cursor = nested;
|
||||
for (let index = 0; index < 80; index++) { cursor['nested'] = {}; cursor = cursor['nested'] as QzonePublicObject; }
|
||||
for (const context of [circular, { value: () => 0 }, { value: new Date() }, { value: Infinity }, { value: 'x'.repeat(8 * 1024 * 1024 + 1) }, nested]) {
|
||||
const source = feed();
|
||||
source['cell_feed_transfer'] = context;
|
||||
expect(() => buildQzonePublicRequest('detail', { feed: source })).toThrow();
|
||||
}
|
||||
const sparse: unknown[] = [];
|
||||
sparse.length = 1;
|
||||
const source = feed();
|
||||
source['cell_feed_transfer'] = sparse;
|
||||
expect(() => buildQzonePublicRequest('detail', { feed: source })).toThrow();
|
||||
});
|
||||
|
||||
test.each(['', [], [null], [1], [false], ['text'], [[{}]]].map(value => ({ value })))('rejects malformed comment content ($value)', ({ value }) => {
|
||||
expect(() => buildQzonePublicRequest('addComment', { feed: feed(), content: value as never, user: currentUser })).toThrow();
|
||||
});
|
||||
|
||||
test('preserves unknown JSON keys without triggering prototype setters', () => {
|
||||
const source = feed();
|
||||
source['cell_feed_transfer'] = JSON.parse('{"__proto__":{"synthetic":true},"constructor":{"opaque":0}}');
|
||||
const transfer = buildQzonePublicRequest('detail', { feed: source })['cell_feed_transfer'] as QzonePublicObject;
|
||||
expect(Object.prototype.hasOwnProperty.call(transfer, '__proto__')).toBe(true);
|
||||
expect(Object.getPrototypeOf(transfer)).toBe(Object.prototype);
|
||||
expect(Object.getOwnPropertyDescriptor(transfer, '__proto__')?.value).toEqual({ synthetic: true });
|
||||
});
|
||||
|
||||
test('accepts a full returned feed larger than an old reference-handle limit', () => {
|
||||
const source = feed();
|
||||
source['cell_comment'] = { comments: [{ id: 'large-comment', content: [{ type: 0, content: 'x'.repeat(70000) }] }] };
|
||||
expect(buildQzonePublicRequest('detail', { feed: source })).toHaveProperty('union_id.uf_key', 'synthetic-feed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QQ public Qzone response acknowledgements', () => {
|
||||
test('requires numeric gateway retcode zero and object data without exposing server text', () => {
|
||||
for (const response of [null, {}, { retcode: '0', data: {} }, { retcode: -1, data: {}, message: 'synthetic-private' }, { retcode: 0, data: [] }, { retcode: 0, data: null }]) {
|
||||
expect(() => parseQzonePublicResponse(response, 'feeds')).toThrow();
|
||||
}
|
||||
try { parseQzonePublicResponse({ retcode: -1, data: {}, message: 'synthetic-private' }, 'feeds'); } catch (error) {
|
||||
expect((error as Error).message).not.toContain('synthetic-private');
|
||||
}
|
||||
});
|
||||
|
||||
test('exposes a numeric upstream rejection code while keeping its private text out of errors', () => {
|
||||
expect(() => parseQzonePublicResponse({ retcode: 10120, message: 'synthetic-private', data: null }, 'addReply'))
|
||||
.toThrow('QQ空间公开接口拒绝请求 (retcode=10120)');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['upvote', 'upvote'], ['downvote', 'upvote'], ['like', 'like'], ['unlike', 'like'],
|
||||
['commentLike', 'like'], ['commentUnlike', 'like'], ['addComment', 'comment'], ['addReply', 'reply'],
|
||||
] as const)('checks the %s business result beyond gateway success', (operation, field) => {
|
||||
for (const result of [undefined, null, false, 0, '']) expect(() => parseQzonePublicResponse({ retcode: 0, data: { [field]: result } }, operation)).toThrow();
|
||||
expect(parseQzonePublicResponse({ retcode: 0, data: { [field]: { status: false, count: 0 }, future: { keep: true } } }, operation))
|
||||
.toEqual({ [field]: { status: false, count: 0 }, future: { keep: true } });
|
||||
});
|
||||
|
||||
test.each(['follow', 'unfollow'] as const)('checks optional %s business ret_code and tolerates protobuf zero defaults', (operation) => {
|
||||
for (const code of [undefined, null, 0, '0']) expect(parseQzonePublicResponse({ retcode: 0, data: { ret_code: code, future: true } }, operation)).toHaveProperty('future', true);
|
||||
for (const code of [-1, 1, '1', false, '', {}, []]) expect(() => parseQzonePublicResponse({ retcode: 0, data: { ret_code: code, ret_msg: 'synthetic-private' } }, operation)).toThrow();
|
||||
});
|
||||
|
||||
test.each(['deleteComment', 'deleteReply', 'forward'] as const)('does not invent an undocumented success field for %s', (operation) => {
|
||||
expect(parseQzonePublicResponse({ retcode: 0, data: {} }, operation)).toEqual({});
|
||||
});
|
||||
|
||||
test('returns preserved business data snapshots and permits a whole response above an old handle limit', () => {
|
||||
const data = { feeds: [{ feed: feed() }], has_more: false, future: { retained: true }, text: 'x'.repeat(70000) };
|
||||
const parsed = parseQzonePublicResponse({ retcode: 0, data }, 'feeds');
|
||||
expect(parsed).toEqual(data);
|
||||
data.future.retained = false;
|
||||
expect(parsed).toHaveProperty('future.retained', true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because they import each other.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { OB11HttpServerAdapter } from '../napcat-onebot/network/http-server';
|
||||
import { CommentQzonePublicFeed, DeleteQzonePublicReply } from '../napcat-onebot/action/extends/qzone-public';
|
||||
|
||||
const FEED = { cell_common: { union_id: { feed_id: 'opaque' } }, future_feed: [1, false] };
|
||||
const COMMENT = { id: 'comment-id', like: { id: 'comment-like-id' }, future_comment: true };
|
||||
const REPLY = { id: 'reply-id', user: { uid: 'recipient' }, future_reply: { keep: true } };
|
||||
|
||||
function fixture (Action = DeleteQzonePublicReply as typeof DeleteQzonePublicReply | typeof CommentQzonePublicFeed) {
|
||||
const result = { upstream_result: { unknown: 'preserved' } };
|
||||
const methods = { deleteReply: vi.fn().mockResolvedValue(result), addComment: vi.fn().mockResolvedValue(result) };
|
||||
const action = new Action(undefined as never, {
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
apis: { QzonePublicApi: methods },
|
||||
} as never);
|
||||
// Exercise the adapter handler without opening a listener or constructing native QQ services.
|
||||
const adapter = Object.assign(Object.create(OB11HttpServerAdapter.prototype), {
|
||||
name: 'test-http', config: {}, actions: new Map([[action.actionName, action]]),
|
||||
}) as OB11HttpServerAdapter;
|
||||
return { adapter, action, methods, result };
|
||||
}
|
||||
|
||||
async function request (
|
||||
adapter: OB11HttpServerAdapter,
|
||||
actionName: string,
|
||||
method: string,
|
||||
query: Record<string, unknown>,
|
||||
body: Record<string, unknown> = {},
|
||||
contentType = 'application/json'
|
||||
) {
|
||||
const json = vi.fn();
|
||||
await adapter.httpApiRequest({
|
||||
method,
|
||||
path: `/${actionName}`,
|
||||
query,
|
||||
body,
|
||||
is: (type: string) => type === contentType,
|
||||
} as never, { json } as never);
|
||||
return json.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe('Qzone public HTTP parameter compatibility', () => {
|
||||
test.each(['GET', 'FORM'])('%s decodes feed, comment and reply JSON values before action validation', async (transport) => {
|
||||
const { adapter, action, methods, result } = fixture();
|
||||
const parameters = { feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY) };
|
||||
const response = transport === 'GET'
|
||||
? await request(adapter, action.actionName, 'GET', parameters)
|
||||
: await request(adapter, action.actionName, 'POST', {}, parameters, 'application/x-www-form-urlencoded');
|
||||
|
||||
expect(response).toMatchObject({ status: 'ok', data: result });
|
||||
expect(methods.deleteReply).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, REPLY);
|
||||
});
|
||||
|
||||
test('POST query parameters receive the same decoding and override body parameters', async () => {
|
||||
const { adapter, action, methods } = fixture();
|
||||
const response = await request(adapter, action.actionName, 'POST', {
|
||||
feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY),
|
||||
}, { feed: {}, comment: {}, reply: {} });
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(methods.deleteReply).toHaveBeenCalledExactlyOnceWith(FEED, COMMENT, REPLY);
|
||||
});
|
||||
|
||||
test('JSON request bodies and WebSocket parameters retain strict object validation', async () => {
|
||||
const { adapter, action, methods } = fixture();
|
||||
const parameters = { feed: JSON.stringify(FEED), comment: JSON.stringify(COMMENT), reply: JSON.stringify(REPLY) };
|
||||
const http = await request(adapter, action.actionName, 'POST', {}, { ...parameters });
|
||||
const websocket = await action.websocketHandle({ ...parameters } as never, 'strict-echo', 'test', {} as never);
|
||||
|
||||
expect(http).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(websocket).toMatchObject({ status: 'failed', retcode: 1400, echo: 'strict-echo' });
|
||||
expect(methods.deleteReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('query content is decoded before rich content validation, including quoted strings', async () => {
|
||||
const { adapter, action, methods } = fixture(CommentQzonePublicFeed);
|
||||
const query = { feed: JSON.stringify(FEED) };
|
||||
const invalid = await request(adapter, action.actionName, 'GET', { ...query, content: '123' });
|
||||
expect(invalid).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(methods.addComment).not.toHaveBeenCalled();
|
||||
|
||||
const quoted = await request(adapter, action.actionName, 'GET', { ...query, content: '"123"' });
|
||||
expect(quoted.status).toBe('ok');
|
||||
expect(methods.addComment).toHaveBeenCalledExactlyOnceWith(FEED, '123');
|
||||
methods.addComment.mockClear();
|
||||
|
||||
const content = [{ type: 0, content: '正文', future: { keep: true } }];
|
||||
expect((await request(adapter, action.actionName, 'GET', { ...query, content: JSON.stringify(content) })).status).toBe('ok');
|
||||
expect(methods.addComment).toHaveBeenCalledExactlyOnceWith(FEED, content);
|
||||
});
|
||||
|
||||
test('other OneBot actions retain their existing undecoded HTTP parameters', async () => {
|
||||
const { adapter } = fixture();
|
||||
const handle = vi.fn().mockResolvedValue({ status: 'ok', retcode: 0, data: {} });
|
||||
Object.assign(adapter, { actions: new Map([['other_action', { handle, useStream: false }]]) });
|
||||
const parameters = { feed: JSON.stringify(FEED), content: '123' };
|
||||
|
||||
await request(adapter, 'other_action', 'GET', parameters);
|
||||
|
||||
expect(handle.mock.calls[0]?.[0]).toEqual(parameters);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import type { QzoneComment, QzoneFeed, QzoneFeedReference } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
const unionId = { uf_key: 'union-key', ugc_id: 'ugc-key', feed_id: 'feed-key', serialized_data: 'opaque', ext_ids: [{ ext_id_type: 5, str: 'mood-tid' }] };
|
||||
const feed: QzoneFeed = {
|
||||
cell_common: { union_id: unionId, feed_type: 1, feed_sub_type: 1, cur_like_key: 'like-key', domain_type: 1 },
|
||||
cell_summary: { summary: [{ type: 0, content: '正文' }] },
|
||||
cell_user_info: { user: { uin: '10001', uid: 'self-uid', nick: '当前账号' } },
|
||||
cell_feed_transfer: { opaque: 'context' },
|
||||
};
|
||||
const reference: QzoneFeedReference = {
|
||||
union_id: unionId, cell_feed_transfer: { opaque: 'context' }, feed_action_domain_type: 1, like_key: 'like-key',
|
||||
};
|
||||
|
||||
function response (data: Record<string, unknown>, retcode = 0): Response {
|
||||
return new Response(JSON.stringify({ retcode, data }), { status: 200 });
|
||||
}
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
getUidByUinV2: vi.fn().mockResolvedValue('target-uid'),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
describe('Qzone modern HTTP2RPC API', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the fixed JSON RPC route, PSKey-first gtk and only current account cookies', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: false }));
|
||||
|
||||
const result = await api.getFriendFeeds();
|
||||
|
||||
expect(result.feeds).toHaveLength(1);
|
||||
expect(result.feeds[0]?.tid).toBe('mood-tid');
|
||||
expect(result.feeds[0]?.feed).toEqual(feed);
|
||||
expect(result.next_cursor).toBeNull();
|
||||
expect(core.apis.UserApi.getPSkey).toHaveBeenCalledExactlyOnceWith(['qzone.qq.com']);
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toBe('https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.qzone.feeds_reader.FeedsReader/GetFriendFeeds?g_tk=177685');
|
||||
expect(options).toMatchObject({ method: 'POST', redirect: 'error', signal: expect.any(AbortSignal) });
|
||||
expect(options?.headers).toMatchObject({
|
||||
Cookie: 'uin=o10001; p_uin=o10001; skey=s; p_skey=p',
|
||||
'Content-Type': 'application/json',
|
||||
'x-oidb': '{"uint32_command":"0x97e5","uint32_service_type":"0"}',
|
||||
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
|
||||
});
|
||||
expect(JSON.parse(options?.body as string)).toEqual({ refresh_id: expect.stringMatching(/^10001_\d+$/) });
|
||||
});
|
||||
|
||||
it('falls back to SKey gtk and rejects missing auth without leaking ticket errors', async () => {
|
||||
const { api, core } = createApi();
|
||||
core.apis.UserApi.getPSkey.mockRejectedValueOnce(new Error('private ticket detail'));
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false }));
|
||||
await api.getFriendFeeds();
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('g_tk=177688');
|
||||
|
||||
core.apis.UserApi.getSKey.mockRejectedValueOnce(new Error('private skey detail'));
|
||||
core.apis.UserApi.getPSkey.mockRejectedValueOnce(new Error('private pskey detail'));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间取票失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid identity and account switches during ticket acquisition', async () => {
|
||||
const { api, core } = createApi();
|
||||
core.selfInfo.uin = '';
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('需要已登录');
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
core.selfInfo.uin = '10001';
|
||||
core.apis.UserApi.getPSkey.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uin = '20001';
|
||||
core.selfInfo.uid = 'another-uid';
|
||||
return { result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) };
|
||||
});
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ账号已切换');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the refresh id, attach info and last non-ad full union identity across pages', async () => {
|
||||
const { api } = createApi();
|
||||
const ad: QzoneFeed = { cell_common: { feed_type: 3, union_id: { uf_key: 'ad-key' } } };
|
||||
const unknown: QzoneFeed = { cell_common: { feed_type: 1, union_id: { uf_key: '' } }, new_cell: { opaque: true } };
|
||||
const noIdentity: QzoneFeed = { cell_common: { feed_type: 1 }, new_cell: { opaque: 'also-preserved' } };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }, { feed: ad }, { feed: unknown }, { feed: noIdentity }], has_more: true, attach_info: 'page-2' }));
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed: unknown }], has_more: false }));
|
||||
const first = await api.getFriendFeeds();
|
||||
expect(first.feeds).toHaveLength(4);
|
||||
expect(first.feeds[2]?.feed).toEqual(unknown);
|
||||
expect(first.feeds[2]?.feed_ref).toBeNull();
|
||||
expect(first.feeds[3]?.feed_ref).toBeNull();
|
||||
expect(first.next_cursor).toEqual(expect.any(String));
|
||||
const firstRequest = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
// A caller mutating returned display data must not change the cached pagination identity.
|
||||
first.feeds[0]!.feed.cell_common!.union_id!.uf_key = 'caller-mutated';
|
||||
const last = await api.getFriendFeeds(first.next_cursor!);
|
||||
expect(last.feeds[0]?.feed).toEqual(unknown);
|
||||
expect(last.has_more).toBe(false);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({
|
||||
refresh_id: firstRequest.refresh_id, attach_info: 'page-2', last_client_feed_union_id: unionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cursors for a different query kind, target or account and after expiry', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000000);
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed }], has_more: true, attach_info: 'next' }));
|
||||
const friend = await api.getFriendFeeds();
|
||||
await expect(api.getMsgList(undefined, friend.next_cursor!)).rejects.toThrow('游标无效');
|
||||
const moods = await api.getMsgList('20001');
|
||||
await expect(api.getMsgList('30001', moods.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '20001';
|
||||
core.selfInfo.uid = 'other-uid';
|
||||
await expect(api.getFriendFeeds(friend.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '10001';
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(1000000 + 10 * 60 * 1000);
|
||||
await expect(api.getFriendFeeds(friend.next_cursor!)).rejects.toThrow('已过期');
|
||||
await expect(api.getFriendFeeds('x'.repeat(16385))).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bounds saved cursors and evicts the oldest issued token', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed }], has_more: true, attach_info: 'next' }));
|
||||
const first = await api.getFriendFeeds();
|
||||
for (let i = 0; i < 128; i++) await api.getFriendFeeds();
|
||||
await expect(api.getFriendFeeds(first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(129);
|
||||
});
|
||||
|
||||
it('uses resolved UID for mood lists and returns explicit seal state without a fake total', async () => {
|
||||
const { api, core } = createApi();
|
||||
const seal = { type: 1, summary: '已封存', additional: 'preserved' };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false, seal_off_memory: seal }));
|
||||
const result = await api.getMsgList('20001');
|
||||
expect(core.apis.UserApi.getUidByUinV2).toHaveBeenCalledExactlyOnceWith('20001');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
host_uid: 'target-uid', host_uin: '20001', attach_info: '', feed_action_domain_type: 1,
|
||||
});
|
||||
expect(result).toEqual({ msglist: [], has_more: false, next_cursor: null, seal_off_memory: seal });
|
||||
expect(result).not.toHaveProperty('total');
|
||||
});
|
||||
|
||||
it('rejects malformed pages, malformed continuation and unresolved target accounts', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ has_more: false }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('列表响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('续页信息无效');
|
||||
core.apis.UserApi.getUidByUinV2.mockResolvedValueOnce('');
|
||||
await expect(api.getMsgList('20001')).rejects.toThrow('无法解析目标QQ号');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the exact detail nesting and preserves raw comments and feed identity', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = { id: '0', user: { uin: '20001', uid: 'peer-uid' }, custom: { keep: true } };
|
||||
const detailFeed = { ...feed, cell_comment: { comments: [parent] } };
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: detailFeed } }));
|
||||
const detail = await api.getMsgDetail(reference);
|
||||
expect(detail.comments).toEqual([parent]);
|
||||
expect(detail.feed).toEqual(detailFeed);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
union_id: unionId, cell_feed_transfer: { opaque: 'context' }, feed_action_domain_type: 1, from_page: 1,
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: detailFeed }));
|
||||
await expect(api.getMsgDetail(reference)).rejects.toThrow('未返回说说详情');
|
||||
});
|
||||
|
||||
it('publishes with session author and mapped privacy, and accepts only a returned server tid', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed }));
|
||||
await expect(api.publishMsg('正文', 16, ['20001'])).resolves.toEqual({ tid: 'mood-tid', time: 0 });
|
||||
const request = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(request).toMatchObject({
|
||||
from: 3,
|
||||
feed: {
|
||||
cell_user_info: { user: { uin: '10001', uid: 'self-uid', nick: '当前账号' } },
|
||||
cell_summary: { summary: [{ type: 0, content: '正文' }] },
|
||||
cell_permission: { right: { flag: [3], white_uins: ['20001'] } },
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { cell_common: { client_key: 'local-key-only' } } }));
|
||||
await expect(api.publishMsg('第二条')).rejects.toThrow('结果未知');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns publish time only from the server feed without substituting the current clock', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { ...feed, cell_common: { ...feed.cell_common, time: '1700000123' } } }));
|
||||
await expect(api.publishMsg('带服务端时间')).resolves.toEqual({ tid: 'mood-tid', time: 1700000123 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not turn a malformed supplied publish time into a missing-time sentinel', async () => {
|
||||
const { api } = createApi();
|
||||
for (const time of [-1, 'bad', Number.MAX_SAFE_INTEGER + 1]) {
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { ...feed, cell_common: { ...feed.cell_common, time } } }));
|
||||
await expect(api.publishMsg('仅发表一次')).rejects.toThrow('勿自动重试');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses distinct official like/unlike routes and requires the business result', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ like: { status: 1 } }));
|
||||
fetchMock.mockResolvedValueOnce(response({ like: { status: 0 } }));
|
||||
await api.setLike(reference, true);
|
||||
await api.setLike(reference, false);
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/DoLike?');
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain('/UnLike?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
like_key: 'like-key', union_id: unionId, like_type: 1, cell_feed_transfer: { opaque: 'context' },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.setLike(reference, true)).rejects.toThrow('未返回点赞结果');
|
||||
await expect(api.setLike({ union_id: unionId }, true)).rejects.toThrow('缺少QQ空间点赞标识');
|
||||
fetchMock.mockResolvedValueOnce(response({ like: {} }));
|
||||
await api.setLike({ ...reference, like_key: 'k'.repeat(8192) }, true);
|
||||
expect(JSON.parse(fetchMock.mock.calls[3]![1]?.body as string).like_key).toHaveLength(8192);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('passes the full parent comment for replies and never trusts it as the acting author', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = {
|
||||
id: '0',
|
||||
user: { uin: '20001', uid: 'peer-uid', nick: '父评论作者' },
|
||||
like: { id: 'parent-like' },
|
||||
additional: { opaque: true },
|
||||
};
|
||||
const reply = { id: 'reply-id', user: { uin: '10001' }, opaque: 'returned' };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply }));
|
||||
await expect(api.comment(reference, '回复正文', parent, { uin: '30001', uid: 'reply-target' })).resolves.toEqual({ comment_id: 'reply-id', comment: reply });
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/AddReply?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
union_id: unionId,
|
||||
comment: parent,
|
||||
reply: { user: { uin: '10001', uid: 'self-uid' }, target_user: { uin: '30001', uid: 'reply-target' }, content: [{ content: '回复正文', type: 0 }] },
|
||||
});
|
||||
await expect(api.comment(reference, '回复', undefined, { uin: '30001' })).rejects.toThrow('必须与父评论');
|
||||
await expect(api.comment(reference, '回复', { id: '0' })).rejects.toThrow('用户信息无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts an empty optional UID on a returned parent while retaining the session actor UID', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = { id: 'parent', user: { uin: '20001', uid: '', nick: '' }, opaque: 'retained' };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id: 'empty-uid-reply' } }));
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toEqual({ comment_id: 'empty-uid-reply', comment: { id: 'empty-uid-reply' } });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
comment: parent,
|
||||
reply: { target_user: { uin: '20001', uid: '', nick: '' }, user: { uin: '10001', uid: 'self-uid' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a nonempty server comment id and does not replay ambiguous writes', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ comment: { id: '0', additional: true } }));
|
||||
await expect(api.comment(reference, '评论')).resolves.toEqual({ comment_id: '0', comment: { id: '0', additional: true } });
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/AddComment?');
|
||||
fetchMock.mockResolvedValueOnce(response({ comment: {} }));
|
||||
await expect(api.comment(reference, '评论')).rejects.toThrow('结果未知');
|
||||
fetchMock.mockRejectedValueOnce(new Error('transport contains private cookie'));
|
||||
await expect(api.publishMsg('正文')).rejects.toThrow('未能确认结果');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('accepts bounded parent and returned comment IDs that pass the public schema', async () => {
|
||||
const { api } = createApi();
|
||||
const id = 'c'.repeat(8192);
|
||||
const parent = { id, user: { uin: '20001', uid: '' } };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id } }));
|
||||
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toMatchObject({ comment_id: id });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string).comment.id).toBe(id);
|
||||
});
|
||||
|
||||
it('preserves a real parent null UID while omitting it only from the normalized reply target', async () => {
|
||||
const { api } = createApi();
|
||||
const parent = { id: '0', user: { uin: '20001', uid: null, nick: '作者', extra: 'keep' }, content: [{ type: 0, content: '原评论' }], unknown: { keep: true } };
|
||||
const saved = structuredClone(parent);
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id: 'new-reply' } }));
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toMatchObject({ comment_id: 'new-reply' });
|
||||
const request = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(request.comment).toEqual(saved);
|
||||
expect(request.reply.target_user).toEqual({ uin: '20001', nick: '作者', extra: 'keep' });
|
||||
expect(parent).toEqual(saved);
|
||||
});
|
||||
|
||||
it('rejects HTTP errors, redirects, malformed JSON and outer RPC errors without returning server secrets', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('private response', { status: 302, headers: { Location: 'https://other.example' } }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
fetchMock.mockResolvedValueOnce(new Response('private response', { status: 500 }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
fetchMock.mockResolvedValueOnce(new Response('not json but private', { status: 200 }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ retcode: 4003, message: 'private credential text', data: {} })));
|
||||
try {
|
||||
await api.getFriendFeeds();
|
||||
expect.fail('must reject business failure');
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain('private');
|
||||
expect(String(error)).toContain('4003');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('aborts a hanging request at the finite timeout and never retries a publish', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api } = createApi();
|
||||
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}));
|
||||
const pending = api.publishMsg('正文');
|
||||
const assertion = expect(pending).rejects.toThrow('未能确认结果');
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds auth waits and streamed response size', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, core } = createApi();
|
||||
core.apis.UserApi.getSKey.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const pending = api.getFriendFeeds();
|
||||
const assertion = expect(pending).rejects.toThrow('取票超时');
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
await assertion;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(8 * 1024 * 1024 + 1)));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzonePublishRequest,
|
||||
buildQzoneText,
|
||||
getQzoneGtk,
|
||||
getQzoneTid,
|
||||
normalizeQzoneFeed,
|
||||
parseQzoneRpcResponse,
|
||||
QZONE_REFERENCE_MAX_BYTES,
|
||||
validateQzoneFeedReference,
|
||||
type QzoneFeed,
|
||||
} from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
describe('Qzone RPC response and feed identity', () => {
|
||||
test('accepts only a successful object data envelope and preserves raw fields', () => {
|
||||
const data = { feeds: [], extra: { server_field: true } };
|
||||
expect(parseQzoneRpcResponse({ retcode: 0, data })).toBe(data);
|
||||
for (const input of [null, [], {}, { retcode: '0', data }, { retcode: 0 }, { retcode: 0, data: [] }]) {
|
||||
expect(() => parseQzoneRpcResponse(input)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not echo credential-shaped server errors', () => {
|
||||
expect(() => parseQzoneRpcResponse({ retcode: 4003, message: 'p_skey=private-value', data: null }))
|
||||
.toThrow('QQ空间请求失败 (retcode=4003)');
|
||||
expect(() => parseQzoneRpcResponse({ retcode: 'p_skey=private-value' }))
|
||||
.toThrow('QQ空间请求失败');
|
||||
});
|
||||
|
||||
test('uses an explicit mood extension ahead of ugc_id and never labels an album ID as a tid', () => {
|
||||
expect(getQzoneTid({ cell_common: { feed_sub_type: 1, union_id: { ugc_id: 'fallback', ext_ids: [{ ext_id_type: 5, str: 'mood-tid' }] } } }))
|
||||
.toBe('mood-tid');
|
||||
expect(getQzoneTid({ cell_common: { app_id: 311, union_id: { ugc_id: 'fallback' } } })).toBe('fallback');
|
||||
expect(getQzoneTid({ cell_common: { feed_sub_type: 2, union_id: { ugc_id: 'album-id' } } })).toBeNull();
|
||||
expect(getQzoneTid({ cell_common: { union_id: { uf_key: 'feed-key', ugc_id: 'unclassified' } } })).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves numeric extension strings and rejects already-rounded numbers', () => {
|
||||
expect(getQzoneTid({ cell_common: { union_id: { ext_ids: [{ ext_id_type: 5, unsigned_numeric: '9007199254740993' }] } } })).toBe('9007199254740993');
|
||||
expect(getQzoneTid({ cell_common: { union_id: { ext_ids: [{ ext_id_type: 5, numeric: 9007199254740992 }] } } })).toBeNull();
|
||||
});
|
||||
|
||||
test('retains complete references without rebuilding identities from tid', () => {
|
||||
const reference = {
|
||||
union_id: { uf_key: 'feed-key', ugc_id: 'mood-id', serialized_data: 'opaque', ext_ids: [{ ext_id_type: 5, str: 'mood-id', extra: 1 }] },
|
||||
cell_feed_transfer: { cookie: 'opaque-server-transfer', nested: [1, true, null] },
|
||||
feed_action_domain_type: 1,
|
||||
like_key: 'server-like-key',
|
||||
};
|
||||
expect(validateQzoneFeedReference(reference)).toBe(reference);
|
||||
const withEmptyDefaults = { union_id: { uf_key: 'issued-key', ugc_id: '', feed_id: '', serialized_data: '' } };
|
||||
expect(validateQzoneFeedReference(withEmptyDefaults)).toBe(withEmptyDefaults);
|
||||
expect(() => validateQzoneFeedReference({ tid: 'mood-id' })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: {} })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: '' } })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, feed_action_domain_type: 4 })).toThrow();
|
||||
});
|
||||
|
||||
test('bounds UTF-8 reference size and rejects non-JSON or cyclic transfer data', () => {
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: '中'.repeat(QZONE_REFERENCE_MAX_BYTES / 2) })).toThrow('64KiB');
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: 1n })).toThrow('JSON');
|
||||
const cycle: Record<string, unknown> = {};
|
||||
cycle['self'] = cycle;
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: cycle })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone RPC feed projections', () => {
|
||||
test('keeps missing values unknown while retaining the original feed', () => {
|
||||
const feed = { future_cell: { unknown: 'retained' } };
|
||||
expect(normalizeQzoneFeed(feed)).toEqual({
|
||||
tid: null,
|
||||
user_id: null,
|
||||
content: null,
|
||||
time: null,
|
||||
comment_num: null,
|
||||
like_num: null,
|
||||
images: null,
|
||||
feed_ref: null,
|
||||
feed,
|
||||
});
|
||||
expect(normalizeQzoneFeed(feed).feed).toBe(feed);
|
||||
});
|
||||
|
||||
test('normalizes observed text, counts and image variants without including video covers', () => {
|
||||
const feed: QzoneFeed = {
|
||||
cell_common: { feed_sub_type: 1, time: '1700000000', union_id: { uf_key: 'key', ugc_id: 'tid' }, cur_like_key: 'like-key', domain_type: 1 },
|
||||
cell_user_info: { user: { uin: '10001' } },
|
||||
cell_summary: { summary: [{ type: 0, content: 'Hello ' }, { type: 1, name: 'Qiao' }, { type: 0, content: '[em]e100[/em]' }] },
|
||||
cell_comment: { num: 0, comments: [] },
|
||||
cell_like: { num: 2 },
|
||||
cell_media: {
|
||||
media_items: [
|
||||
{ type: 0, image: { photo_url: [{ spec: 1, url: { url: 'https://example.com/big.jpg' } }, { spec: 6, url: { url: '//example.com/original.jpg' } }] } },
|
||||
{ type: 1, video: { cover: { default_url: { url: 'https://example.com/video-cover.jpg' } } } },
|
||||
],
|
||||
},
|
||||
cell_feed_transfer: { unknown: true },
|
||||
};
|
||||
const result = normalizeQzoneFeed(feed);
|
||||
expect(result).toMatchObject({ tid: 'tid', user_id: '10001', content: 'Hello @Qiao[em]e100[/em]', time: 1700000000, comment_num: 0, like_num: 2, images: ['https://example.com/original.jpg'] });
|
||||
expect(result.feed_ref).toEqual({ union_id: feed.cell_common!.union_id, like_key: 'like-key', feed_action_domain_type: 1, cell_feed_transfer: { unknown: true } });
|
||||
expect(result.feed).toBe(feed);
|
||||
});
|
||||
|
||||
test('distinguishes known empty values from unknown or malformed sections', () => {
|
||||
expect(normalizeQzoneFeed({ cell_summary: { summary: [] }, cell_media: { media_items: [] } })).toMatchObject({ content: '', images: [] });
|
||||
expect(normalizeQzoneFeed({ cell_summary: { summary: [{ type: 99, content: 'future' }] } }).content).toBeNull();
|
||||
expect(normalizeQzoneFeed({ cell_media: { media_items: [{ type: 0, image: {} }] } }).images).toBeNull();
|
||||
expect(normalizeQzoneFeed({ cell_comment: { num: -1 }, cell_like: { num: 'not-a-number' } })).toMatchObject({ comment_num: null, like_num: null });
|
||||
});
|
||||
|
||||
test('declines unusable action handles while keeping their raw source', () => {
|
||||
const feed = { cell_common: { union_id: { uf_key: '' } }, untouched: true };
|
||||
const result = normalizeQzoneFeed(feed);
|
||||
expect(result.feed_ref).toBeNull();
|
||||
expect(result.feed).toBe(feed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone RPC pure-text publish body', () => {
|
||||
test('uses the official RichMsg shape and preserves text exactly', () => {
|
||||
expect(buildQzoneText(' hello\n[em]e100[/em] ')).toEqual([
|
||||
{ content: ' hello\n[em]e100[/em] ', media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 },
|
||||
]);
|
||||
expect(() => buildQzoneText(' \n ')).toThrow();
|
||||
expect(() => buildQzoneText('x'.repeat(2001))).toThrow('2000');
|
||||
});
|
||||
|
||||
test.each([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]])('maps legacy permission %i to modern flag %i', (legacy, modern) => {
|
||||
const result = buildQzonePublishRequest({ uin: '10001', uid: 'uid', nick: 'Qiao' }, 'post', legacy, ['10002'], 1700000000, 'client-key');
|
||||
expect(result).toMatchObject({
|
||||
from: 3,
|
||||
feed: {
|
||||
cell_common: { time: 1700000000, client_key: 'client-key', feed_type: 1, feed_sub_type: 1, feed_biz_type: 0, union_id: { uf_key: 'client-key', ugc_id: 'client-key', feed_id: 'client-key' } },
|
||||
cell_user_info: { user: { uin: '10001', uid: 'uid', nick: 'Qiao' } },
|
||||
cell_permission: { right: { flag: [modern] } },
|
||||
},
|
||||
});
|
||||
const permission = result.feed['cell_permission'] as { right: Record<string, unknown>; };
|
||||
expect(permission.right['white_uins']).toEqual(legacy === 16 ? ['10002'] : undefined);
|
||||
expect(permission.right['black_uins']).toEqual(legacy === 128 ? ['10002'] : undefined);
|
||||
expect(result.feed).not.toHaveProperty('cell_media');
|
||||
});
|
||||
|
||||
test('rejects unsupported rights and incomplete target lists before publishing', () => {
|
||||
for (const right of [0, 2, 3, 5, 256, NaN]) {
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right)).toThrow();
|
||||
}
|
||||
for (const right of [16, 128]) {
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right)).toThrow('target_uins');
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right, [])).toThrow('target_uins');
|
||||
}
|
||||
expect(() => buildQzonePublishRequest({ uin: '' }, 'post', 1)).toThrow();
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', 1, ['bad-uin'])).toThrow();
|
||||
});
|
||||
|
||||
test('matches known gtk values without needing an account key', () => {
|
||||
expect(getQzoneGtk('')).toBe(5381);
|
||||
expect(getQzoneGtk('abc')).toBe(193485963);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { GetQzoneFeeds } from '@/napcat-onebot/action/extends/qzone/GetQzoneFeeds';
|
||||
import { GetQzoneMsgList } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgList';
|
||||
import { GetQzoneMsgDetail } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail';
|
||||
import { LikeQzone } from '@/napcat-onebot/action/extends/qzone/LikeQzone';
|
||||
import { UnlikeQzone } from '@/napcat-onebot/action/extends/qzone/UnlikeQzone';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { SendQzoneMsg } from '@/napcat-onebot/action/extends/SendQzoneMsg';
|
||||
import { DeleteQzoneMsg } from '@/napcat-onebot/action/extends/DeleteQzoneMsg';
|
||||
import { QzoneFeedItemExample, QzoneFeedReferenceExample } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { SlQzoneFeedsExample, SlQzoneMsgDetailExample, SlQzoneMsgListExample } from '@/napcat-onebot/action/extends/qzone/common-snowluma';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getFriendFeeds: vi.fn().mockResolvedValue({ feeds: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgList: vi.fn().mockResolvedValue({ msglist: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgDetail: vi.fn().mockResolvedValue({ ...QzoneFeedItemExample, comments: [] }),
|
||||
setLike: vi.fn().mockResolvedValue(undefined),
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'native-comment', comment: { id: 'native-comment', extra: 'raw' } }),
|
||||
getSnowlumaFeeds: vi.fn().mockResolvedValue(SlQzoneFeedsExample),
|
||||
getSnowlumaMsgList: vi.fn().mockResolvedValue(SlQzoneMsgListExample),
|
||||
getSnowlumaMsgDetail: vi.fn().mockResolvedValue(SlQzoneMsgDetailExample),
|
||||
setSnowlumaLike: vi.fn().mockResolvedValue(undefined),
|
||||
commentSnowluma: vi.fn().mockResolvedValue({ comment_id: 'sl-comment' }),
|
||||
uploadCommentImage: vi.fn().mockResolvedValue({ url: 'https://images.example.test/comment.png' }),
|
||||
publishMsg: vi.fn().mockResolvedValue({ tid: 'published', time: 1700000000 }),
|
||||
};
|
||||
return {
|
||||
NapCatTempPath: '',
|
||||
selfInfo: { uin: '10001', uid: 'u_current' },
|
||||
apis: {
|
||||
QzoneApi,
|
||||
WebApi: {
|
||||
uploadImageToQzone: vi.fn().mockResolvedValue('published-richval'),
|
||||
publishQzoneMsg: vi.fn().mockResolvedValue({ tid: 'published-image', time: 1700000001 }),
|
||||
deleteQzoneMsg: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
function withImageDirectory (core: ReturnType<typeof createCore>) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'napcat-qzone-sl-actions-'));
|
||||
tempDirs.push(directory);
|
||||
core.NapCatTempPath = directory;
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('QQ空间 SnowLuma action compatibility', () => {
|
||||
it('defaults to SL feeds and routes old opaque cursors before applying SL defaults', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneFeeds(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ page_num: '2', count: '5', unrelated: 'ignored' } as never, 'test', {} as never);
|
||||
await action.handle({ cursor: 'slqf:continuation' }, 'test', {} as never);
|
||||
await action.handle({ cursor: 'old-native-cursor' }, 'test', {} as never);
|
||||
await action.handle({ format: 'native' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: SlQzoneFeedsExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaFeeds.mock.calls).toEqual([
|
||||
[1, 10, undefined], [2, 5, undefined], [1, 10, 'slqf:continuation'],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.getFriendFeeds.mock.calls).toEqual([['old-native-cursor'], [undefined]]);
|
||||
});
|
||||
|
||||
it('defaults to SL list pagination while retaining native raw results and lossless target IDs', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgList(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ target_uin: '9007199254740993', pos: '40', num: '10' }, 'test', {} as never);
|
||||
const native = await action.handle({ format: 'native', target_uin: 10002 }, 'test', {} as never);
|
||||
await action.handle({ target_uin: '10003', cursor: 'native-list-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: SlQzoneMsgListExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaMsgList.mock.calls).toEqual([
|
||||
[undefined, 0, 20], ['9007199254740993', 40, 10],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.getMsgList.mock.calls).toEqual([['10002', undefined], ['10003', 'native-list-cursor']]);
|
||||
expect(native).toHaveProperty('data.msglist.0.feed_ref', QzoneFeedReferenceExample);
|
||||
expect(native).toHaveProperty('data.msglist.0.feed', QzoneFeedItemExample.feed);
|
||||
});
|
||||
|
||||
it('routes detail by its explicit anchor and does not project native raw comments', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgDetail(undefined as never, core as never);
|
||||
const reference = { ...QzoneFeedReferenceExample, cell_feed_transfer: { keep: [0, false, 'raw'] } };
|
||||
|
||||
const sl = await action.handle({ tid: 0, target_uin: 10002, pos: '2', num: '3' }, 'test', {} as never);
|
||||
const native = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
|
||||
expect(sl).toMatchObject({ status: 'ok', data: SlQzoneMsgDetailExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaMsgDetail).toHaveBeenCalledExactlyOnceWith('0', '10002', 2, 3);
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(reference);
|
||||
expect(native).toHaveProperty('data.feed', QzoneFeedItemExample.feed);
|
||||
});
|
||||
|
||||
it('uses one like backend per anchor and returns explicit null for both SL operations', async () => {
|
||||
const core = createCore();
|
||||
for (const Action of [LikeQzone, UnlikeQzone]) {
|
||||
const action = new Action(undefined as never, core as never);
|
||||
const result = await action.handle({ tid: 'post', target_uin: '10002', abstime: '123' }, 'test', {} as never);
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', data: null });
|
||||
await action.handle({ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never);
|
||||
}
|
||||
|
||||
expect(core.apis.QzoneApi.setSnowlumaLike.mock.calls).toEqual([
|
||||
['post', '10002', true, 123], ['post', '10002', false, 123],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.setLike.mock.calls).toEqual([[QzoneFeedReferenceExample, true], [QzoneFeedReferenceExample, false]]);
|
||||
});
|
||||
|
||||
it('normalizes SL root comments and complete reply pairs while retaining native comments', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const root = await action.handle({ tid: 'post', content: false, ignored: true } as never, 'test', {} as never);
|
||||
await action.handle({
|
||||
tid: 'post',
|
||||
target_uin: 10002,
|
||||
content: '回复',
|
||||
comment_id: 0,
|
||||
comment_uin: 10003,
|
||||
reply_uin: '10004',
|
||||
reply_name: ' 回复目标 ',
|
||||
images: [],
|
||||
}, 'test', {} as never);
|
||||
const native = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: '原生评论' }, 'test', {} as never);
|
||||
|
||||
expect(root).toMatchObject({ status: 'ok', data: { comment_id: 'sl-comment' } });
|
||||
expect(core.apis.QzoneApi.commentSnowluma.mock.calls).toEqual([
|
||||
['post', 'false', undefined, undefined],
|
||||
['post', '回复', '10002', { comment_id: '0', comment_uin: '10003', reply_uin: '10004', reply_name: ' 回复目标 ' }],
|
||||
]);
|
||||
expect(native).toMatchObject({ status: 'ok', data: { comment_id: 'native-comment', comment: { id: 'native-comment', extra: 'raw' } } });
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '原生评论', undefined, undefined);
|
||||
});
|
||||
|
||||
it('uploads root comment images in order and removes temporary sources while preserving user files', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
const localImage = join(directory, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
core.apis.QzoneApi.uploadCommentImage
|
||||
.mockResolvedValueOnce({ url: 'https://images.example.test/first.png' })
|
||||
.mockResolvedValueOnce({ url: 'https://images.example.test/second.png' });
|
||||
|
||||
await new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
});
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage.mock.calls).toEqual([
|
||||
[Buffer.from('hello').toString('base64')], [Buffer.from('user image').toString('base64')],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).toHaveBeenCalledExactlyOnceWith('post', '图片评论', undefined, {
|
||||
imageUrls: ['https://images.example.test/first.png', 'https://images.example.test/second.png'],
|
||||
});
|
||||
expect(readdirSync(directory)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['upload failure', 'invalid URL'])('stops comment publishing after %s and cleans temporary images', async (failure) => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
if (failure === 'upload failure') core.apis.QzoneApi.uploadCommentImage.mockRejectedValue(new Error('upload failed'));
|
||||
else core.apis.QzoneApi.uploadCommentImage.mockResolvedValue({ url: 'javascript:alert(1)' });
|
||||
|
||||
await expect(new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', 'base64://d29ybGQ='],
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops the image comment chain when either account identifier changes during upload', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
core.apis.QzoneApi.uploadCommentImage.mockImplementationOnce(async () => {
|
||||
Object.assign(core.selfInfo, change);
|
||||
return { url: 'https://images.example.test/uploaded.png' };
|
||||
});
|
||||
|
||||
await expect(new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', 'base64://d29ybGQ='],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ambiguous routing and malformed known parameters before any API call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneFeeds, { format: 'native', page_num: 1 }],
|
||||
[GetQzoneFeeds, { cursor: 'native-cursor', count: 10 }],
|
||||
[GetQzoneFeeds, { format: 'native', cursor: 'slqf:cursor' }],
|
||||
[GetQzoneFeeds, { format: 'snowluma', cursor: 'native-cursor' }],
|
||||
[GetQzoneFeeds, { page_num: false }],
|
||||
[GetQzoneFeeds, { count: 51 }],
|
||||
[GetQzoneFeeds, { target_uin: '10002' }],
|
||||
[GetQzoneMsgList, { format: 'native', pos: 0 }],
|
||||
[GetQzoneMsgList, { cursor: 'native-cursor', num: 20 }],
|
||||
[GetQzoneMsgList, { format: 'snowluma', cursor: 'native-cursor' }],
|
||||
[GetQzoneMsgList, { cursor: 'slqf:cursor' }],
|
||||
[GetQzoneMsgList, { target_uin: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[GetQzoneMsgList, { pos: null }],
|
||||
[GetQzoneMsgList, { num: 0 }],
|
||||
[GetQzoneMsgDetail, {}],
|
||||
[GetQzoneMsgDetail, { feed_ref: QzoneFeedReferenceExample, tid: 'post' }],
|
||||
[GetQzoneMsgDetail, { feed_ref: QzoneFeedReferenceExample, pos: 0 }],
|
||||
[GetQzoneMsgDetail, { tid: true }],
|
||||
[GetQzoneMsgDetail, { tid: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[LikeQzone, { tid: 'post', abstime: -1 }],
|
||||
[LikeQzone, { tid: 'post', abstime: true }],
|
||||
[UnlikeQzone, { feed_ref: QzoneFeedReferenceExample, target_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_id: 0 }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_name: 'name' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_uin: '10002', reply_name: 'name' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_id: 0, comment_uin: '9007199254740993' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment: { id: 'native-parent', user: { uin: '10002' } } }],
|
||||
[CommentQzone, { feed_ref: QzoneFeedReferenceExample, content: 'x', images: [] }],
|
||||
[CommentQzone, { tid: 'post', content: null }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', images: [''] }],
|
||||
[DeleteQzoneMsg, { tid: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[DeleteQzoneMsg, { tid: 'post', target_uin: '10002' }],
|
||||
[SendQzoneMsg, { content: 'x', ugc_right: true }],
|
||||
[SendQzoneMsg, { content: 'x', target_uins: [Number.MAX_SAFE_INTEGER + 1] }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
expect(result.status, JSON.stringify(payload)).toBe('failed');
|
||||
for (const method of [...Object.values(core.apis.QzoneApi), ...Object.values(core.apis.WebApi)]) {
|
||||
expect(method, JSON.stringify(payload)).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('validates all reply and identity fields before any image upload', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const cases = [
|
||||
{ tid: 'post', comment_id: 0, comment_uin: 10002 },
|
||||
{ tid: 'post', comment_id: 0 },
|
||||
{ tid: 'post', reply_uin: 10002, reply_name: 'name' },
|
||||
{ tid: 'post\n' },
|
||||
{ tid: 'post', comment_uin: false },
|
||||
{ tid: 'post', reply_name: ['invalid'] },
|
||||
{ tid: 'post', images: ['base64://aGVsbG8=', 1] },
|
||||
];
|
||||
for (const payload of cases) {
|
||||
const result = await action.handle({ content: 'x', images: ['base64://aGVsbG8='], ...payload } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry uncertain SL writes through any native backend', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setSnowlumaLike.mockRejectedValue(new Error('写入结果未知'));
|
||||
core.apis.QzoneApi.commentSnowluma.mockRejectedValue(new Error('写入结果未知'));
|
||||
|
||||
expect((await new LikeQzone(undefined as never, core as never).handle({ tid: 'post' }, 'test', {} as never)).status).toBe('failed');
|
||||
expect((await new CommentQzone(undefined as never, core as never).handle({ tid: 'post', content: 'x' }, 'test', {} as never)).status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.setSnowlumaLike).toHaveBeenCalledExactlyOnceWith('post', undefined, true, 0);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).toHaveBeenCalledExactlyOnceWith('post', 'x', undefined, undefined);
|
||||
expect(core.apis.QzoneApi.setLike).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps server publish times, preserves empty image captions, and marks missing times as unknown', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: false })).resolves.toEqual({ tid: 'published', time: 1700000000 });
|
||||
await expect(action._handle({ content: '', images: ['base64://aGVsbG8='] }))
|
||||
.resolves.toEqual({ tid: 'published-image', time: 1700000001 });
|
||||
core.apis.QzoneApi.publishMsg.mockResolvedValue({ tid: 'old-mock' } as never);
|
||||
await expect(action._handle({ content: 'x' })).resolves.toEqual({ tid: 'old-mock', time: 0 });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenNthCalledWith(1, 'false', 1, undefined);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).toHaveBeenCalledExactlyOnceWith('', ['published-richval'], 1, undefined);
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes safe numeric delete IDs and serializes success with explicit null', async () => {
|
||||
const core = createCore();
|
||||
const result = await new DeleteQzoneMsg(undefined as never, core as never).handle({ tid: 0 }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.WebApi.deleteQzoneMsg).toHaveBeenCalledExactlyOnceWith('0');
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', data: null });
|
||||
});
|
||||
|
||||
it('requires a complete initial account identity before starting image publishing', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
core.selfInfo.uid = '';
|
||||
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8='],
|
||||
})).rejects.toThrow('需要已登录');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('publishes schema-valid SL examples and accepts the documented unknown-total metadata', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneFeeds(undefined as never, core as never), new GetQzoneMsgList(undefined as never, core as never),
|
||||
new GetQzoneMsgDetail(undefined as never, core as never), new LikeQzone(undefined as never, core as never),
|
||||
new UnlikeQzone(undefined as never, core as never), new CommentQzone(undefined as never, core as never),
|
||||
new SendQzoneMsg(undefined as never, core as never), new DeleteQzoneMsg(undefined as never, core as never),
|
||||
];
|
||||
for (const action of actions) {
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
expect(TypeCompiler.Compile(actions[1]!.returnSchema).Check({ total: 0, total_known: false, msglist: [] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import { NTQQWebApi } from '@/napcat-core/apis/webapi';
|
||||
import { RequestUtil } from '@/napcat-common/src/request';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '本人' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (data: Record<string, unknown>): Response {
|
||||
return new Response(`_preloadCallback(${JSON.stringify(data)});`);
|
||||
}
|
||||
|
||||
function feedPage (key: string, hasMore: boolean, state: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { code: 0, data: { data: [{ uin: 20002, nickname: '好友', abstime: 1700000000, appid: 311, key, html: '' }], main: { hasMoreFeeds: hasMore, ...state } } };
|
||||
}
|
||||
|
||||
describe('SnowLuma Qzone compatibility transport', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('reads the requested legacy list offset/count and keeps a real total', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, total: 100, msglist: [{ tid: 'post', content: '正文', created_time: 1700000000, cmtnum: 5, secret: 1 }] }));
|
||||
await expect(api.getSnowlumaMsgList('20002', 40, 10)).resolves.toMatchObject({
|
||||
total: 100, total_known: true, msglist: [{ tid: 'post', content: '正文', time: 1700000000, comment_num: 5, is_private: true, images: [] }],
|
||||
});
|
||||
const url = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(url.pathname).toBe('/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ uin: '20002', pos: '40', num: '10', replynum: '100', g_tk: '177685' });
|
||||
expect(fetchMock.mock.calls[0]![1]).toMatchObject({ method: 'GET', redirect: 'error' });
|
||||
});
|
||||
|
||||
it('uses the alternate fixed read route only for explicit code -10000', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: -10000 })).mockResolvedValueOnce(response({ code: 0, total: 0, msglist: [] }));
|
||||
await expect(api.getSnowlumaMsgList()).resolves.toMatchObject({ total: 0, msglist: [] });
|
||||
const [url, options] = fetchMock.mock.calls[1]!;
|
||||
expect(new URL(String(url)).origin).toBe('https://user.qzone.qq.com');
|
||||
expect(new URL(String(url)).pathname).toBe('/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6');
|
||||
expect(options?.headers).toMatchObject({ Referer: 'https://user.qzone.qq.com/10001' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: -3000 }));
|
||||
await expect(api.getSnowlumaMsgList()).rejects.toThrow('-3000');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reads a tid and one flat comment page without synthesizing a modern feed reference', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, tid: 'post', content: '正文', cmtnum: 90, commentlist: [{ tid: 0, uin: 20002, name: '甲', create_time: 1700000000, content: '评论', list_3: [{ tid: 1, uin: 20003, name: '乙', create_time: 1700000001, content: '回复' }] }] }));
|
||||
const page = await api.getSnowlumaMsgDetail('post', '20002', 20, 5);
|
||||
expect(page).toMatchObject({ tid: 'post', comment_num: 90, comments: [{ comment_id: '0', replies: [{ comment_id: '1', parent_comment_id: '0', parent_comment_uin: 20002 }] }] });
|
||||
const url = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ tid: 'post', uin: '20002', pos: '20', num: '5', not_trunc_con: '1' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid identities and pagination before ticket acquisition', async () => {
|
||||
const { api, core } = createApi();
|
||||
await expect(api.getSnowlumaMsgList('bad')).rejects.toThrow();
|
||||
await expect(api.getSnowlumaMsgList(undefined, -1)).rejects.toThrow();
|
||||
await expect(api.getSnowlumaMsgDetail('post', undefined, 0, 101)).rejects.toThrow();
|
||||
await expect(api.getSnowlumaFeeds(21)).rejects.toThrow('预算');
|
||||
await expect(api.getSnowlumaFeeds(1, 51)).rejects.toThrow();
|
||||
await expect(api.uploadCommentImage('not base64')).rejects.toThrow();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts each tid-based like/unlike exactly once with owner and timestamp', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 })).mockResolvedValueOnce(response({ code: 0 }));
|
||||
await api.setSnowlumaLike('post', '20002', true, 1700000000);
|
||||
await api.setSnowlumaLike('post', '20002', false, 1700000000);
|
||||
expect(new URL(String(fetchMock.mock.calls[0]![0])).pathname).toContain('internal_dolike_app');
|
||||
expect(new URL(String(fetchMock.mock.calls[1]![0])).pathname).toContain('internal_unlike_app');
|
||||
expect(Object.fromEntries(new URLSearchParams(fetchMock.mock.calls[0]![1]?.body as string))).toMatchObject({
|
||||
opuin: '10001', fid: 'post', abstime: '1700000000', unikey: 'http://user.qzone.qq.com/20002/mood/post',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uploads a comment image using native tickets and the returned real URL', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('frameElement.callback({"code":0,"data":{"url":"https://photo.example/image?a=1&b=2"}});</script>'));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).resolves.toEqual({ url: 'https://photo.example/image?a=1&b=2' });
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toBe('https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=177685');
|
||||
expect(options).toMatchObject({ method: 'POST', redirect: 'error' });
|
||||
expect(new URLSearchParams(options?.body as string).get('picfile')).toBe('aGVsbG8=');
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { url: 'javascript:private()' } }));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('图片地址无效');
|
||||
for (const url of [' https://photo.example/a ', 'https://photo.example/a' + String.fromCharCode(0)]) {
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { url } }));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('可用图片地址');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the observed FormSender upload envelope with ret acknowledgement and rejects conflicting codes', async () => {
|
||||
const { api } = createApi();
|
||||
const html = (body: Record<string, unknown>) => '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body><script type="text/javascript">document.domain="h5.qzone.qq.com";frameElement.callback(' + JSON.stringify(body) + ');</script></body></html>';
|
||||
fetchMock.mockResolvedValueOnce(new Response(html({ ret: 0, data: { url: 'https://photo.example/test.png' } })));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).resolves.toEqual({ url: 'https://photo.example/test.png' });
|
||||
fetchMock.mockResolvedValueOnce(new Response(html({ ret: 0, code: -1, data: { url: 'https://photo.example/test.png' } })));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('posts image comments with URLs and preserves the real parent for replies', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, commentid: 0 }));
|
||||
await expect(api.commentSnowluma('post', '图片', '20002', { imageUrls: ['https://photo.example/a'] })).resolves.toEqual({ comment_id: '0' });
|
||||
const commentBody = new URLSearchParams(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(Object.fromEntries(commentBody)).toMatchObject({ hostUin: '20002', uin: '10001', topicId: '20002_post__1', richtype: '1', richval: 'https://photo.example/a' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { id: 'new-reply' } }));
|
||||
await expect(api.commentSnowluma('post', '回复', '20002', { comment_id: '0', comment_uin: '20003', reply_uin: '20004', reply_name: '丙' })).resolves.toEqual({ comment_id: 'new-reply', parent_comment_id: '0', parent_comment_uin: 20003 });
|
||||
const replyBody = new URLSearchParams(fetchMock.mock.calls[1]![1]?.body as string);
|
||||
expect(Object.fromEntries(replyBody)).toMatchObject({ uin: '10001', hostUin: '20002', commentId: '0', commentUin: '20003' });
|
||||
expect(replyBody.get('content')).toContain('20004');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never replays an uncertain write and rejects image replies before network IO', async () => {
|
||||
const { api } = createApi();
|
||||
await expect(api.commentSnowluma('post', '回复', '20002', { comment_id: '0', comment_uin: '20003', imageUrls: ['https://photo.example/a'] })).rejects.toThrow();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
fetchMock.mockRejectedValueOnce(new Error('private transport detail'));
|
||||
await expect(api.commentSnowluma('post', '正文')).rejects.toThrow('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the existing image publisher if the account changes during either native ticket lookup', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockRejectedValue(new Error('unexpected network request'));
|
||||
for (const stage of ['skey', 'pskey']) {
|
||||
const { core } = createApi();
|
||||
const web = new NTQQWebApi({} as never, core as never);
|
||||
if (stage === 'skey') {
|
||||
core.apis.UserApi.getSKey.mockImplementationOnce(async () => { core.selfInfo.uid = 'switched'; return 's'; });
|
||||
} else {
|
||||
core.apis.UserApi.getPSkey.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uid = 'switched';
|
||||
return { result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) };
|
||||
});
|
||||
}
|
||||
await expect(web.uploadImageToQzone('aGVsbG8=')).rejects.toThrow('QQ账号已切换');
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gets HTML feeds with bounded opaque continuation and actual server state', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('first', true, { pagenum: '2', begintime: '123', aisortNextTime: '456', externparam: 'opaque' })));
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('second', false)));
|
||||
const first = await api.getSnowlumaFeeds();
|
||||
expect(first.next_cursor).toMatch(/^slqf:/);
|
||||
expect(first.feeds[0]?.key).toBe('first');
|
||||
const next = await api.getSnowlumaFeeds(1, 5, first.next_cursor!);
|
||||
expect(next.feeds[0]?.key).toBe('second');
|
||||
expect(next.next_cursor).toBeNull();
|
||||
const url = new URL(String(fetchMock.mock.calls[1]![0]));
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ count: '5', pagenum: '2', begintime: '123', getAisort: '456', externparam: 'opaque', outputhtmlfeed: '1' });
|
||||
expect(url.searchParams.has('aisortNextTime')).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('walks server continuations for explicit page numbers and stops at the actual end', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('skip', true, { pagenum: '2' }))).mockResolvedValueOnce(response(feedPage('wanted', false)));
|
||||
const page = await api.getSnowlumaFeeds(2);
|
||||
expect(page.feeds.map(item => item.key)).toEqual(['wanted']);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('last', false)));
|
||||
await expect(api.getSnowlumaFeeds(3)).resolves.toEqual({ feeds: [], has_more: false, next_cursor: null });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('binds feed cursors to account, page, lifetime and prevents clock-only continuation', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('first', true, { pagenum: '2' })));
|
||||
const first = await api.getSnowlumaFeeds();
|
||||
await expect(api.getSnowlumaFeeds(3, 10, first.next_cursor!)).rejects.toThrow('不匹配');
|
||||
core.selfInfo.uid = 'other-account';
|
||||
await expect(api.getSnowlumaFeeds(1, 10, first.next_cursor!)).rejects.toThrow('游标');
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(601000);
|
||||
await expect(api.getSnowlumaFeeds(1, 10, first.next_cursor!)).rejects.toThrow('已过期');
|
||||
clock.mockReturnValue(1000);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('stuck', true, { icServerTime: '999' })));
|
||||
await expect(api.getSnowlumaFeeds()).rejects.toThrow('未推进');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enforces a shared 45-second read budget while skipping feed pages', async () => {
|
||||
const { api } = createApi();
|
||||
let now = 1000;
|
||||
let pages = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
fetchMock.mockImplementation(async () => {
|
||||
now += 16000;
|
||||
return response(feedPage('page', true, { pagenum: String(++pages + 1) }));
|
||||
});
|
||||
await expect(api.getSnowlumaFeeds(4)).rejects.toThrow('预算');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildSlQzoneCommentBody,
|
||||
buildSlQzoneLikeBody,
|
||||
parseSlQzoneComment,
|
||||
parseSlQzoneMsgDetail,
|
||||
parseSlQzoneMsgList,
|
||||
} from '@/napcat-core/data/qzone-snowluma';
|
||||
|
||||
describe('SnowLuma-compatible mood list projections', () => {
|
||||
test('maps wire fields and photo preference while retaining exact body text', () => {
|
||||
const result = parseSlQzoneMsgList({
|
||||
code: 0,
|
||||
total: '23',
|
||||
msglist: [{
|
||||
tid: 'post-local-x',
|
||||
content: ' independently written\ntext ',
|
||||
created_time: '31',
|
||||
cmtnum: '8',
|
||||
secret: '1',
|
||||
pic: [{ smallurl: 'https://cdn.example.test/tiny', url2: 'https://cdn.example.test/medium', url3: 'https://cdn.example.test/full' }],
|
||||
}],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
total: 23,
|
||||
total_known: true,
|
||||
msglist: [{
|
||||
tid: 'post-local-x', content: ' independently written\ntext ', time: 31, comment_num: 8, is_private: true, images: ['https://cdn.example.test/full'],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('labels the page-length total fallback and preserves the declared legacy sentinels', () => {
|
||||
expect(parseSlQzoneMsgList({ msglist: [{}] })).toEqual({
|
||||
total: 1, total_known: false, msglist: [{ tid: '', content: '', time: 0, comment_num: 0, is_private: false, images: [] }],
|
||||
});
|
||||
expect(parseSlQzoneMsgList({ total: 0, msglist: [] })).toEqual({ total: 0, total_known: true, msglist: [] });
|
||||
expect(parseSlQzoneMsgList({ total: null, msglist: [] }).total_known).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects missing arrays, unsafe identities and malformed supplied values rather than inventing success', () => {
|
||||
for (const response of [
|
||||
{}, { msglist: null }, { msglist: [null] }, { msglist: [{}], total: 'bad' },
|
||||
{ msglist: [{ tid: Number.MAX_SAFE_INTEGER + 1 }] }, { msglist: [{ created_time: -1 }] },
|
||||
{ msglist: [{ cmtnum: 'bad' }] }, { msglist: [{ content: false }] }, { msglist: [{ pic: 'bad' }] },
|
||||
{ code: -10000, subcode: 0, msglist: [] }, { code: 0, subcode: '0', msglist: [] },
|
||||
]) expect(() => parseSlQzoneMsgList(response)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function detail (overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
code: 0,
|
||||
tid: 'discussion-local-z',
|
||||
content: 'discussion text',
|
||||
cmtnum: '41',
|
||||
commentlist: [{
|
||||
tid: 0,
|
||||
uin: '135790',
|
||||
name: 'parent author',
|
||||
create_time: '7',
|
||||
content: 'parent text',
|
||||
list_3: [
|
||||
{ tid: 'nested-local-b', uin: 246810, name: 'reply author', create_time: 9, content: 'reply text' },
|
||||
],
|
||||
}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnowLuma-compatible tid detail', () => {
|
||||
test('normalizes legacy create_time and parent identities while preserving comment ID zero', () => {
|
||||
expect(parseSlQzoneMsgDetail(detail(), 'discussion-local-z')).toEqual({
|
||||
tid: 'discussion-local-z',
|
||||
content: 'discussion text',
|
||||
comment_num: 41,
|
||||
comments: [{
|
||||
comment_id: '0',
|
||||
uin: 135790,
|
||||
name: 'parent author',
|
||||
time: 7,
|
||||
content: 'parent text',
|
||||
replies: [{
|
||||
comment_id: 'nested-local-b', uin: 246810, name: 'reply author', time: 9, content: 'reply text', parent_comment_id: '0', parent_comment_uin: 135790,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('treats only missing or null comment batches as no supplied inline comments', () => {
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: null }), 'discussion-local-z').comments).toEqual([]);
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: undefined }), 'discussion-local-z').comment_num).toBe(41);
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: [{ tid: 'parent-only', uin: 135790, name: '', create_time: 0, content: '', list_3: null }] }), 'discussion-local-z').comments[0]?.replies).toEqual([]);
|
||||
});
|
||||
|
||||
test('requires matching post identity, measured total and complete comment fields', () => {
|
||||
for (const value of [
|
||||
detail({ tid: 'different-post' }), detail({ content: undefined }), detail({ cmtnum: undefined }),
|
||||
detail({ commentlist: {} }), detail({ commentlist: [{ tid: 0, uin: 135790, name: 'author', time: 10, content: 'wrong timestamp field' }] }),
|
||||
detail({ commentlist: [{ tid: Number.MAX_SAFE_INTEGER + 1, uin: 135790, name: 'author', create_time: 10, content: '' }] }),
|
||||
detail({ commentlist: [{ tid: 'parent', uin: '9007199254740993', name: 'author', create_time: 10, content: '' }] }),
|
||||
detail({ code: 0, subcode: -99 }),
|
||||
]) expect(() => parseSlQzoneMsgDetail(value, 'discussion-local-z')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowLuma-compatible like and comment forms', () => {
|
||||
test('builds tid-based like resources with distinct operator and post owner', () => {
|
||||
const enabled = new URLSearchParams(buildSlQzoneLikeBody('246810', '135790', 'post-local-x', true, 42));
|
||||
const disabled = new URLSearchParams(buildSlQzoneLikeBody('246810', '135790', 'post-local-x', false, 42));
|
||||
expect(enabled.get('unikey')).toBe('http://user.qzone.qq.com/135790/mood/post-local-x');
|
||||
expect(enabled.get('curkey')).toBe(enabled.get('unikey'));
|
||||
expect(Object.fromEntries(enabled)).toMatchObject({ opuin: '246810', fid: 'post-local-x', abstime: '42', appid: '311', qzreferrer: 'https://user.qzone.qq.com/246810' });
|
||||
expect([...disabled]).toEqual([...enabled]);
|
||||
expect(() => buildSlQzoneLikeBody('246810', 'bad-owner', 'post-local-x', true, 0)).toThrow();
|
||||
expect(() => buildSlQzoneLikeBody('246810', '135790', 'post-local-x', true, -1)).toThrow();
|
||||
expect(() => buildSlQzoneLikeBody('246810', '135790', 'post-local-x', 'false' as never, 0)).toThrow();
|
||||
});
|
||||
|
||||
test('uses direct uploaded URLs for ordinary image comments and keeps whitespace unchanged', () => {
|
||||
const body = new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', ' image comment\n ', {
|
||||
imageUrls: ['https://cdn.example.test/comment-a', 'https://cdn.example.test/comment-b'],
|
||||
}));
|
||||
expect(Object.fromEntries(body)).toMatchObject({
|
||||
uin: '246810',
|
||||
hostUin: '135790',
|
||||
topicId: '135790_post-local-x__1',
|
||||
content: ' image comment\n ',
|
||||
richtype: '1',
|
||||
richval: 'https://cdn.example.test/comment-a\thttps://cdn.example.test/comment-b',
|
||||
format: 'json',
|
||||
ref: 'feeds',
|
||||
feedsType: '100',
|
||||
});
|
||||
expect(body.has('commentId')).toBe(false);
|
||||
expect(new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'text')).get('richtype')).toBe('');
|
||||
});
|
||||
|
||||
test('separates parent author from addressed recipient and escapes mention grammar before form encoding', () => {
|
||||
const options = { comment_id: '0', comment_uin: '135790', reply_uin: '112233', reply_name: ' recipient%,} ' };
|
||||
const body = new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'reply body', options));
|
||||
expect(Object.fromEntries(body)).toMatchObject({ uin: '246810', hostUin: '135790', hostuin: '246810', topicId: '135790_post-local-x', commentId: '0', commentUin: '135790', format: 'fs', ref: '' });
|
||||
expect(body.get('content')).toBe('@{uin:112233,nick:recipient%25%2C%7D,auto:1} reply body');
|
||||
expect(body.has('replyId')).toBe(false);
|
||||
expect(body.has('richval')).toBe(false);
|
||||
expect(new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'reply body', { comment_id: '0', comment_uin: '135790' })).get('content')).toBe('reply body');
|
||||
});
|
||||
|
||||
test('rejects mismatched parent/recipient pairs, unsafe authors and any image reply before returning a body', () => {
|
||||
for (const options of [
|
||||
{ comment_id: '0' }, { comment_uin: '135790' }, { reply_uin: '112233', reply_name: 'recipient' },
|
||||
{ comment_id: '0', comment_uin: '135790', reply_uin: '112233' },
|
||||
{ comment_id: '0', comment_uin: '9007199254740993' },
|
||||
{ comment_id: '0', comment_uin: '135790', imageUrls: ['https://cdn.example.test/picture'] },
|
||||
{ imageUrls: ['javascript:alert(1)'] }, { imageUrls: ['https://user:password@cdn.example.test/picture'] },
|
||||
]) expect(() => buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'body', options)).toThrow();
|
||||
expect(() => buildSlQzoneCommentBody('246810', '135790', 'post-local-x', ' ', { comment_id: '0', comment_uin: '135790' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowLuma-compatible comment acknowledgements', () => {
|
||||
test('supports both ordinary ID casings and the documented empty-ID sentinel only after acknowledgement', () => {
|
||||
expect(parseSlQzoneComment({ code: 0, commentid: 0 })).toEqual({ comment_id: '0' });
|
||||
expect(parseSlQzoneComment({ subcode: 0, commentId: 'fresh-comment' })).toEqual({ comment_id: 'fresh-comment' });
|
||||
expect(parseSlQzoneComment({ code: 0 })).toEqual({ comment_id: '' });
|
||||
expect(parseSlQzoneComment({ code: 0, data: { id: 'another-endpoint-shape' } })).toEqual({ comment_id: '' });
|
||||
expect(() => parseSlQzoneComment({})).toThrow('成功状态');
|
||||
expect(() => parseSlQzoneComment({ code: 0, commentid: Number.MAX_SAFE_INTEGER + 1 })).toThrow();
|
||||
});
|
||||
|
||||
test('requires a real reply identity and returns the supplied safe parent identity', () => {
|
||||
const options = { comment_id: 'parent-local-c', comment_uin: '135790' };
|
||||
expect(parseSlQzoneComment({ code: 0, data: { id: 0 } }, options)).toEqual({ comment_id: '0', parent_comment_id: 'parent-local-c', parent_comment_uin: 135790 });
|
||||
for (const response of [{ code: 0 }, { code: 0, data: {} }, { code: 0, data: { id: '' } }, { subcode: 0, data: { id: 'not-acknowledged' } }]) {
|
||||
expect(() => parseSlQzoneComment(response, options)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects either error code without echoing server message text', () => {
|
||||
expect(() => parseSlQzoneComment({ code: 0, subcode: -9, message: 'private response text' })).toThrow('QQ空间兼容接口拒绝请求 (subcode=-9)');
|
||||
expect(() => parseSlQzoneComment({ code: '0', commentid: 'ignored' })).toThrow('QQ空间兼容接口拒绝请求');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
advanceSlQzoneFeedState,
|
||||
initialSlQzoneFeedState,
|
||||
parseSlQzoneFeeds,
|
||||
slQzoneFeedPosition,
|
||||
type SlQzoneFeedState,
|
||||
} from '@/napcat-core/data/qzone-snowluma-feeds';
|
||||
|
||||
// All payloads here are newly synthesized protocol data, not reference-project fixtures.
|
||||
function packet (items: unknown[], main: Record<string, unknown> = { hasMoreFeeds: false }): string {
|
||||
return `_preloadCallback(${JSON.stringify({ code: 0, subcode: 0, data: { main, data: items } })});`;
|
||||
}
|
||||
|
||||
function post (html: string, extra: Record<string, unknown> = {}) {
|
||||
return { uin: '330003', nickname: '林', abstime: '1800000123', appid: '311', key: 'entry-nine', html, ...extra };
|
||||
}
|
||||
|
||||
function ownHtml (body: string, footer = ''): string {
|
||||
return `<article class="f-single"><div class="user-pto"><img src="https://example.test/portrait.png"></div><div class="f-item" data-key="entry-nine"><input name="feed_data" data-uin="330003" data-fkey="entry-nine" data-tid="tid-nine">${body}</div>${footer}</article>`;
|
||||
}
|
||||
|
||||
describe('independent SL feed data-literal parsing', () => {
|
||||
it('accepts the required JS data subset without evaluating it', () => {
|
||||
const input = '_preloadCallback({code:0,subcode:0,data:{main:{hasMoreFeeds:false},data:[undefined,,{uin:\'330003\',nickname:\'云\\u2601\',abstime:1800000123,appid:\'311\',key:\'entry-nine\',html:\'\\x3Cdiv class="f-item" data-key="entry-nine"\\x3E\\x3Cdiv class="f-info"\\x3E文字 undefined, } )\\x3C/div\\x3E\\x3C/div\\x3E\',},],},});';
|
||||
const result = parseSlQzoneFeeds(input);
|
||||
expect(result.has_more).toBe(false);
|
||||
expect(result.feeds).toHaveLength(1);
|
||||
expect(result.feeds[0]).toMatchObject({ uin: 330003, nickname: '云☁', time: 1800000123, appid: 311, content: '文字 undefined, } )', content_complete: true });
|
||||
expect(result.feeds[0]?.html).toContain('<div');
|
||||
});
|
||||
|
||||
it('accepts JSON and recognized complete wrappers, including comments inside data only', () => {
|
||||
const data = { code: 0, data: { data: [], hasmore: '0' } };
|
||||
for (const text of [JSON.stringify(data), `_Callback(${JSON.stringify(data)})`, `callback(${JSON.stringify(data)});`, '{/* data-only */code:0,data:{main:{hasMoreFeeds:false},data:[],},}']) {
|
||||
expect(parseSlQzoneFeeds(text)).toMatchObject({ feeds: [], has_more: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not interpret undefined in strings or as a property name', () => {
|
||||
const result = parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false,undefined:\'ordinary key\',externparam:undefined},data:[{uin:330003,appid:311,key:\'entry-nine\',html:\'undefined\',nickname:\'undefined\',abstime:0}]}}');
|
||||
expect(result.feeds[0]?.nickname).toBe('undefined');
|
||||
expect(result.feeds[0]?.html).toBe('undefined');
|
||||
expect(result.main?.['undefined']).toBe('ordinary key');
|
||||
expect(result.main).not.toHaveProperty('externparam');
|
||||
expect(advanceSlQzoneFeedState({ ...initialSlQzoneFeedState(), externparam: 'retained' }, result.main!).externparam).toBe('retained');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});danger();',
|
||||
'danger();_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});',
|
||||
'unknownCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});',
|
||||
'<script>_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});</script>',
|
||||
'{code:0,data:(function(){throw new Error("private");})()}',
|
||||
'{code:0,data:{get main(){return {hasMoreFeeds:false}},data:[]}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:new Date()}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:Infinity}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:NaN}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],"__proto__":{"polluted":true}}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],"constr\\u0075ctor":{}}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:/regex/}}',
|
||||
])('rejects executable or unsupported bodies without echoing their text (%#)', (text) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseSlQzoneFeeds(text);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/private|danger|polluted|function/);
|
||||
expect(({} as { polluted?: boolean; }).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires explicit numeric success and a recognizable page structure', () => {
|
||||
for (const body of [
|
||||
{}, { code: '0', data: { data: [], hasmore: 0 } }, { code: 0, subcode: -1 },
|
||||
{ code: -7, subcode: 0 }, { code: 0, subcode: '0' },
|
||||
{ code: 0, data: {} }, { code: 0, data: { data: [] } },
|
||||
{ code: 0, data: { data: [], main: null } },
|
||||
{ code: 0, data: { data: [], main: { hasMoreFeeds: 'false' } } },
|
||||
{ code: 0, data: { data: [], main: { hasMoreFeeds: false, error: 'private failure' } } },
|
||||
{ code: 0, data: { data: [], hasmore: true } },
|
||||
]) expect(() => parseSlQzoneFeeds(JSON.stringify(body))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects duplicate root success keys even when quoted or escaped, without rejecting ordinary unknown duplicates', () => {
|
||||
const data = 'data:{main:{hasMoreFeeds:false},data:[]}';
|
||||
for (const keys of [
|
||||
'code:-1,code:0',
|
||||
'code:-1,"code":0',
|
||||
'"co\\u0064e":-1,code:0',
|
||||
'co\\u0064e:-1,code:0',
|
||||
'code:0,subcode:-9,"sub\\u0063ode":0',
|
||||
'code:0,subcode:0,subcode:0',
|
||||
]) expect(() => parseSlQzoneFeeds(`_preloadCallback({${keys},${data}});`)).toThrow();
|
||||
expect(parseSlQzoneFeeds('{code:0,message:\'old\',message:\'new\',data:{main:{hasMoreFeeds:false,code:-9,code:0},data:[]}}').feeds).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses main.hasMoreFeeds before the legacy fallback and keeps the raw main state', () => {
|
||||
const main = { hasMoreFeeds: '1', pagenum: '7', externparam: 'sort=chronological&opaque=a%26b', icServerTime: '1800001000' };
|
||||
const result = parseSlQzoneFeeds(packet([], main));
|
||||
expect(result).toEqual({ feeds: [], has_more: true, main });
|
||||
expect(() => parseSlQzoneFeeds(JSON.stringify({ code: 0, data: { main: {}, hasmore: 0, data: [] } }))).toThrow('结束标志');
|
||||
});
|
||||
|
||||
it('bounds input and nesting before passing data to JSON5', () => {
|
||||
expect(() => parseSlQzoneFeeds(' '.repeat(8 * 1024 * 1024 + 1))).toThrow('过大');
|
||||
expect(() => parseSlQzoneFeeds(`{code:0,data:${'['.repeat(66)}0${']'.repeat(66)}}`)).toThrow();
|
||||
expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:[/* never closed')).toThrow();
|
||||
expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:["unterminated]}}')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed real entries rather than dropping them along with padding', () => {
|
||||
for (const item of [12, 'bad', [], post('', { uin: 'not-a-number' }), post('', { abstime: Number.MAX_SAFE_INTEGER + 1 }), post('', { html: {} }), post('', { key: {} })]) {
|
||||
expect(() => parseSlQzoneFeeds(packet([null, item]))).toThrow();
|
||||
}
|
||||
expect(parseSlQzoneFeeds(packet([null, null])).feeds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('independent passive HTML feed projection', () => {
|
||||
it('extracts own text, matching tid, original images and explicit counters while retaining HTML exactly', () => {
|
||||
const html = ownHtml('<div class="f-info">测试 & 文字<br>第二行<img alt="[云]" src="https://example.test/face.gif"></div><a class="img-item" data-originurl="//cdn.example.test/full.webp?a=1&b=2"><img src="https://example.test/thumb.webp"></a>', '<div class="f-single-foot"><a class="qz_like_btn_v3" data-likecnt="17">赞</a></div>');
|
||||
const feed = parseSlQzoneFeeds(packet([post(html, { commentcnt: '6' })])).feeds[0]!;
|
||||
expect(feed).toEqual({
|
||||
uin: 330003,
|
||||
nickname: '林',
|
||||
time: 1800000123,
|
||||
appid: 311,
|
||||
key: 'entry-nine',
|
||||
html,
|
||||
tid: 'tid-nine',
|
||||
content: '测试 & 文字\n第二行[云]',
|
||||
content_complete: true,
|
||||
images: ['https://cdn.example.test/full.webp?a=1&b=2'],
|
||||
like_count: 17,
|
||||
comment_count: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('distinguishes truncated text from hidden complete text without including expand controls', () => {
|
||||
const partial = ownHtml('<div class="f-info">短内容<a data-cmd="qz_toggle">阅读全文</a></div>');
|
||||
expect(parseSlQzoneFeeds(packet([post(partial)])).feeds[0]).toMatchObject({ content: '短内容', content_complete: false });
|
||||
const full = ownHtml('<div class="f-info">短内容<a data-cmd="qz_toggle">展开</a></div><div class="f-info qz_info_complete" style="display:none">完整内容<p>下一段</p><a data-cmd="qz_toggle">收起</a></div>');
|
||||
expect(parseSlQzoneFeeds(packet([post(full)])).feeds[0]).toMatchObject({ content: '完整内容\n下一段', content_complete: true });
|
||||
});
|
||||
|
||||
it('keeps avatars, comments, repost bodies, scripts and video covers out of the original post', () => {
|
||||
const html = ownHtml(`<div class="f-info">原帖<script>dangerous()</script></div>
|
||||
<div class="mod-comments"><div class="f-info">评论内容</div><a class="img-item"><img src="https://example.test/comment.png"></a><a class="qz_like_btn_v3" data-likecnt="9999"></a></div>
|
||||
<div class="f-item-passive"><div class="f-info">转发正文</div><a class="img-item"><img src="https://example.test/repost.png"></a></div>
|
||||
<div class="f-ct-video"><a class="img-item"><img src="https://example.test/video-cover.png"></a></div>
|
||||
<a class="img-item" data-v_itemid="v"><img src="https://example.test/another-cover.png"></a>
|
||||
<a class="img-item"><img truesrc="https://example.test/photo.png" src="https://example.test/loading.png"></a>`);
|
||||
const feed = parseSlQzoneFeeds(packet([post(html)])).feeds[0]!;
|
||||
expect(feed.content).toBe('原帖');
|
||||
expect(feed.images).toEqual(['https://example.test/photo.png']);
|
||||
expect(feed.like_count).toBeNull();
|
||||
});
|
||||
|
||||
it('does not derive a mood tid from a feed key, another author or a non-mood app', () => {
|
||||
const mismatched = ownHtml('<div class="f-info">文字</div>').replace('data-uin="330003"', 'data-uin="440004"');
|
||||
expect(parseSlQzoneFeeds(packet([post(mismatched)])).feeds[0]?.tid).toBeNull();
|
||||
expect(parseSlQzoneFeeds(packet([post(ownHtml('<div class="f-info">相册说明</div>'), { appid: 4 })])).feeds[0]?.tid).toBeNull();
|
||||
const absent = parseSlQzoneFeeds(packet([post('<div class="unrecognized">没有可确认的正文</div>')])).feeds[0]!;
|
||||
expect(absent).toMatchObject({ tid: null, content: null, content_complete: null, images: null, like_count: null, comment_count: null });
|
||||
});
|
||||
|
||||
it('handles normal void tags/entities and deduplicates images without accepting unsafe URLs', () => {
|
||||
const html = ownHtml('<div class="f-info"><p>段落一 文字</p><p>段落二</p></div><a class="img-item" data-url3="https://example.test/a.png"><img src="https://example.test/ignored.png"></a><a class="img-item"><img src="https://example.test/a.png"></a><a class="img-item" data-originurl="javascript:alert(1)"><img src="data:image/png;base64,AAAA"></a>');
|
||||
const feed = parseSlQzoneFeeds(packet([post(html, { likecnt: '4', commentcnt: 0 })])).feeds[0]!;
|
||||
expect(feed.content).toBe('段落一 文字\n\n段落二');
|
||||
expect(feed.images).toEqual(['https://example.test/a.png']);
|
||||
expect(feed.like_count).toBe(4);
|
||||
expect(feed.comment_count).toBe(0);
|
||||
});
|
||||
|
||||
it('declines ambiguous content roots and bounds excessive HTML structure', () => {
|
||||
const duplicate = '<div class="f-item" data-key="entry-nine"><div class="f-info">A</div></div><div class="f-item" data-key="entry-nine"><div class="f-info">B</div></div>';
|
||||
expect(parseSlQzoneFeeds(packet([post(duplicate)])).feeds[0]?.content).toBeNull();
|
||||
expect(() => parseSlQzoneFeeds(packet([post(`<div>${'<span>'.repeat(70)}deep${'</span>'.repeat(70)}</div>`)]))).toThrow('HTML');
|
||||
expect(() => parseSlQzoneFeeds(packet([post('x'.repeat(2 * 1024 * 1024 + 1))]))).toThrow('HTML过大');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SL feed server state helpers', () => {
|
||||
it('uses explicit initial fields and updates only values actually supplied by the server', () => {
|
||||
const first = initialSlQzoneFeedState();
|
||||
expect(first).toEqual({ begintime: '0', dayspac: '0', pagenum: '1', aisortEndTime: '0', aisortOffset: '0', aisortBeginTime: '0', aisortNextTime: '0', icServerTime: '0', externparam: '', daylist: '', uinlist: '' });
|
||||
const next = advanceSlQzoneFeedState(first, { pagenum: '0007', begintime: '1800000000', aisortNextTime: 3, externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003', ignored: 'never put into query state' });
|
||||
expect(next).toMatchObject({ pagenum: '7', begintime: '1800000000', aisortNextTime: '3', externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003' });
|
||||
expect(first.pagenum).toBe('1');
|
||||
const partial = advanceSlQzoneFeedState(next, { externparam: '', daylist: '', uinlist: '', aisortOffset: '8' });
|
||||
expect(partial).toMatchObject({ pagenum: '7', externparam: next.externparam, daylist: 'day-a', uinlist: '', aisortOffset: '8' });
|
||||
expect(partial).not.toHaveProperty('ignored');
|
||||
});
|
||||
|
||||
it('does not count clock-only changes or property order as pagination progress', () => {
|
||||
const state = advanceSlQzoneFeedState(initialSlQzoneFeedState(), { externparam: 'p=2', pagenum: '2' });
|
||||
const changedClock = advanceSlQzoneFeedState(state, { icServerTime: '1800000123' });
|
||||
expect(slQzoneFeedPosition(changedClock)).toBe(slQzoneFeedPosition(state));
|
||||
const reordered = Object.fromEntries(Object.entries(state).reverse()) as unknown as SlQzoneFeedState;
|
||||
expect(slQzoneFeedPosition(reordered)).toBe(slQzoneFeedPosition(state));
|
||||
expect(slQzoneFeedPosition(advanceSlQzoneFeedState(state, { externparam: 'p=3' }))).not.toBe(slQzoneFeedPosition(state));
|
||||
});
|
||||
|
||||
it('preserves large digit strings without rounding and rejects unsafe numeric or oversized state', () => {
|
||||
expect(advanceSlQzoneFeedState(initialSlQzoneFeedState(), { icServerTime: '9007199254740993' }).icServerTime).toBe('9007199254740993');
|
||||
for (const update of [{ pagenum: 0 }, { pagenum: '-2' }, { dayspac: 1.5 }, { begintime: Number.MAX_SAFE_INTEGER + 1 }, { externparam: {} }, { daylist: 'x'.repeat(32769) }]) {
|
||||
expect(() => advanceSlQzoneFeedState(initialSlQzoneFeedState(), update)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (value: Record<string, unknown>): Response {
|
||||
return new Response(`_Callback(${JSON.stringify(value)});`, { status: 200 });
|
||||
}
|
||||
|
||||
const ownDetail = { code: 0, tid: 'post-tid', uin: '10001', content: ' 原始正文\n ', ugc_right: 1 };
|
||||
|
||||
describe('Qzone profile, visibility and blacklist HTTP integration', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([undefined, '20002'])('reads profile %s using the current account ticket without exposing other fields', async (target) => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '名称 & +', desc: '空间说明', private_field: 'secret' } }));
|
||||
|
||||
await expect(api.getProfile(target)).resolves.toEqual({ name: '名称 & +', description: '空间说明' });
|
||||
|
||||
const [rawUrl, options] = fetchMock.mock.calls[0]!;
|
||||
const url = new URL(String(rawUrl));
|
||||
expect(url.origin + url.pathname).toBe('https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ uin: target ?? '10001', vuin: '10001', fupdate: '1', g_tk: '177685' });
|
||||
expect(options).toMatchObject({ method: 'GET', redirect: 'error', headers: { Cookie: 'uin=o10001; p_uin=o10001; skey=s; p_skey=p' } });
|
||||
expect(options?.body).toBeUndefined();
|
||||
expect(core.apis.UserApi.getPSkey).toHaveBeenCalledExactlyOnceWith(['qzone.qq.com']);
|
||||
});
|
||||
|
||||
it('preserves empty profile values and rejects incomplete data', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '', desc: '' } }));
|
||||
await expect(api.getProfile()).resolves.toEqual({ name: '', description: '' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: 'Name' } }));
|
||||
await expect(api.getProfile()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ patch: { name: '新空间 & +' }, bitmap: '2048', fields: { spacename: '新空间 & +' } },
|
||||
{ patch: { description: '' }, bitmap: '4096', fields: { desc: '' } },
|
||||
{ patch: { name: '', description: '第一行\r\n第二行' }, bitmap: '6144', fields: { spacename: '', desc: '第一行\n第二行' } },
|
||||
])('updates only supplied profile fields (bitmap $bitmap)', async ({ patch, bitmap, fields }) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
|
||||
|
||||
await expect(api.setProfile(patch)).resolves.toBeUndefined();
|
||||
|
||||
const [rawUrl, options] = fetchMock.mock.calls[0]!;
|
||||
expect(new URL(String(rawUrl)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new');
|
||||
expect(options).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({ uin: '10001', pageindex: '3', fupdate: '1', mb: bitmap, ...fields });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid management arguments before acquiring tickets or making requests', async () => {
|
||||
const { api, core } = createApi();
|
||||
|
||||
await expect(api.getProfile('invalid')).rejects.toThrow();
|
||||
await expect(api.setProfile({})).rejects.toThrow();
|
||||
await expect(api.setProfile({ name: [] } as never)).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 2)).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 16, [])).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 128, ['20002', 'bad'])).rejects.toThrow();
|
||||
await expect(api.setBan('bad')).rejects.toThrow();
|
||||
await expect(api.setBan('20002', 'false' as never)).rejects.toThrow();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the complete own post before changing visibility without changing content', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, subcode: 0, ugc_right: 16 }));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 16, ['20002', '20002', '9007199254740993'])).resolves.toEqual({ ugc_right: 16 });
|
||||
|
||||
const detailUrl = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(detailUrl.pathname).toBe('/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6');
|
||||
expect(Object.fromEntries(detailUrl.searchParams)).toMatchObject({
|
||||
tid: 'post-tid', uin: '10001', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
const [updateUrl, options] = fetchMock.mock.calls[1]!;
|
||||
expect(new URL(String(updateUrl)).pathname).toBe('/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update');
|
||||
expect(options?.method).toBe('POST');
|
||||
const body = new URLSearchParams(options?.body as string);
|
||||
expect(body.get('con')).toBe(ownDetail.content);
|
||||
expect(body.get('tid')).toBe('post-tid');
|
||||
expect(body.get('hostuin')).toBe('10001');
|
||||
expect(body.get('ugc_right')).toBe('16');
|
||||
expect(body.get('allow_uins')).toBe('20002|9007199254740993');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...ownDetail, tid: 'other-tid' },
|
||||
{ ...ownDetail, uin: '20002' },
|
||||
{ code: 0, tid: 'post-tid', content: 'no verified owner' },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'unusable' }] },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X, ,L', height: 100, width: 100 }] },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X,A, ', height: 100, width: 100 }] },
|
||||
{ ...ownDetail, video: [{ video_id: 'not-safe-to-rebuild' }] },
|
||||
{ code: -3000, subcode: 0, private_message: 'secret' },
|
||||
])('does not submit a destructive or unverified permission update (%j)', async (detail) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('prevents overlapping writes to the same post and releases the lock afterward', async () => {
|
||||
const { api } = createApi();
|
||||
let resolveDetail!: (value: Response) => void;
|
||||
fetchMock.mockImplementationOnce(() => new Promise(resolve => { resolveDetail = resolve; }));
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
|
||||
const first = api.setMsgRight('post-tid', 64);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await expect(api.setMsgRight('post-tid', 4)).rejects.toThrow('正在修改权限');
|
||||
resolveDetail(response(ownDetail));
|
||||
await expect(first).resolves.toEqual({ ugc_right: 64 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: '4' }));
|
||||
await expect(api.setMsgRight('post-tid', 4)).resolves.toEqual({ ugc_right: 4 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not write after the active account changes while reading the post', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uin = '20002';
|
||||
core.selfInfo.uid = 'other-uid';
|
||||
return response(ownDetail);
|
||||
});
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ账号已切换');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not report success when the server echoes a different visibility', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: 1 }));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('返回权限与请求不一致');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rewrite a post whose response contains malformed UTF-8 content', async () => {
|
||||
const { api } = createApi();
|
||||
const bytes = Buffer.concat([
|
||||
Buffer.from('{"code":0,"tid":"post-tid","uin":"10001","content":"'),
|
||||
Buffer.from([255]),
|
||||
Buffer.from('","ugc_right":1}'),
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(new Response(bytes));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ空间请求失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it.each([{ enable: undefined, action: '1' }, { enable: true, action: '1' }, { enable: false, action: '2' }])('updates only the current account blacklist (enable=$enable)', async ({ enable, action }) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, subcode: 0 }));
|
||||
|
||||
await expect(api.setBan('9007199254740993', enable)).resolves.toBeUndefined();
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(new URL(String(url)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new');
|
||||
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({
|
||||
uin: '10001', act_uin: '9007199254740993', action, fupdate: '1', qzreferrer: 'https://user.qzone.qq.com/10001/main',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{}',
|
||||
'_Callback({"code":0,"subcode":-1,"message":"private-response"});',
|
||||
'<html>private login page</html>',
|
||||
'_Callback({"code":0});window.privateSideEffect();',
|
||||
])('rejects unconfirmed writes without exposing or executing a response (%s)', async (text) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response(text));
|
||||
let error: unknown;
|
||||
try {
|
||||
await api.setProfile({ name: 'Name' });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/private|window\./);
|
||||
expect(String(error)).toContain('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never replays an ambiguous write or follows a redirect with credentials', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('private redirect', { status: 302 }));
|
||||
await expect(api.setBan('20002')).rejects.toThrow('未能确认结果');
|
||||
expect(fetchMock.mock.calls[0]![1]?.redirect).toBe('error');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzoneBanBody,
|
||||
buildQzoneProfileBody,
|
||||
buildQzoneRightBody,
|
||||
normalizeQzoneRight,
|
||||
parseQzoneProfile,
|
||||
parseQzoneWebResponse,
|
||||
} from '@/napcat-core/data/qzone-web';
|
||||
|
||||
describe('Qzone management response parsing', () => {
|
||||
test.each([
|
||||
'{"code":0,"subcode":0,"data":{"field":"raw"}}',
|
||||
'_Callback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'callback({"code":0,"subcode":0,"data":{"field":"raw"}})',
|
||||
'_preloadCallback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'back({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'frameElement.callback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'<script>frameElement.callback({"code":0,"subcode":0,"data":{"field":"raw"}});</script>',
|
||||
])('parses only an inert known response envelope (%s)', (text) => {
|
||||
expect(parseQzoneWebResponse(text, true)).toEqual({ code: 0, subcode: 0, data: { field: 'raw' } });
|
||||
});
|
||||
|
||||
test('repairs observed name string fragments without treating a name alone as success', () => {
|
||||
expect(parseQzoneWebResponse('frameElement.callback({"code":0,"subcode":0,"data":{name:"Ack"}});'))
|
||||
.toEqual({ code: 0, subcode: 0, data: { name: 'Ack' } });
|
||||
expect(() => parseQzoneWebResponse('frameElement.callback({name:"Ack"});')).toThrow('成功状态');
|
||||
expect(parseQzoneWebResponse('callback({"code":0,"data":{name:"Other"}});'))
|
||||
.toEqual({ code: 0, data: { name: 'Other' } });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({name:"Ack","code":0,"subcode":0});'))
|
||||
.toEqual({ name: 'Ack', code: 0, subcode: 0 });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({"code":0,"data":{name:"Ack","extra":true}});'))
|
||||
.toEqual({ code: 0, data: { name: 'Ack', extra: true } });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({name:"Other","code":0});'))
|
||||
.toEqual({ name: 'Other', code: 0 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{}',
|
||||
'null',
|
||||
'[]',
|
||||
'{"code":"0"}',
|
||||
'{"code":0,"subcode":null}',
|
||||
'{"code":-1,"subcode":0}',
|
||||
'{"code":0,"subcode":-1}',
|
||||
'{"code":0,"retcode":-1}',
|
||||
'{"code":-1,"code":0}',
|
||||
'{"code":-1,"\\u0063ode":0}',
|
||||
'unknownCallback({"code":0});',
|
||||
'alert("prefix");callback({"code":0});',
|
||||
'callback({"code":0});alert("suffix");',
|
||||
'<html><script>callback({"code":0});</script></html>',
|
||||
'<html>login</html>',
|
||||
'<script>{"code":0}</script>',
|
||||
'<script>alert("prefix");callback({"code":0});</script>',
|
||||
])('rejects ambiguous, failed or executable wrappers (%s)', (text) => {
|
||||
expect(() => parseQzoneWebResponse(text)).toThrow();
|
||||
});
|
||||
|
||||
test('requires explicit code=0 for profile routes and never exposes private response text', () => {
|
||||
expect(parseQzoneWebResponse('{"subcode":0}')).toEqual({ subcode: 0 });
|
||||
expect(() => parseQzoneWebResponse('{"subcode":0}', true)).toThrow('成功状态');
|
||||
let error: unknown;
|
||||
try {
|
||||
parseQzoneWebResponse('{"code":-3000,"message":"p_skey=private-secret"}');
|
||||
} catch (value) {
|
||||
error = value;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('QQ空间接口拒绝请求 (code=-3000)');
|
||||
expect((error as Error).message).not.toContain('private-secret');
|
||||
});
|
||||
|
||||
test('preserves exact profile text and does not invent missing fields', () => {
|
||||
expect(parseQzoneProfile({ code: 0, data: { spacename: '', desc: ' 第一行\n第二行 ', signature: 'not-returned' } }))
|
||||
.toEqual({ name: '', description: ' 第一行\n第二行 ' });
|
||||
for (const response of [{}, { code: 0 }, { code: 0, data: {} }, { code: 0, data: { spacename: 1, desc: '' } }]) {
|
||||
expect(() => parseQzoneProfile(response)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone profile and blacklist form bodies', () => {
|
||||
test.each([
|
||||
{ profile: { name: '名称 & +' }, fields: { spacename: '名称 & +' }, bitmap: '2048' },
|
||||
{ profile: { description: '' }, fields: { desc: '' }, bitmap: '4096' },
|
||||
{ profile: { name: '', description: '一\r\n二\r' }, fields: { spacename: '', desc: '一\n二' }, bitmap: '6144' },
|
||||
])('only submits supplied profile fields (%j)', ({ profile, fields, bitmap }) => {
|
||||
const body = new URLSearchParams(buildQzoneProfileBody('10001', profile));
|
||||
expect(Object.fromEntries(body)).toEqual({ uin: '10001', pageindex: '3', fupdate: '1', mb: bitmap, ...fields });
|
||||
expect(body.has('signature')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects invalid profile input and preserves omitted fields', () => {
|
||||
expect(() => buildQzoneProfileBody('10001', {})).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: undefined })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: 'x'.repeat(8193) })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: 42 } as never)).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { description: null } as never)).toThrow();
|
||||
expect(() => buildQzoneProfileBody('invalid', { name: 'space' })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { signature: 'not-a-supported-field' } as never)).toThrow();
|
||||
expect(new URLSearchParams(buildQzoneProfileBody('10001', { name: 'space' })).has('desc')).toBe(false);
|
||||
});
|
||||
|
||||
test('builds ban and unban independently with an exact decimal target', () => {
|
||||
for (const enable of [true, false]) {
|
||||
expect(Object.fromEntries(new URLSearchParams(buildQzoneBanBody('10001', '9007199254740993', enable)))).toEqual({
|
||||
uin: '10001', act_uin: '9007199254740993', action: enable ? '1' : '2', fupdate: '1', qzreferrer: 'https://user.qzone.qq.com/10001/main',
|
||||
});
|
||||
}
|
||||
expect(() => buildQzoneBanBody('10001', 'bad', true)).toThrow();
|
||||
expect(() => buildQzoneBanBody('10001', '10002', 'false' as never)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function detail (overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { code: 0, subcode: 0, tid: 'T1', uin: '10001', content: ' original\ntext ', ...overrides };
|
||||
}
|
||||
|
||||
function observedEmptyForwardFields (): Record<string, unknown> {
|
||||
return { rt_tid: '', rt_uin: 0, rt_con: { conlist: null, content: '' }, rt_source: 0 };
|
||||
}
|
||||
|
||||
describe('Qzone permission update preflight', () => {
|
||||
test('validates rights and all target IDs without lossy conversion or silent omission', () => {
|
||||
expect(normalizeQzoneRight(16, ['10002', '10002', '9007199254740993']))
|
||||
.toEqual({ right: 16, targetUins: ['10002', '9007199254740993'] });
|
||||
for (const right of [0, 2, 3, 5, 256, NaN, Infinity]) expect(() => normalizeQzoneRight(right)).toThrow();
|
||||
expect(() => normalizeQzoneRight(16)).toThrow('target_uins');
|
||||
expect(() => normalizeQzoneRight(128, [])).toThrow('target_uins');
|
||||
expect(() => normalizeQzoneRight(128, ['10002', 'bad'])).toThrow();
|
||||
expect(() => normalizeQzoneRight(1, [10002] as never)).toThrow();
|
||||
});
|
||||
|
||||
test('rebuilds pure text with exact content, ownership, defaults and selective visibility', () => {
|
||||
const body = new URLSearchParams(buildQzoneRightBody(detail(), '10001', 'T1', 128, ['10002', '10002']));
|
||||
expect(Object.fromEntries(body)).toEqual({
|
||||
syn_tweet_verson: '1',
|
||||
tid: 'T1',
|
||||
paramstr: '1',
|
||||
pic_template: '',
|
||||
richtype: '',
|
||||
richval: '',
|
||||
special_url: '',
|
||||
subrichtype: '',
|
||||
pic_bo: '',
|
||||
con: ' original\ntext ',
|
||||
feedversion: '1',
|
||||
ver: '1',
|
||||
ugc_right: '128',
|
||||
to_sign: '0',
|
||||
ugcright_id: 'T1',
|
||||
hostuin: '10001',
|
||||
code_version: '1',
|
||||
format: 'fs',
|
||||
qzreferrer: 'https://user.qzone.qq.com/10001',
|
||||
allow_uins: '10002',
|
||||
});
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail(), '10001', 'T1', 64, ['10002'])).has('allow_uins')).toBe(false);
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ richtype: '', subrichtype: '' }), '10001', 'T1', 4)).get('richtype')).toBe('');
|
||||
});
|
||||
|
||||
test('accepts the observed plain-text forward placeholder and retains subtype 2', () => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 2 });
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 4));
|
||||
expect(Object.fromEntries(body)).toMatchObject({ con: ' original\ntext ', richtype: '', richval: '', subrichtype: '2' });
|
||||
expect(value['rt_con']).toEqual({ conlist: null, content: '' });
|
||||
});
|
||||
|
||||
test('accepts the same observed placeholder on complete image posts without dropping image data', () => {
|
||||
const value = detail({
|
||||
...observedEmptyForwardFields(),
|
||||
t1_subtype: 1,
|
||||
pic: [{ pic_id: ',SYNTHETIC-ALBUM,SYNTHETIC-LOCATION', pictype: 3, height: 96, width: 128, smallurl: 'https://photo.example/a?bo=metadata' }],
|
||||
});
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 64));
|
||||
expect(Object.fromEntries(body)).toMatchObject({
|
||||
con: ' original\ntext ',
|
||||
richtype: '1',
|
||||
subrichtype: '1',
|
||||
richval: ',SYNTHETIC-ALBUM,SYNTHETIC-LOCATION,SYNTHETIC-LOCATION,3,96,128,,0,0',
|
||||
pic_bo: 'metadata\tmetadata',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ rt_con: { conlist: null, content: ' ' } },
|
||||
{ rt_con: { conlist: null, content: 'forwarded text' } },
|
||||
{ rt_con: { conlist: [], content: '' } },
|
||||
{ rt_con: { conlist: [{}], content: '' } },
|
||||
{ rt_con: { content: '' } },
|
||||
{ rt_con: { conlist: null } },
|
||||
{ rt_con: { conlist: null, content: '', unknown: 0 } },
|
||||
{ rt_con: [{ conlist: null, content: '' }] },
|
||||
{ rt_con: 'nonempty' },
|
||||
{ rt_tid: 'forwarded' },
|
||||
{ rt_uin: 10002 },
|
||||
{ rt_source: 1 },
|
||||
{ rt_tid: undefined },
|
||||
{ rt_uin: '0' },
|
||||
{ rt_source: undefined },
|
||||
{ video: { vid: 'video-id' } },
|
||||
{ music: { url: 'https://music.example/song' } },
|
||||
{ forward: { tid: 'forwarded' } },
|
||||
{ origin: { tid: 'original' } },
|
||||
])('does not generalize the observed empty forward object to other content (%j)', (overrides) => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 1, ...overrides });
|
||||
expect(() => buildQzoneRightBody(value, '10001', 'T1', 4)).toThrow('媒体或转发内容');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pic: [{ pic_id: ',ALBUM,LOCATION', pictype: 3, height: 96, width: 128 }] },
|
||||
{ richtype: 1 },
|
||||
{ richtype: 2 },
|
||||
{ richtype: '' },
|
||||
{ subrichtype: 2 },
|
||||
{ t1_subtype: 9 },
|
||||
{ rt_con: undefined },
|
||||
])('restricts subtype 2 to the observed ordinary text layout (%j)', (overrides) => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 2, ...overrides });
|
||||
expect(() => buildQzoneRightBody(value, '10001', 'T1', 4)).toThrow();
|
||||
});
|
||||
|
||||
test('preserves all conlist segments including whitespace instead of using a truncated summary', () => {
|
||||
const value = detail({ content: 'summary', conlist: [{ con: ' \nfirst' }, { con: ' ' }, { con: 'last\n ' }] });
|
||||
expect(new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 4)).get('con')).toBe(' \nfirst last\n ');
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ content: '' }), '10001', 'T1', 4)).get('con')).toBe('');
|
||||
expect(() => buildQzoneRightBody(detail({ conlist: [{ con: 'kept' }, {}] }), '10001', 'T1', 4)).toThrow('分段不完整');
|
||||
expect(() => buildQzoneRightBody(detail({ content: undefined }), '10001', 'T1', 4)).toThrow('完整正文');
|
||||
});
|
||||
|
||||
test('preserves image order, complete richvals, bo alignment and version metadata', () => {
|
||||
const value = detail({
|
||||
conlist: [{ con: 'image text' }],
|
||||
richtype: 1,
|
||||
t1_subtype: 1,
|
||||
pic_template: 'template',
|
||||
feedversion: 3,
|
||||
ver: '2',
|
||||
code_version: 5,
|
||||
to_sign: 1,
|
||||
ugcright_id: 'right-key',
|
||||
pic: [
|
||||
{ pic_id: 'X,ALBUM1,LLOC1,extra', pictype: 22, height: 800, width: 600, url1: 'https://photo.example/a?bo=abc%2Cdef' },
|
||||
{ pic_id: 'X,ALBUM2,LLOC2,extra', type: '23', b_height: '400', b_width: '300', smallurl: 'https://photo.example/b?bo=second' },
|
||||
],
|
||||
});
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 64));
|
||||
expect(body.get('richval')).toBe(',ALBUM1,LLOC1,LLOC1,22,800,600,,0,0\t,ALBUM2,LLOC2,LLOC2,23,400,300,,0,0');
|
||||
expect(body.get('pic_bo')).toBe('abc,def,second\tabc,def,second');
|
||||
expect(Object.fromEntries(body)).toMatchObject({ con: 'image text', pic_template: 'template', feedversion: '3', ver: '2', code_version: '5', to_sign: '1', ugcright_id: 'right-key' });
|
||||
expect(value['ugc_right']).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100 }, {}], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'broken' }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X, ,L', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A, ', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L' }], richtype: 0 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 'unknown', width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: null, b_height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100, url1: 'https://photo.example/a?bo=one' }, { pic_id: 'X,A,L2', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100, url1: 'https://photo.example/a?bo=%GG' }], richtype: 1 },
|
||||
{ richval: 'unknown-media' },
|
||||
{ richtype: 2 },
|
||||
{ t1_subtype: 9 },
|
||||
{ video: { vid: 'video-id' } },
|
||||
{ music: { url: 'https://music.example/song' } },
|
||||
{ rt_tid: 'forwarded-tid' },
|
||||
{ special_url: 'https://example.com/shared' },
|
||||
])('rejects incomplete or unsupported media instead of partially resubmitting it (%j)', (overrides) => {
|
||||
expect(() => buildQzoneRightBody(detail(overrides), '10001', 'T1', 64)).toThrow();
|
||||
});
|
||||
|
||||
test('requires every existing image to retain positive known dimensions', () => {
|
||||
for (const picture of [
|
||||
{ pic_id: 'X,A,L', width: 100 },
|
||||
{ pic_id: 'X,A,L', height: 100 },
|
||||
{ pic_id: 'X,A,L', height: 0, width: 100 },
|
||||
{ pic_id: 'X,A,L', height: 100, width: '0' },
|
||||
]) {
|
||||
expect(() => buildQzoneRightBody(detail({ richtype: 1, pic: [picture] }), '10001', 'T1', 64)).toThrow();
|
||||
}
|
||||
expect(() => buildQzoneRightBody(detail({
|
||||
richtype: 1,
|
||||
pic: [
|
||||
{ pic_id: 'X,A,L1', height: 100, width: 100 },
|
||||
{ pic_id: 'X,A,L2', width: 100 },
|
||||
],
|
||||
}), '10001', 'T1', 64)).toThrow('尺寸不完整');
|
||||
});
|
||||
|
||||
test('requires the exact requested tid and current-account owner before returning a body', () => {
|
||||
expect(() => buildQzoneRightBody(detail({ tid: 'other-tid' }), '10001', 'T1', 64)).toThrow('不匹配');
|
||||
expect(() => buildQzoneRightBody(detail({ uin: '10002' }), '10001', 'T1', 64)).toThrow('自己的说说');
|
||||
expect(() => buildQzoneRightBody(detail({ uin: undefined }), '10001', 'T1', 64)).toThrow('自己的说说');
|
||||
expect(() => buildQzoneRightBody(detail({ code: -1, subcode: 0 }), '10001', 'T1', 64)).toThrow();
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ uin: 10001 }), '10001', 'T1', 4)).get('hostuin')).toBe('10001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { ChatType, NTMsgType, type RawMessage } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
|
||||
vi.mock('../napcat-webui-backend/index', () => ({
|
||||
pendingTokenToSend: undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../napcat-webui-backend/src/helper/Data', () => ({
|
||||
WebUiDataRuntime: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/config', () => ({
|
||||
OB11ConfigLoader: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network', () => ({
|
||||
OB11HttpClientAdapter: class {},
|
||||
OB11WebSocketClientAdapter: class {},
|
||||
OB11NetworkManager: class {},
|
||||
OB11NetworkReloadType: {},
|
||||
OB11HttpServerAdapter: class {},
|
||||
OB11WebSocketServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/api', () => ({
|
||||
OneBotFriendApi: class {},
|
||||
OneBotGroupApi: class {},
|
||||
OneBotMsgApi: class {},
|
||||
OneBotQuickActionApi: class {},
|
||||
OneBotUserApi: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/action', () => ({
|
||||
createActionMap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network/adapter', () => ({
|
||||
IOB11NetworkAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network/http-server-sse', () => ({
|
||||
OB11HttpSSEServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/network/plugin-manger', () => ({
|
||||
OB11PluginMangerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/napcat-onebot/api/file', () => ({
|
||||
OneBotFileApi: class {},
|
||||
}));
|
||||
|
||||
interface TestMsgListener {
|
||||
onMsgRecall (chatType: ChatType, uid: string, msgSeq: string): Promise<void> | void;
|
||||
onMsgInfoListUpdate (messages: RawMessage[]): Promise<void> | void;
|
||||
}
|
||||
|
||||
const friendUid = 'u_friend';
|
||||
const friendUin = '123456';
|
||||
const recalledMsgId = 'recalled-message-id';
|
||||
const recalledMsgSeq = '100';
|
||||
const botUin = '999999';
|
||||
|
||||
function createMessage (overrides: Partial<RawMessage>): RawMessage {
|
||||
return {
|
||||
msgId: 'message-id',
|
||||
msgRandom: '1',
|
||||
msgSeq: '101',
|
||||
cntSeq: '101',
|
||||
chatType: ChatType.KCHATTYPEC2C,
|
||||
msgType: NTMsgType.KMSGTYPEUNKNOWN,
|
||||
subMsgType: 0,
|
||||
sendStatus: 2,
|
||||
senderUid: friendUid,
|
||||
senderUin: friendUin,
|
||||
peerUid: friendUid,
|
||||
peerUin: friendUin,
|
||||
peerName: 'friend',
|
||||
sendNickName: 'friend',
|
||||
sendRemarkName: 'friend',
|
||||
msgTime: '1',
|
||||
recallTime: '0',
|
||||
elements: [],
|
||||
records: [],
|
||||
sourceType: 0,
|
||||
isOnlineMsg: true,
|
||||
...overrides,
|
||||
} as RawMessage;
|
||||
}
|
||||
|
||||
function createRecallGrayTip (): RawMessage {
|
||||
return createMessage({
|
||||
msgId: recalledMsgId,
|
||||
msgSeq: recalledMsgSeq,
|
||||
msgType: NTMsgType.KMSGTYPEGRAYTIPS,
|
||||
recallTime: Math.floor(Date.now() / 1000).toString(),
|
||||
elements: [{
|
||||
elementType: 8,
|
||||
elementId: 'recall-element',
|
||||
grayTipElement: {
|
||||
revokeElement: {
|
||||
operatorRole: '0',
|
||||
operatorUid: friendUid,
|
||||
operatorNick: 'friend',
|
||||
operatorRemark: '',
|
||||
isSelfOperate: false,
|
||||
wording: '',
|
||||
},
|
||||
},
|
||||
} as unknown as RawMessage['elements'][number]],
|
||||
});
|
||||
}
|
||||
|
||||
function createHarness (queryResult: RawMessage[], operatorUin = '654321') {
|
||||
let listener: TestMsgListener | undefined;
|
||||
const emitEvent = vi.fn(async (_event: unknown): Promise<void> => undefined);
|
||||
const queryMsgsWithFilterExWithSeq = vi.fn(async () => ({ msgList: queryResult }));
|
||||
const registerListen = vi.fn();
|
||||
const logger = {
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
};
|
||||
const context = {
|
||||
logger,
|
||||
session: {
|
||||
getMsgService: () => ({
|
||||
addKernelMsgListener: (registeredListener: TestMsgListener) => {
|
||||
listener = registeredListener;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
selfInfo: { uin: botUin },
|
||||
apis: {
|
||||
MsgApi: { queryMsgsWithFilterExWithSeq },
|
||||
UserApi: { getUinByUidV2: vi.fn(async () => operatorUin) },
|
||||
},
|
||||
eventWrapper: { registerListen },
|
||||
};
|
||||
const adapter = Object.create(NapCatOneBot11Adapter.prototype) as NapCatOneBot11Adapter;
|
||||
|
||||
Object.assign(adapter, {
|
||||
core,
|
||||
context,
|
||||
networkManager: { emitEvent },
|
||||
bootTime: Date.now() / 1000,
|
||||
recallEventCache: new Map(),
|
||||
reportedRecallEventCache: new Map(),
|
||||
processingRecallEvents: new Map(),
|
||||
});
|
||||
|
||||
(adapter as unknown as { initMsgListener (): void }).initMsgListener();
|
||||
|
||||
if (!listener) {
|
||||
throw new Error('message listener was not registered');
|
||||
}
|
||||
|
||||
return { adapter, listener, emitEvent, logger, queryMsgsWithFilterExWithSeq, registerListen };
|
||||
}
|
||||
|
||||
describe('friend recall reporting', () => {
|
||||
test('reports a recalled non-latest private message from its gray-tip update', async () => {
|
||||
const botReply = createMessage({
|
||||
msgId: 'bot-reply-id',
|
||||
msgSeq: '101',
|
||||
senderUid: 'u_bot',
|
||||
senderUin: botUin,
|
||||
});
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([botReply]);
|
||||
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
notice_type: 'friend_recall',
|
||||
user_id: Number(friendUin),
|
||||
}));
|
||||
});
|
||||
|
||||
test('deduplicates the same recall from the gray-tip update and recall callback', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([grayTip]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('deduplicates concurrent update and callback processing', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([grayTip]);
|
||||
let finishEmit: (() => void) | undefined;
|
||||
emitEvent.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishEmit = resolve;
|
||||
}));
|
||||
|
||||
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
|
||||
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
|
||||
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
finishEmit?.();
|
||||
await Promise.all([updateTask, callbackTask]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('uses a concurrent duplicate callback to retry a failed report', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent, logger } = createHarness([grayTip]);
|
||||
let failFirstEmit: (() => void) | undefined;
|
||||
emitEvent
|
||||
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
|
||||
failFirstEmit = () => reject(new Error('network unavailable'));
|
||||
}))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
|
||||
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
|
||||
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
failFirstEmit?.();
|
||||
await Promise.all([updateTask, callbackTask]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
|
||||
});
|
||||
|
||||
test('reports one self recall without waiting for a duplicate callback', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const revokeElement = grayTip.elements[0]?.grayTipElement?.revokeElement;
|
||||
if (!revokeElement) throw new Error('missing revoke element');
|
||||
revokeElement.isSelfOperate = true;
|
||||
|
||||
const { adapter, listener, emitEvent, registerListen } = createHarness([grayTip]);
|
||||
const originTimeout = setTimeout(() => undefined, 60_000);
|
||||
adapter.recallEventCache.set(recalledMsgId, originTimeout);
|
||||
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.recallEventCache.has(recalledMsgId)).toBe(false);
|
||||
expect(registerListen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('retries reporting when the first network emission fails', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent, logger } = createHarness([grayTip]);
|
||||
emitEvent
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
|
||||
});
|
||||
|
||||
test('ignores historical recall rows replayed during startup sync', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
grayTip.recallTime = Math.floor(Date.now() / 1000 - 120).toString();
|
||||
const { listener, emitEvent } = createHarness([]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores ordinary message updates', async () => {
|
||||
const ordinaryMessage = createMessage({ recallTime: Math.floor(Date.now() / 1000).toString() });
|
||||
const { listener, emitEvent } = createHarness([]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([ordinaryMessage]);
|
||||
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group recall reporting', () => {
|
||||
test('preserves group, sender and operator fields', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const groupUin = '112233';
|
||||
const senderUin = '223344';
|
||||
grayTip.chatType = ChatType.KCHATTYPEGROUP;
|
||||
grayTip.peerUid = groupUin;
|
||||
grayTip.peerUin = groupUin;
|
||||
grayTip.senderUin = senderUin;
|
||||
|
||||
const { listener, emitEvent } = createHarness([], '334455');
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
notice_type: 'group_recall',
|
||||
group_id: Number(groupUin),
|
||||
user_id: Number(senderUin),
|
||||
operator_id: 334455,
|
||||
}));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
import { OB11MessageDataSchema, OB11MessageSchema, OB11PostSendMsgSchema } from '../napcat-onebot/types/message';
|
||||
import { OB11MessageSchema as OB11ActionMessageSchema } from '../napcat-onebot/action/schemas';
|
||||
import { OneBotConfigSchema, loadConfig as loadOneBotConfig, OneBotConfig } from '../napcat-onebot/config';
|
||||
import { NapcatConfigSchema } from '../napcat-core/helper/config';
|
||||
|
||||
describe('NapCat Schemas Compilation', () => {
|
||||
test('should compile OB11MessageDataSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11MessageDataSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11MessageSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11MessageSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11ActionMessageSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11ActionMessageSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11PostSendMsgSchema without error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11PostSendMsgSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile QuickAction hybrid schema without error', () => {
|
||||
const QASchema = Type.Object({
|
||||
message: OB11PostSendMsgSchema,
|
||||
quick_action: OB11ActionMessageSchema,
|
||||
});
|
||||
expect(() => TypeCompiler.Compile(QASchema)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapCat Schemas Validation & Coercion', () => {
|
||||
test('should coerce numeric user_id to string in OB11PostSendMsgSchema', () => {
|
||||
const payload = {
|
||||
message_type: 'private',
|
||||
user_id: 123456,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
};
|
||||
|
||||
let data: unknown = structuredClone(payload);
|
||||
data = Value.Parse(OB11PostSendMsgSchema, data);
|
||||
|
||||
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
|
||||
expect(compiler.Check(data)).toBe(true);
|
||||
expect((data as Record<string, unknown>)['user_id']).toBe('123456');
|
||||
});
|
||||
|
||||
test('should validate complex mixed messages correctly', () => {
|
||||
const payload = {
|
||||
message_type: 'group',
|
||||
group_id: '654321',
|
||||
message: 'this is a string message',
|
||||
};
|
||||
|
||||
let data: unknown = structuredClone(payload);
|
||||
data = Value.Parse(OB11PostSendMsgSchema, data);
|
||||
|
||||
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
|
||||
expect(compiler.Check(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapCat Configuration Loaders', () => {
|
||||
test('OneBotConfig schemas should compile', () => {
|
||||
expect(() => TypeCompiler.Compile(OneBotConfigSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('OneBotConfig should load and apply defaults correctly', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [{
|
||||
port: 3000,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
host: '127.0.0.1',
|
||||
enableCors: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false,
|
||||
}],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.httpServers[0]?.host).toBe('127.0.0.1');
|
||||
expect(loaded.network.httpServers[0]?.enableCors).toBe(true);
|
||||
expect(loaded.musicSignUrl).toBe('');
|
||||
});
|
||||
|
||||
test('OneBotConfig websocketClients should apply verifyCertificate default', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [{
|
||||
name: 'test-ws-client',
|
||||
enable: true,
|
||||
url: 'wss://example.com',
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
reconnectInterval: 5000,
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
}],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(true);
|
||||
});
|
||||
|
||||
test('OneBotConfig websocketClients should preserve explicit verifyCertificate false', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [{
|
||||
name: 'test-ws-client',
|
||||
enable: true,
|
||||
url: 'wss://example.com',
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
reconnectInterval: 5000,
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
verifyCertificate: false,
|
||||
}],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(false);
|
||||
});
|
||||
|
||||
test('NapcatConfig should compile and apply defaults', () => {
|
||||
let compiled: ReturnType<typeof TypeCompiler.Compile> | undefined;
|
||||
expect(() => {
|
||||
compiled = TypeCompiler.Compile(NapcatConfigSchema);
|
||||
}).not.toThrow();
|
||||
|
||||
let data: unknown = {};
|
||||
data = Value.Parse(NapcatConfigSchema, data);
|
||||
|
||||
expect(compiled?.Check(data)).toBe(true);
|
||||
const resolved = data as Record<string, unknown>;
|
||||
expect(resolved['consoleLog']).toBe(true);
|
||||
expect(resolved['consoleLogLevel']).toBe('info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import SetGroupLeave from '../napcat-onebot/action/group/SetGroupLeave';
|
||||
|
||||
function createAction () {
|
||||
const quitGroup = vi.fn().mockResolvedValue(undefined);
|
||||
const destroyGroup = vi.fn().mockResolvedValue(undefined);
|
||||
const action = new SetGroupLeave(
|
||||
undefined as never,
|
||||
{
|
||||
apis: {
|
||||
GroupApi: { quitGroup, destroyGroup },
|
||||
},
|
||||
} as never
|
||||
);
|
||||
return { action, quitGroup, destroyGroup };
|
||||
}
|
||||
|
||||
describe('set_group_leave is_dismiss', () => {
|
||||
test('leaves the group when is_dismiss is absent', async () => {
|
||||
const { action, quitGroup, destroyGroup } = createAction();
|
||||
|
||||
await action._handle({ group_id: '123' });
|
||||
|
||||
expect(quitGroup).toHaveBeenCalledWith('123');
|
||||
expect(destroyGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('leaves the group when is_dismiss is false', async () => {
|
||||
const { action, quitGroup, destroyGroup } = createAction();
|
||||
|
||||
await action._handle({ group_id: '123', is_dismiss: false });
|
||||
|
||||
expect(quitGroup).toHaveBeenCalledWith('123');
|
||||
expect(destroyGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("leaves the group when is_dismiss is the string 'false'", async () => {
|
||||
const { action, quitGroup, destroyGroup } = createAction();
|
||||
|
||||
await action._handle({ group_id: '123', is_dismiss: 'false' });
|
||||
|
||||
expect(quitGroup).toHaveBeenCalledWith('123');
|
||||
expect(destroyGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('destroys the group when is_dismiss is true', async () => {
|
||||
const { action, quitGroup, destroyGroup } = createAction();
|
||||
|
||||
await action._handle({ group_id: '123', is_dismiss: true });
|
||||
|
||||
expect(destroyGroup).toHaveBeenCalledWith('123');
|
||||
expect(quitGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("destroys the group when is_dismiss is the string 'true'", async () => {
|
||||
const { action, quitGroup, destroyGroup } = createAction();
|
||||
|
||||
await action._handle({ group_id: '123', is_dismiss: 'true' });
|
||||
|
||||
expect(destroyGroup).toHaveBeenCalledWith('123');
|
||||
expect(quitGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
|
||||
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { SetQQProfile } from '../napcat-onebot/action/go-cqhttp/SetQQProfile';
|
||||
|
||||
const OLD_PROFILE = {
|
||||
sex: 1,
|
||||
longNick: 'old note',
|
||||
birthday_year: 2000,
|
||||
birthday_month: 1,
|
||||
birthday_day: 1,
|
||||
};
|
||||
|
||||
function createAction () {
|
||||
const modifySelfProfile = vi.fn().mockResolvedValue({});
|
||||
const action = new SetQQProfile(undefined as never, {
|
||||
selfInfo: { uid: 'u1' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getUserDetailInfo: vi.fn().mockResolvedValue(OLD_PROFILE),
|
||||
modifySelfProfile,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
return { action, modifySelfProfile };
|
||||
}
|
||||
|
||||
describe('set_qq_profile sex handling', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// NTSex.GENDER_UNKOWN === 0, and the payload schema documents "0: 未知".
|
||||
test('forwards sex: 0 instead of falling back to the current profile', async () => {
|
||||
const { action, modifySelfProfile } = createAction();
|
||||
|
||||
await action._handle({ nickname: 'newnick', sex: 0 });
|
||||
|
||||
expect(modifySelfProfile).toHaveBeenCalledWith(expect.objectContaining({ nick: 'newnick', sex: 0 }));
|
||||
});
|
||||
|
||||
test('forwards sex: 2', async () => {
|
||||
const { action, modifySelfProfile } = createAction();
|
||||
|
||||
await action._handle({ nickname: 'newnick', sex: 2 });
|
||||
|
||||
expect(modifySelfProfile).toHaveBeenCalledWith(expect.objectContaining({ sex: 2 }));
|
||||
});
|
||||
|
||||
test('falls back to the current profile when sex is omitted', async () => {
|
||||
const { action, modifySelfProfile } = createAction();
|
||||
|
||||
await action._handle({ nickname: 'newnick' });
|
||||
|
||||
expect(modifySelfProfile).toHaveBeenCalledWith(expect.objectContaining({ sex: OLD_PROFILE.sex }));
|
||||
});
|
||||
|
||||
test('falls back to the current profile when sex is an empty string', async () => {
|
||||
const { action, modifySelfProfile } = createAction();
|
||||
|
||||
await action._handle({ nickname: 'newnick', sex: '' });
|
||||
|
||||
expect(modifySelfProfile).toHaveBeenCalledWith(expect.objectContaining({ sex: OLD_PROFILE.sex }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { Sha1Stream } from 'napcat-core/packet/utils/crypto/sha1Stream';
|
||||
|
||||
describe('Sha1Stream', () => {
|
||||
it('should compute correct SHA-1 hash for empty string', () => {
|
||||
const sha1Stream = new Sha1Stream();
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update('').digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should compute correct SHA-1 hash for simple string', () => {
|
||||
const testData = 'Hello, World!';
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(testData));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should compute correct SHA-1 hash for binary data', () => {
|
||||
const testData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(testData);
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple update calls', () => {
|
||||
const part1 = 'Hello';
|
||||
const part2 = ', World!';
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(part1));
|
||||
sha1Stream.update(Buffer.from(part2));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(part1 + part2).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle large data correctly', () => {
|
||||
const testData = crypto.randomBytes(1024 * 10); // 10KB random data
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(testData);
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should produce consistent results for same input', () => {
|
||||
const testData = 'Consistent test data';
|
||||
|
||||
const sha1Stream1 = new Sha1Stream();
|
||||
sha1Stream1.update(Buffer.from(testData));
|
||||
const hash1 = sha1Stream1.final();
|
||||
|
||||
const sha1Stream2 = new Sha1Stream();
|
||||
sha1Stream2.update(Buffer.from(testData));
|
||||
const hash2 = sha1Stream2.final();
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should handle edge case with exact block size', () => {
|
||||
// SHA-1 block size is 64 bytes
|
||||
const testData = 'a'.repeat(64);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(testData));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle random data correctly', () => {
|
||||
// Run multiple random tests (reduced from 100000 to 100 for performance)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const randomLength = Math.floor(Math.random() * 1024);
|
||||
const randomData = crypto.randomBytes(randomLength);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(randomData);
|
||||
const hash = sha1Stream.final();
|
||||
const expectedDigest = crypto.createHash('sha1').update(randomData).digest();
|
||||
expect(hash).toEqual(expectedDigest);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const fixtures: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixture of fixtures.splice(0)) {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('type post-build replaces complete nested generic and import types', () => {
|
||||
const fixture = mkdtempSync(path.join(packageRoot, 'napcat-types/post-build-test-'));
|
||||
fixtures.push(fixture);
|
||||
mkdirSync(path.join(fixture, 'scripts'));
|
||||
mkdirSync(path.join(fixture, 'dist'));
|
||||
const script = path.join(fixture, 'scripts/post-build.mjs');
|
||||
copyFileSync(path.join(packageRoot, 'napcat-types/scripts/post-build.mjs'), script);
|
||||
writeFileSync(path.join(fixture, 'dist/fixture.d.ts'), `
|
||||
import { NapProtoDecodeStructType } from 'napcat-protobuf';
|
||||
export declare class Example {
|
||||
parse(): import('napcat-protobuf').NapProtoDecodeStructType<() => { nested: Map<string, Array<number>> }>;
|
||||
encode(): NapProtoEncodeStructType<{ fn: () => Promise<Array<string>> }>;
|
||||
validate: ValidateFunction<Map<string, Array<number>>>;
|
||||
unrelated: Promise<Array<string>>;
|
||||
}
|
||||
export declare enum ExampleKind { First = 1 }
|
||||
`);
|
||||
execFileSync(process.execPath, [script], { encoding: 'utf8' });
|
||||
const output = readFileSync(path.join(fixture, 'dist/fixture.ts'), 'utf8');
|
||||
const parsed = ts.transpileModule(output, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.Latest } });
|
||||
expect(parsed.diagnostics).toHaveLength(0);
|
||||
expect(output).toContain('parse(): any;');
|
||||
expect(output).toContain('encode(): any;');
|
||||
expect(output).toContain('validate: any;');
|
||||
expect(output).toContain('unrelated: Promise<Array<string>>;');
|
||||
expect(output).toContain('export enum ExampleKind');
|
||||
expect(output).not.toContain("import('napcat-protobuf').any");
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'napcat-protobuf';
|
||||
import { ElementType, NTVideoType } from '@/napcat-core/types/msg';
|
||||
import { SendVideoElement } from '@/napcat-core/types/element';
|
||||
import {
|
||||
PacketMsgPicElement,
|
||||
PacketMsgVideoElement,
|
||||
} from '@/napcat-core/packet/message/element';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { assertUploadResults } from '@/napcat-core/packet/context/uploadResult';
|
||||
import { Elem, MsgInfo } from '@/napcat-core/packet/transformer/proto';
|
||||
import { NTV2RichMediaReq } from '@/napcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq';
|
||||
import UploadGroupVideo from '@/napcat-core/packet/transformer/highway/UploadGroupVideo';
|
||||
import OidbBase from '@/napcat-core/packet/transformer/oidb/oidbBase';
|
||||
|
||||
function createVideoMsgInfo (): NapProtoEncodeStructType<typeof MsgInfo> {
|
||||
return {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
info: {
|
||||
fileSize: 1024,
|
||||
fileHash: '00112233445566778899aabbccddeeff',
|
||||
fileSha1: '00112233445566778899aabbccddeeff00112233',
|
||||
fileName: 'clip.mp4',
|
||||
type: {
|
||||
type: 2,
|
||||
videoFormat: NTVideoType.VIDEO_FORMAT_MP4,
|
||||
},
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
time: 12,
|
||||
},
|
||||
fileUuid: 'video-uuid',
|
||||
},
|
||||
},
|
||||
{
|
||||
index: {
|
||||
info: {
|
||||
fileSize: 2048,
|
||||
fileHash: 'ffeeddccbbaa99887766554433221100',
|
||||
fileSha1: 'ffeeddccbbaa99887766554433221100ffeeddcc',
|
||||
fileName: 'thumb.jpg',
|
||||
type: {
|
||||
type: 1,
|
||||
picFormat: 0,
|
||||
},
|
||||
width: 640,
|
||||
height: 360,
|
||||
},
|
||||
fileUuid: 'thumb-uuid',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createSendVideoElement (): SendVideoElement {
|
||||
return {
|
||||
elementType: ElementType.VIDEO,
|
||||
elementId: '',
|
||||
videoElement: {
|
||||
filePath: 'clip.mp4',
|
||||
fileName: 'clip.mp4',
|
||||
videoMd5: '00112233445566778899aabbccddeeff',
|
||||
thumbMd5: 'ffeeddccbbaa99887766554433221100',
|
||||
fileTime: 12,
|
||||
fileFormat: NTVideoType.VIDEO_FORMAT_MP4,
|
||||
fileSize: '1024',
|
||||
fileWidth: 1920,
|
||||
fileHeight: 1080,
|
||||
thumbWidth: 640,
|
||||
thumbHeight: 360,
|
||||
thumbSize: 2048,
|
||||
thumbPath: new Map([[0, 'thumb.jpg']]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeElem (
|
||||
elem: NapProtoEncodeStructType<typeof Elem>
|
||||
): NapProtoDecodeStructType<typeof Elem> {
|
||||
const codec = new NapProtoMsg(Elem);
|
||||
return codec.decode(codec.encode(elem));
|
||||
}
|
||||
|
||||
function createCommonVideoElem (businessType: 11 | 21): NapProtoDecodeStructType<typeof Elem> {
|
||||
return decodeElem({
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
businessType,
|
||||
pbElem: new NapProtoMsg(MsgInfo).encode(createVideoMsgInfo()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('packet video forwarding', () => {
|
||||
it('emits only the common rich-media element so mobile rendering is not blocked', () => {
|
||||
const video = new PacketMsgVideoElement(createSendVideoElement());
|
||||
video.msgInfo = createVideoMsgInfo();
|
||||
video.businessType = 11;
|
||||
|
||||
const elems = video.buildElement();
|
||||
|
||||
expect(elems).toHaveLength(1);
|
||||
expect(elems[0]?.videoFile).toBeUndefined();
|
||||
expect(elems[0]?.commonElem?.serviceType).toBe(48);
|
||||
expect(elems[0]?.commonElem?.businessType).toBe(11);
|
||||
expect(video.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('parses common business type 11/21 as video instead of image', () => {
|
||||
for (const businessType of [11, 21] as const) {
|
||||
const commonElem = createCommonVideoElem(businessType);
|
||||
expect(PacketMsgPicElement.parseElement(commonElem)).toBeUndefined();
|
||||
|
||||
const parsed = new PacketMsgConverter().packetMsgToRaw([commonElem]);
|
||||
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.[0].elementType).toBe(ElementType.VIDEO);
|
||||
expect(parsed[0]?.[0].picElement).toBeUndefined();
|
||||
expect(parsed[0]?.[0].videoElement).toMatchObject({
|
||||
fileName: 'clip.mp4',
|
||||
fileTime: 12,
|
||||
fileWidth: 1920,
|
||||
fileHeight: 1080,
|
||||
thumbWidth: 640,
|
||||
thumbHeight: 360,
|
||||
fileUuid: 'video-uuid',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves video metadata in the rich-media upload request', () => {
|
||||
const video = new PacketMsgVideoElement(createSendVideoElement());
|
||||
video.fileSha1 = '00112233445566778899aabbccddeeff00112233';
|
||||
video.thumbSha1 = 'ffeeddccbbaa99887766554433221100ffeeddcc';
|
||||
|
||||
const packet = UploadGroupVideo.build(123456, video);
|
||||
const envelope = OidbBase.parse(packet.data);
|
||||
const request = new NapProtoMsg(NTV2RichMediaReq).decode(envelope.body);
|
||||
const videoInfo = request.upload?.uploadInfo[0]?.fileInfo;
|
||||
const thumbInfo = request.upload?.uploadInfo[1]?.fileInfo;
|
||||
|
||||
expect(videoInfo).toMatchObject({
|
||||
fileName: 'clip.mp4',
|
||||
fileSize: 1024,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
time: 12,
|
||||
});
|
||||
expect(videoInfo?.type.videoFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
|
||||
expect(thumbInfo?.fileName).toBe('ffeeddccbbaa99887766554433221100.jpg');
|
||||
});
|
||||
|
||||
it('propagates resource upload failures instead of silently continuing', () => {
|
||||
const uploadError = new Error('thumb upload failed');
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => assertUploadResults([
|
||||
{ status: 'fulfilled', value: undefined },
|
||||
{ status: 'rejected', reason: uploadError },
|
||||
], logger)).toThrow('上传转发消息资源失败');
|
||||
expect(logger.warn).toHaveBeenCalledWith('上传资源2个,失败1个');
|
||||
expect(logger.error).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { OneBotFileApi } from '@/napcat-onebot/api/file';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NTVideoType } from '@/napcat-core/types/msg';
|
||||
|
||||
const MP4_HEADER = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18,
|
||||
0x66, 0x74, 0x79, 0x70,
|
||||
0x69, 0x73, 0x6f, 0x6d,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
|
||||
|
||||
describe('OneBot video send modes', () => {
|
||||
let tempDir: string;
|
||||
let sourceVideoPath: string;
|
||||
let uploadedVideoPath: string;
|
||||
let fileApi: OneBotFileApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'napcat-video-send-mode-'));
|
||||
sourceVideoPath = path.join(tempDir, 'clip.mp4');
|
||||
uploadedVideoPath = path.join(tempDir, 'cache', 'Ori', 'clip.mp4');
|
||||
await mkdir(path.dirname(uploadedVideoPath), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(sourceVideoPath, MP4_HEADER),
|
||||
writeFile(uploadedVideoPath, MP4_HEADER),
|
||||
]);
|
||||
|
||||
const core = {
|
||||
NapCatTempPath: tempDir,
|
||||
context: {
|
||||
logger: {
|
||||
logError: vi.fn(),
|
||||
},
|
||||
},
|
||||
apis: {
|
||||
FileApi: {
|
||||
uploadFile: vi.fn().mockResolvedValue({
|
||||
fileName: 'clip.mp4',
|
||||
path: uploadedVideoPath,
|
||||
fileSize: MP4_HEADER.length,
|
||||
md5: '00112233445566778899aabbccddeeff',
|
||||
}),
|
||||
copyFile: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof OneBotFileApi>[1];
|
||||
|
||||
fileApi = new OneBotFileApi(
|
||||
{} as ConstructorParameters<typeof OneBotFileApi>[0],
|
||||
core
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a PNG thumbnail and legacy minimal metadata for direct kernel sends', async () => {
|
||||
const getVideoInfo = vi.spyOn(FFmpegService, 'getVideoInfo').mockResolvedValue({
|
||||
width: 480,
|
||||
height: 852,
|
||||
time: 3.6,
|
||||
format: 'jpg',
|
||||
size: MP4_HEADER.length,
|
||||
filePath: sourceVideoPath,
|
||||
});
|
||||
const extractLegacyThumbnail = vi
|
||||
.spyOn(FFmpegService, 'extractLegacyVideoThumbnail')
|
||||
.mockImplementation(async (_videoPath, thumbnailPath) => {
|
||||
await writeFile(thumbnailPath, PNG_HEADER);
|
||||
});
|
||||
|
||||
const context = {
|
||||
deleteAfterSentFiles: [],
|
||||
peer: {},
|
||||
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
|
||||
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
|
||||
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
|
||||
|
||||
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, '');
|
||||
expect(extractLegacyThumbnail).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
|
||||
expect(path.extname(thumbnailPath)).toBe('.png');
|
||||
expect((await readFile(thumbnailPath)).subarray(0, PNG_HEADER.length)).toEqual(PNG_HEADER);
|
||||
expect(element.videoElement.fileTime).toBe(3.6);
|
||||
expect('fileFormat' in element.videoElement).toBe(false);
|
||||
expect('fileWidth' in element.videoElement).toBe(false);
|
||||
expect('fileHeight' in element.videoElement).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps JPEG thumbnails and complete metadata for packet forward uploads', async () => {
|
||||
const getVideoInfo = vi
|
||||
.spyOn(FFmpegService, 'getVideoInfo')
|
||||
.mockImplementation(async (videoPath, thumbnailPath) => {
|
||||
await writeFile(thumbnailPath, JPEG_HEADER);
|
||||
return {
|
||||
width: 480,
|
||||
height: 852,
|
||||
time: 3.6,
|
||||
format: 'jpg',
|
||||
size: MP4_HEADER.length,
|
||||
filePath: videoPath,
|
||||
};
|
||||
});
|
||||
const extractLegacyThumbnail = vi.spyOn(FFmpegService, 'extractLegacyVideoThumbnail');
|
||||
|
||||
const context = {
|
||||
deleteAfterSentFiles: [],
|
||||
peer: {},
|
||||
usePacketVideoMetadata: true,
|
||||
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
|
||||
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
|
||||
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
|
||||
|
||||
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
|
||||
expect(extractLegacyThumbnail).not.toHaveBeenCalled();
|
||||
expect(path.extname(thumbnailPath)).toBe('.jpg');
|
||||
expect((await readFile(thumbnailPath)).subarray(0, JPEG_HEADER.length)).toEqual(JPEG_HEADER);
|
||||
expect(element.videoElement.fileTime).toBe(4);
|
||||
expect(element.videoElement.fileFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
|
||||
expect(element.videoElement.fileWidth).toBe(480);
|
||||
expect(element.videoElement.fileHeight).toBe(852);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-test': resolve(__dirname, '.'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-shell': resolve(__dirname, '../napcat-shell'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'napcat-protobuf': resolve(__dirname, '../napcat-protobuf/NapProto.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user