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 });