284 lines
17 KiB
TypeScript
284 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|