import { afterEach, describe, expect, it, vi } from 'vitest'; import { getQzoneFeeds } from '../../src/web/qzone'; import { RequestUtil } from '../../src/web/request-util'; import { parseQzoneFeedContent } from '../../src/web/qzone-feed-content'; const cookies = { p_skey: 'fixture' }; function page(key: string, more: boolean, state: Record = {}) { return `_preloadCallback(${JSON.stringify({ code: 0, data: { main: { hasMoreFeeds: more, error: '', begintime: '1700000000', dayspac: '5', pagenum: '2', externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b', ...state, }, data: [ { uin: '10002', nickname: 'Reader', abstime: '1700000001', appid: '311', key, html: `
  • Hello & friend
    Second line[笑]
    赞
    Another person's comment
  • `, commentcnt: '3', }, null, ], }, })});`; } describe('Qzone friend feed continuation', () => { afterEach(() => vi.restoreAllMocks()); it('uses server continuation state and preserves structured content alongside legacy fields', async () => { const request = vi .spyOn(RequestUtil, 'HttpGetText') .mockResolvedValueOnce( page('first', true, { aisortEndTime: '17', aisortOffset: '4', aisortBeginTime: '11', aisortNextTime: '2', icServerTime: '1700000010', daylist: 'd', uinlist: '10002', }), ) .mockResolvedValueOnce(page('second', false)); const first = await getQzoneFeeds(cookies, '10001'); expect(first.has_more).toBe(true); expect(first.next_cursor).toEqual(expect.any(String)); expect(first.feeds[0]).toMatchObject({ uin: 10002, appid: 311, key: 'first', tid: 'first', content: 'Hello & friend\nSecond line[笑]', content_complete: true, images: ['https://example.com/full.jpg?a=1&b=2'], like_count: 12, comment_count: 3, html: expect.stringContaining('Another person'), }); const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!); expect(second).toMatchObject({ has_more: false, next_cursor: null }); expect(second.feeds[0].key).toBe('second'); const query = new URL(request.mock.calls[1][0]).searchParams; expect(Object.fromEntries(query)).toMatchObject({ uin: '10001', pagenum: '2', begintime: '1700000000', dayspac: '5', externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b', aisortEndTime: '17', aisortOffset: '4', aisortBeginTime: '11', getAisort: '2', icServerTime: '1700000010', daylist: 'd', uinlist: '10002', refresh: '0', }); expect(request).toHaveBeenCalledTimes(2); }); it('resolves a legacy page number by traversing continuations, not restarting at that number', async () => { const request = vi .spyOn(RequestUtil, 'HttpGetText') .mockResolvedValueOnce(page('first', true)) .mockResolvedValueOnce(page('second', false)); const result = await getQzoneFeeds(cookies, '10001', 2, 20); expect(result.feeds[0].key).toBe('second'); expect(request.mock.calls.map(([url]) => new URL(url).searchParams.get('pagenum'))).toEqual( ['1', '2'], ); }); it('rejects stalled continuation instead of looping or silently ending the list', async () => { const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('same', true)); await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow(/未推进|advance/); expect(request).toHaveBeenCalledTimes(2); }); it('keeps empty intermediate pages traversable and stops at the real end', async () => { const empty = JSON.parse(page('unused', true).slice(17, -2)); empty.data.data = [null]; const request = vi .spyOn(RequestUtil, 'HttpGetText') .mockResolvedValueOnce(JSON.stringify(empty)) .mockResolvedValueOnce(page('last', false)); const first = await getQzoneFeeds(cookies, '10001'); expect(first.feeds).toEqual([]); expect(first.has_more).toBe(true); expect( (await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!)).feeds[0].key, ).toBe('last'); request.mockReset().mockResolvedValue(page('last', false)); expect(await getQzoneFeeds(cookies, '10001', 8)).toEqual({ feeds: [], has_more: false, next_cursor: null, }); expect(request).toHaveBeenCalledTimes(1); }); it('rejects foreign-account, corrupt and mismatched-page cursors before HTTP', async () => { const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('first', true)); const first = await getQzoneFeeds(cookies, '10001'); request.mockClear(); await expect(getQzoneFeeds(cookies, '10002', 1, 10, first.next_cursor!)).rejects.toThrow( '账号', ); await expect(getQzoneFeeds(cookies, '10001', 3, 10, first.next_cursor!)).rejects.toThrow( '不匹配', ); for (const cursor of ['', 'bad!', 'e30', 'a'.repeat(65537)]) { await expect(getQzoneFeeds(cookies, '10001', 1, 10, cursor)).rejects.toThrow('cursor'); } const raw = JSON.parse(Buffer.from(first.next_cursor!, 'base64url').toString()); raw.state.url = 'https://example.com/foreign'; await expect( getQzoneFeeds( cookies, '10001', 1, 10, Buffer.from(JSON.stringify(raw)).toString('base64url'), ), ).rejects.toThrow('cursor'); expect(request).not.toHaveBeenCalled(); }); it('preserves opaque parameters and sort state when a later page omits optional fields', async () => { const request = vi .spyOn(RequestUtil, 'HttpGetText') .mockResolvedValueOnce(page('first', true, { aisortOffset: '9', aisortNextTime: '2' })) .mockResolvedValueOnce( page('second', true, { pagenum: '3', begintime: '1699999000', externparam: '', }), ) .mockResolvedValueOnce(page('third', false)); const first = await getQzoneFeeds(cookies, '10001'); const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!); await getQzoneFeeds(cookies, '10001', 1, 10, second.next_cursor!); const query = new URL(request.mock.calls[2][0]).searchParams; expect(query.get('aisortOffset')).toBe('9'); expect(query.get('getAisort')).toBe('2'); expect(query.get('externparam')).toBe('basetime=1700000000&pagenum=2&opaque=a%26b'); }); it('does not regard the changing server clock as pagination progress', async () => { vi.spyOn(RequestUtil, 'HttpGetText') .mockResolvedValueOnce(page('first', true, { icServerTime: '20' })) .mockResolvedValueOnce(page('first', true, { icServerTime: '21' })); await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow('未推进'); }); it.each([ { code: 0, subcode: -1, data: { data: [] } }, { code: 0, data: { main: { error: 'more', hasMoreFeeds: false }, data: [] } }, { code: 0, data: { main: { hasMoreFeeds: 'false' }, data: [] } }, { code: 0, data: { main: { hasMoreFeeds: true }, data: [] } }, { code: 0, data: { main: { hasMoreFeeds: true, pagenum: {} }, data: [] } }, { code: 0, data: { hasmore: 1, data: [] } }, { code: 0, data: { main: [], data: [] } }, { code: 0, data: { main: { hasMoreFeeds: false }, data: [false] } }, { code: 0, data: { main: { hasMoreFeeds: false }, data: [{ uin: 'broken' }] } }, ])( 'exposes malformed or failed responses instead of returning an empty success (%j)', async (response) => { vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(response)); await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow(); }, ); it('propagates transport failures without retrying or resetting pagination', async () => { const request = vi .spyOn(RequestUtil, 'HttpGetText') .mockRejectedValue(new Error('network unavailable')); await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow('network unavailable'); expect(request).toHaveBeenCalledTimes(1); }); }); describe('Qzone friend feed content', () => { function parse(body: string, like?: unknown, comments?: unknown) { return parseQzoneFeedContent( `
    ${body}
    `, 'post', 10002, 311, like, comments, ); } it('marks excerpts as incomplete, removes expansion controls and prefers the full body when present', () => { const excerpt = '
    Start展开全文
    '; expect(parse(excerpt + '
    ')).toMatchObject({ content: 'Start', content_complete: false, }); expect( parse(excerpt + '
    Start and finish
    '), ).toMatchObject({ content: 'Start and finish', content_complete: true }); }); it('preserves explicit zero, distinguishes unknown values and never estimates counts from visible comments', () => { expect(parse('
    ', '0', 0)).toMatchObject({ content: '', content_complete: true, images: [], like_count: 0, comment_count: 0, }); expect(parse('
    one
    ', '', 'many')).toMatchObject({ content: null, content_complete: null, like_count: null, comment_count: null, }); expect( parseQzoneFeedContent('
    unrecognised template
    ', 'post', 10002, 311), ).toMatchObject({ tid: null, content: null, images: null }); }); it('excludes video covers, nested reposts, avatars and comment pictures', () => { expect( parse(`
    repost
    `), ).toMatchObject({ content: null, images: ['https://example.com/photo.jpg?a=1&b=2'] }); }); it('parses markup as inert data and decodes entities without including script or style text', () => { expect( parse( '
    <b>😀

    Next

    ', ), ).toMatchObject({ content: '😀\nNext' }); }); });