Files
jackwener--opencli/clis/weread/private-api-regression.test.js
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

370 lines
17 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { log } from '@jackwener/opencli/logger';
import { fetchPrivateApi } from './utils.js';
import './shelf.js';
describe('weread private API regression', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('uses browser cookies and Node fetch for private API requests', async () => {
const mockPage = {
getCookies: vi.fn()
.mockResolvedValueOnce([
{ name: 'wr_vid', value: 'vid123', domain: 'i.weread.qq.com' },
])
.mockResolvedValueOnce([
{ name: 'wr_name', value: 'alice', domain: 'weread.qq.com' },
]),
evaluate: vi.fn(),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ title: 'Test Book', errcode: 0 }),
});
vi.stubGlobal('fetch', fetchMock);
const result = await fetchPrivateApi(mockPage, '/book/info', { bookId: '123' });
expect(result.title).toBe('Test Book');
expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/book/info?bookId=123' });
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(mockPage.evaluate).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=123', expect.objectContaining({
headers: expect.objectContaining({
Cookie: 'wr_name=alice; wr_vid=vid123',
}),
}));
});
it('merges host-only main-domain cookies into private API requests', async () => {
// Simulates host-only cookies on weread.qq.com that don't match i.weread.qq.com by URL
const mockPage = {
getCookies: vi.fn()
.mockResolvedValueOnce([]) // URL lookup returns nothing for i.weread.qq.com
.mockResolvedValueOnce([
{ name: 'wr_skey', value: 'skey-host', domain: 'weread.qq.com' },
{ name: 'wr_vid', value: 'vid-host', domain: 'weread.qq.com' },
]),
evaluate: vi.fn(),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ title: 'Book', errcode: 0 }),
});
vi.stubGlobal('fetch', fetchMock);
await fetchPrivateApi(mockPage, '/book/info', { bookId: '42' });
expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/book/info?bookId=42' });
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=42', expect.objectContaining({
headers: expect.objectContaining({
Cookie: 'wr_skey=skey-host; wr_vid=vid-host',
}),
}));
});
it('prefers API-subdomain cookies over main-domain cookies on name collision', async () => {
const mockPage = {
getCookies: vi.fn()
.mockResolvedValueOnce([
{ name: 'wr_skey', value: 'from-api', domain: 'i.weread.qq.com' },
])
.mockResolvedValueOnce([
{ name: 'wr_skey', value: 'from-main', domain: 'weread.qq.com' },
{ name: 'wr_vid', value: 'vid-main', domain: 'weread.qq.com' },
]),
evaluate: vi.fn(),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ title: 'Book', errcode: 0 }),
});
vi.stubGlobal('fetch', fetchMock);
await fetchPrivateApi(mockPage, '/book/info', { bookId: '99' });
expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=99', expect.objectContaining({
headers: expect.objectContaining({
Cookie: 'wr_skey=from-api; wr_vid=vid-main',
}),
}));
});
it('maps unauthenticated private API responses to AUTH_REQUIRED', async () => {
const mockPage = {
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ errcode: -2010, errmsg: '用户不存在' }),
}));
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
});
it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
const mockPage = {
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
}));
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
code: 'AUTH_REQUIRED',
message: 'Not logged in to WeRead',
});
});
it('maps non-auth API errors to API_ERROR', async () => {
const mockPage = {
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ errcode: -1, errmsg: 'unknown error' }),
}));
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('unknown error');
});
it('maps non-401 HTTP failures to FETCH_ERROR', async () => {
const mockPage = {
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ errmsg: 'forbidden' }),
}));
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('HTTP 403');
});
it('maps invalid JSON to PARSE_ERROR', async () => {
const mockPage = {
getCookies: vi.fn().mockResolvedValue([]),
evaluate: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
}));
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Invalid JSON');
});
it('routes weread shelf through the private API helper path', async () => {
const command = getRegistry().get('weread/shelf');
expect(command?.func).toBeTypeOf('function');
const mockPage = {
getCookies: vi.fn()
.mockResolvedValueOnce([
{ name: 'wr_vid', value: 'vid123', domain: 'i.weread.qq.com' },
])
.mockResolvedValueOnce([
{ name: 'wr_name', value: 'alice', domain: 'weread.qq.com' },
]),
evaluate: vi.fn(),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({
books: [{
title: 'Deep Work',
author: 'Cal Newport',
readingProgress: 42,
bookId: 'abc123',
}],
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await command.func(mockPage, { limit: 1 });
expect(mockPage.evaluate).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0', expect.any(Object));
expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
expect(mockPage.getCookies).toHaveBeenCalledWith({
url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0',
});
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(result).toEqual([
{
title: 'Deep Work',
author: 'Cal Newport',
progress: '42%',
bookId: 'abc123',
},
]);
});
it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
const command = getRegistry().get('weread/shelf');
expect(command?.func).toBeTypeOf('function');
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => { });
const mockPage = {
getCookies: vi.fn()
// fetchPrivateApi: URL lookup (i.weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// fetchPrivateApi: domain lookup (weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// loadWebShelfSnapshot: domain lookup for wr_vid
.mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockImplementation(async (source) => {
expect(source).toContain('shelf:rawBooks:vid-current');
expect(source).toContain('shelf:shelfIndexes:vid-current');
return {
cacheFound: true,
rawBooks: [
{
bookId: '40055543',
title: '置身事内:中国政府与经济发展',
author: '兰小欢',
},
{
bookId: '29196155',
title: '文明、现代化、价值投资与中国',
author: '李录',
},
],
shelfIndexes: [
{ bookId: '29196155', idx: 0, role: 'book' },
{ bookId: '40055543', idx: 1, role: 'book' },
],
lastChapters: {
'29196155': 40,
'40055543': 60,
},
};
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
}));
const result = await command.func(mockPage, { limit: 1 });
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith('WeRead private API auth expired; showing cached shelf data from localStorage. Results may be stale, and detail commands may still require re-login.');
expect(result).toEqual([
{
title: '文明、现代化、价值投资与中国',
author: '李录',
progress: '-',
bookId: '29196155',
},
]);
});
it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
const command = getRegistry().get('weread/shelf');
expect(command?.func).toBeTypeOf('function');
const mockPage = {
getCookies: vi.fn()
// fetchPrivateApi: URL lookup (i.weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// fetchPrivateApi: domain lookup (weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// loadWebShelfSnapshot: domain lookup for wr_vid
.mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
cacheFound: false,
rawBooks: [],
shelfIndexes: [],
lastChapters: {},
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
}));
await expect(command.func(mockPage, { limit: 20 })).rejects.toMatchObject({
code: 'AUTH_REQUIRED',
message: 'Not logged in to WeRead',
});
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
});
it('returns an empty list when the current session cache is confirmed but empty', async () => {
const command = getRegistry().get('weread/shelf');
expect(command?.func).toBeTypeOf('function');
const mockPage = {
getCookies: vi.fn()
// fetchPrivateApi: URL lookup (i.weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// fetchPrivateApi: domain lookup (weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// loadWebShelfSnapshot: domain lookup for wr_vid
.mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
cacheFound: true,
rawBooks: [],
shelfIndexes: [],
lastChapters: {},
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
}));
const result = await command.func(mockPage, { limit: 20 });
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(result).toEqual([]);
});
it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
const command = getRegistry().get('weread/shelf');
expect(command?.func).toBeTypeOf('function');
const mockPage = {
getCookies: vi.fn()
// fetchPrivateApi: URL lookup (i.weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// fetchPrivateApi: domain lookup (weread.qq.com)
.mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
// loadWebShelfSnapshot: domain lookup for wr_vid
.mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
cacheFound: true,
rawBooks: [
{
bookId: '40055543',
title: '置身事内:中国政府与经济发展',
author: '兰小欢',
},
{
bookId: '29196155',
title: '文明、现代化、价值投资与中国',
author: '李录',
},
],
shelfIndexes: [],
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
}));
const result = await command.func(mockPage, { limit: 2 });
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
expect(result).toEqual([
{
title: '置身事内:中国政府与经济发展',
author: '兰小欢',
progress: '-',
bookId: '40055543',
},
{
title: '文明、现代化、价值投资与中国',
author: '李录',
progress: '-',
bookId: '29196155',
},
]);
});
});