NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+3
View File
@@ -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');
});
});
+180
View File
@@ -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);
});
});
+130
View File
@@ -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' }));
});
});
+29
View File
@@ -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');
});
});
+346
View File
@@ -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/);
});
});
+89
View File
@@ -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'] });
});
});
+220
View File
@@ -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);
});
});
+23
View File
@@ -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:*"
}
}
+72
View File
@@ -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');
});
});
+131
View File
@@ -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');
});
});
+221
View File
@@ -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);
});
});
+182
View File
@@ -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);
});
});
+357
View File
@@ -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);
});
});
+162
View File
@@ -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">测试 &amp; 文字<br>第二行<img alt="[云]" src="https://example.test/face.gif"></div><a class="img-item" data-originurl="//cdn.example.test/full.webp?a=1&amp;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>段落一&nbsp;文字</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();
}
});
});
+225
View File
@@ -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);
});
});
+302
View File
@@ -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');
});
});
+309
View File
@@ -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
+165
View File
@@ -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 }));
});
});
+87
View File
@@ -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);
}
});
});
+14
View File
@@ -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");
});
+170
View File
@@ -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();
});
});
+131
View File
@@ -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);
});
});
+22
View File
@@ -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'),
},
},
});