/ clis / weread / private-api-regression.test.js
private-api-regression.test.js
  1  import { beforeEach, describe, expect, it, vi } from 'vitest';
  2  import { getRegistry } from '@jackwener/opencli/registry';
  3  import { log } from '@jackwener/opencli/logger';
  4  import { fetchPrivateApi } from './utils.js';
  5  import './shelf.js';
  6  describe('weread private API regression', () => {
  7      beforeEach(() => {
  8          vi.restoreAllMocks();
  9      });
 10      it('uses browser cookies and Node fetch for private API requests', async () => {
 11          const mockPage = {
 12              getCookies: vi.fn()
 13                  .mockResolvedValueOnce([
 14                  { name: 'wr_vid', value: 'vid123', domain: 'i.weread.qq.com' },
 15              ])
 16                  .mockResolvedValueOnce([
 17                  { name: 'wr_name', value: 'alice', domain: 'weread.qq.com' },
 18              ]),
 19              evaluate: vi.fn(),
 20          };
 21          const fetchMock = vi.fn().mockResolvedValue({
 22              ok: true,
 23              status: 200,
 24              json: () => Promise.resolve({ title: 'Test Book', errcode: 0 }),
 25          });
 26          vi.stubGlobal('fetch', fetchMock);
 27          const result = await fetchPrivateApi(mockPage, '/book/info', { bookId: '123' });
 28          expect(result.title).toBe('Test Book');
 29          expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
 30          expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/book/info?bookId=123' });
 31          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
 32          expect(mockPage.evaluate).not.toHaveBeenCalled();
 33          expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=123', expect.objectContaining({
 34              headers: expect.objectContaining({
 35                  Cookie: 'wr_name=alice; wr_vid=vid123',
 36              }),
 37          }));
 38      });
 39      it('merges host-only main-domain cookies into private API requests', async () => {
 40          // Simulates host-only cookies on weread.qq.com that don't match i.weread.qq.com by URL
 41          const mockPage = {
 42              getCookies: vi.fn()
 43                  .mockResolvedValueOnce([]) // URL lookup returns nothing for i.weread.qq.com
 44                  .mockResolvedValueOnce([
 45                  { name: 'wr_skey', value: 'skey-host', domain: 'weread.qq.com' },
 46                  { name: 'wr_vid', value: 'vid-host', domain: 'weread.qq.com' },
 47              ]),
 48              evaluate: vi.fn(),
 49          };
 50          const fetchMock = vi.fn().mockResolvedValue({
 51              ok: true,
 52              status: 200,
 53              json: () => Promise.resolve({ title: 'Book', errcode: 0 }),
 54          });
 55          vi.stubGlobal('fetch', fetchMock);
 56          await fetchPrivateApi(mockPage, '/book/info', { bookId: '42' });
 57          expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
 58          expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/book/info?bookId=42' });
 59          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
 60          expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=42', expect.objectContaining({
 61              headers: expect.objectContaining({
 62                  Cookie: 'wr_skey=skey-host; wr_vid=vid-host',
 63              }),
 64          }));
 65      });
 66      it('prefers API-subdomain cookies over main-domain cookies on name collision', async () => {
 67          const mockPage = {
 68              getCookies: vi.fn()
 69                  .mockResolvedValueOnce([
 70                  { name: 'wr_skey', value: 'from-api', domain: 'i.weread.qq.com' },
 71              ])
 72                  .mockResolvedValueOnce([
 73                  { name: 'wr_skey', value: 'from-main', domain: 'weread.qq.com' },
 74                  { name: 'wr_vid', value: 'vid-main', domain: 'weread.qq.com' },
 75              ]),
 76              evaluate: vi.fn(),
 77          };
 78          const fetchMock = vi.fn().mockResolvedValue({
 79              ok: true,
 80              status: 200,
 81              json: () => Promise.resolve({ title: 'Book', errcode: 0 }),
 82          });
 83          vi.stubGlobal('fetch', fetchMock);
 84          await fetchPrivateApi(mockPage, '/book/info', { bookId: '99' });
 85          expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/book/info?bookId=99', expect.objectContaining({
 86              headers: expect.objectContaining({
 87                  Cookie: 'wr_skey=from-api; wr_vid=vid-main',
 88              }),
 89          }));
 90      });
 91      it('maps unauthenticated private API responses to AUTH_REQUIRED', async () => {
 92          const mockPage = {
 93              getCookies: vi.fn().mockResolvedValue([]),
 94              evaluate: vi.fn(),
 95          };
 96          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
 97              ok: false,
 98              status: 401,
 99              json: () => Promise.resolve({ errcode: -2010, errmsg: '用户不存在' }),
100          }));
101          await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
102      });
103      it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
104          const mockPage = {
105              getCookies: vi.fn().mockResolvedValue([]),
106              evaluate: vi.fn(),
107          };
108          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
109              ok: true,
110              status: 200,
111              json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
112          }));
113          await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
114              code: 'AUTH_REQUIRED',
115              message: 'Not logged in to WeRead',
116          });
117      });
118      it('maps non-auth API errors to API_ERROR', async () => {
119          const mockPage = {
120              getCookies: vi.fn().mockResolvedValue([]),
121              evaluate: vi.fn(),
122          };
123          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
124              ok: true,
125              status: 200,
126              json: () => Promise.resolve({ errcode: -1, errmsg: 'unknown error' }),
127          }));
128          await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('unknown error');
129      });
130      it('maps non-401 HTTP failures to FETCH_ERROR', async () => {
131          const mockPage = {
132              getCookies: vi.fn().mockResolvedValue([]),
133              evaluate: vi.fn(),
134          };
135          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
136              ok: false,
137              status: 403,
138              json: () => Promise.resolve({ errmsg: 'forbidden' }),
139          }));
140          await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('HTTP 403');
141      });
142      it('maps invalid JSON to PARSE_ERROR', async () => {
143          const mockPage = {
144              getCookies: vi.fn().mockResolvedValue([]),
145              evaluate: vi.fn(),
146          };
147          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
148              ok: true,
149              status: 200,
150              json: () => Promise.reject(new SyntaxError('Unexpected token <')),
151          }));
152          await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Invalid JSON');
153      });
154      it('routes weread shelf through the private API helper path', async () => {
155          const command = getRegistry().get('weread/shelf');
156          expect(command?.func).toBeTypeOf('function');
157          const mockPage = {
158              getCookies: vi.fn()
159                  .mockResolvedValueOnce([
160                  { name: 'wr_vid', value: 'vid123', domain: 'i.weread.qq.com' },
161              ])
162                  .mockResolvedValueOnce([
163                  { name: 'wr_name', value: 'alice', domain: 'weread.qq.com' },
164              ]),
165              evaluate: vi.fn(),
166          };
167          const fetchMock = vi.fn().mockResolvedValue({
168              ok: true,
169              status: 200,
170              json: () => Promise.resolve({
171                  books: [{
172                          title: 'Deep Work',
173                          author: 'Cal Newport',
174                          readingProgress: 42,
175                          bookId: 'abc123',
176                      }],
177              }),
178          });
179          vi.stubGlobal('fetch', fetchMock);
180          const result = await command.func(mockPage, { limit: 1 });
181          expect(mockPage.evaluate).not.toHaveBeenCalled();
182          expect(fetchMock).toHaveBeenCalledWith('https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0', expect.any(Object));
183          expect(mockPage.getCookies).toHaveBeenCalledTimes(2);
184          expect(mockPage.getCookies).toHaveBeenCalledWith({
185              url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0',
186          });
187          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
188          expect(result).toEqual([
189              {
190                  title: 'Deep Work',
191                  author: 'Cal Newport',
192                  progress: '42%',
193                  bookId: 'abc123',
194              },
195          ]);
196      });
197      it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
198          const command = getRegistry().get('weread/shelf');
199          expect(command?.func).toBeTypeOf('function');
200          const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => { });
201          const mockPage = {
202              getCookies: vi.fn()
203                  // fetchPrivateApi: URL lookup (i.weread.qq.com)
204                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
205                  // fetchPrivateApi: domain lookup (weread.qq.com)
206                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
207                  // loadWebShelfSnapshot: domain lookup for wr_vid
208                  .mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
209              goto: vi.fn().mockResolvedValue(undefined),
210              evaluate: vi.fn().mockImplementation(async (source) => {
211                  expect(source).toContain('shelf:rawBooks:vid-current');
212                  expect(source).toContain('shelf:shelfIndexes:vid-current');
213                  return {
214                      cacheFound: true,
215                      rawBooks: [
216                          {
217                              bookId: '40055543',
218                              title: '置身事内:中国政府与经济发展',
219                              author: '兰小欢',
220                          },
221                          {
222                              bookId: '29196155',
223                              title: '文明、现代化、价值投资与中国',
224                              author: '李录',
225                          },
226                      ],
227                      shelfIndexes: [
228                          { bookId: '29196155', idx: 0, role: 'book' },
229                          { bookId: '40055543', idx: 1, role: 'book' },
230                      ],
231                      lastChapters: {
232                          '29196155': 40,
233                          '40055543': 60,
234                      },
235                  };
236              }),
237          };
238          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
239              ok: false,
240              status: 401,
241              json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
242          }));
243          const result = await command.func(mockPage, { limit: 1 });
244          expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
245          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
246          expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
247          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.');
248          expect(result).toEqual([
249              {
250                  title: '文明、现代化、价值投资与中国',
251                  author: '李录',
252                  progress: '-',
253                  bookId: '29196155',
254              },
255          ]);
256      });
257      it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
258          const command = getRegistry().get('weread/shelf');
259          expect(command?.func).toBeTypeOf('function');
260          const mockPage = {
261              getCookies: vi.fn()
262                  // fetchPrivateApi: URL lookup (i.weread.qq.com)
263                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
264                  // fetchPrivateApi: domain lookup (weread.qq.com)
265                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
266                  // loadWebShelfSnapshot: domain lookup for wr_vid
267                  .mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
268              goto: vi.fn().mockResolvedValue(undefined),
269              evaluate: vi.fn().mockResolvedValue({
270                  cacheFound: false,
271                  rawBooks: [],
272                  shelfIndexes: [],
273                  lastChapters: {},
274              }),
275          };
276          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
277              ok: false,
278              status: 401,
279              json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
280          }));
281          await expect(command.func(mockPage, { limit: 20 })).rejects.toMatchObject({
282              code: 'AUTH_REQUIRED',
283              message: 'Not logged in to WeRead',
284          });
285          expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
286          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
287      });
288      it('returns an empty list when the current session cache is confirmed but empty', async () => {
289          const command = getRegistry().get('weread/shelf');
290          expect(command?.func).toBeTypeOf('function');
291          const mockPage = {
292              getCookies: vi.fn()
293                  // fetchPrivateApi: URL lookup (i.weread.qq.com)
294                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
295                  // fetchPrivateApi: domain lookup (weread.qq.com)
296                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
297                  // loadWebShelfSnapshot: domain lookup for wr_vid
298                  .mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
299              goto: vi.fn().mockResolvedValue(undefined),
300              evaluate: vi.fn().mockResolvedValue({
301                  cacheFound: true,
302                  rawBooks: [],
303                  shelfIndexes: [],
304                  lastChapters: {},
305              }),
306          };
307          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
308              ok: false,
309              status: 401,
310              json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
311          }));
312          const result = await command.func(mockPage, { limit: 20 });
313          expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
314          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
315          expect(result).toEqual([]);
316      });
317      it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
318          const command = getRegistry().get('weread/shelf');
319          expect(command?.func).toBeTypeOf('function');
320          const mockPage = {
321              getCookies: vi.fn()
322                  // fetchPrivateApi: URL lookup (i.weread.qq.com)
323                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
324                  // fetchPrivateApi: domain lookup (weread.qq.com)
325                  .mockResolvedValueOnce([{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' }])
326                  // loadWebShelfSnapshot: domain lookup for wr_vid
327                  .mockResolvedValueOnce([{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' }]),
328              goto: vi.fn().mockResolvedValue(undefined),
329              evaluate: vi.fn().mockResolvedValue({
330                  cacheFound: true,
331                  rawBooks: [
332                      {
333                          bookId: '40055543',
334                          title: '置身事内:中国政府与经济发展',
335                          author: '兰小欢',
336                      },
337                      {
338                          bookId: '29196155',
339                          title: '文明、现代化、价值投资与中国',
340                          author: '李录',
341                      },
342                  ],
343                  shelfIndexes: [],
344              }),
345          };
346          vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
347              ok: false,
348              status: 401,
349              json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
350          }));
351          const result = await command.func(mockPage, { limit: 2 });
352          expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
353          expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
354          expect(result).toEqual([
355              {
356                  title: '置身事内:中国政府与经济发展',
357                  author: '兰小欢',
358                  progress: '-',
359                  bookId: '40055543',
360              },
361              {
362                  title: '文明、现代化、价值投资与中国',
363                  author: '李录',
364                  progress: '-',
365                  bookId: '29196155',
366              },
367          ]);
368      });
369  });