Files
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

386 lines
17 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import {
ArgumentError,
AuthRequiredError,
EmptyResultError,
} from '@jackwener/opencli/errors';
import './search.js';
import './shelf.js';
import './book.js';
import './notes.js';
import './review.js';
import './readdata.js';
import './discover.js';
import './list-apis.js';
function jsonResponse(body, ok = true, status = 200) {
return {
ok,
status,
json: vi.fn().mockResolvedValue(body),
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
};
}
function stubGateway(...payloads) {
const mock = vi.fn();
for (const payload of payloads) {
mock.mockResolvedValueOnce(jsonResponse(payload));
}
vi.stubGlobal('fetch', mock);
return mock;
}
beforeEach(() => {
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe('weread-official command registration', () => {
it('registers eight read-only public commands without browser dependency', () => {
const registry = getRegistry();
const expected = ['search', 'shelf', 'book', 'notes', 'review', 'readdata', 'discover', 'list-apis'];
for (const name of expected) {
const command = registry.get(`weread-official/${name}`);
expect(command, `weread-official/${name}`).toBeDefined();
expect(command.access).toBe('read');
expect(command.strategy).toBe('public');
expect(command.browser).toBe(false);
expect(command.domain).toBe('weread.qq.com');
}
});
});
describe('weread-official search', () => {
const search = () => getRegistry().get('weread-official/search').func;
it('maps scope alias, flattens params, surfaces rating + searchIdx', async () => {
const mock = stubGateway({
errcode: 0,
results: [{
title: '电子书', scope: 10, scopeCount: 2, books: [
{
searchIdx: 1, readingCount: 1000, newRating: 950,
bookInfo: { bookId: 'b1', title: '三体', author: '刘慈欣', category: '科幻', cover: 'cover1', intro: '...' },
},
{
searchIdx: 2, readingCount: 200, newRating: 0,
bookInfo: { bookId: 'b2', title: '三体II', author: '刘慈欣' },
},
],
}],
});
const rows = await search()({ keyword: '三体', scope: 'ebook' });
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ rank: 1, scope: '电子书', bookId: 'b1', title: '三体', searchIdx: 1, readingCount: 1000 });
expect(rows[0].rating).toMatch(/神作/);
expect(rows[0].link).toBe('weread://reading?bId=b1');
expect(rows[1].rating).toBe('暂无');
// verify the request body
const [, init] = mock.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.scope).toBe(10);
expect(body.keyword).toBe('三体');
expect(body.skill_version).toBeDefined();
});
it('rejects empty keyword and unknown scope before fetch', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const fn = search();
await expect(fn({ keyword: ' ' })).rejects.toBeInstanceOf(ArgumentError);
await expect(fn({ keyword: '三体', scope: 'movie' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws EmptyResultError when the gateway returns no groups', async () => {
stubGateway({ errcode: 0, results: [] });
await expect(search()({ keyword: '三体' })).rejects.toBeInstanceOf(EmptyResultError);
});
});
describe('weread-official shelf', () => {
const shelf = () => getRegistry().get('weread-official/shelf').func;
it('returns books + albums + mp entry in a single flat list', async () => {
stubGateway({
errcode: 0,
books: [
{ bookId: 'b1', title: '三体', author: '刘慈欣', category: '科幻', isTop: 1, secret: 0, finishReading: 1, readUpdateTime: 1748563200, cover: 'c1' },
],
albums: [
{
albumInfo: { albumId: 'a1', name: '红楼梦广播剧', authorName: '播主', finish: 1, cover: 'cover-a', updateTime: 1700000000 },
albumInfoExtra: { secret: 1, isTop: 0, lectureReadUpdateTime: 1748000000 },
},
],
mp: { entry: 'present' },
});
const rows = await shelf()({});
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ kind: 'book', id: 'b1', isTop: true, finished: true, secret: false });
expect(rows[0].link).toBe('weread://reading?bId=b1');
expect(rows[1]).toMatchObject({ kind: 'album', id: 'a1', title: '红楼梦广播剧', author: '播主', secret: true, finished: true });
expect(rows[2]).toMatchObject({ kind: 'mp', title: '文章收藏', secret: true });
});
it('omits mp row when mp is empty / absent', async () => {
stubGateway({ errcode: 0, books: [{ bookId: 'b1', title: 't' }], albums: [], mp: {} });
const rows = await shelf()({});
expect(rows.map((r) => r.kind)).toEqual(['book']);
});
it('throws EmptyResultError on a fully empty shelf', async () => {
stubGateway({ errcode: 0, books: [], albums: [] });
await expect(shelf()({})).rejects.toBeInstanceOf(EmptyResultError);
});
});
describe('weread-official book', () => {
const book = () => getRegistry().get('weread-official/book').func;
it('runs all three calls by default and groups output into sections', async () => {
const fetchMock = stubGateway(
{ errcode: 0, bookId: 'b1', title: '三体', author: '刘慈欣', newRating: 920, intro: 'intro', cover: 'cov' },
{ errcode: 0, chapters: [{ chapterUid: 107, chapterIdx: 1, title: 'Ch1', wordCount: 1234, level: 1, paid: 1, price: 0 }] },
{ errcode: 0, book: { chapterUid: 107, progress: 45, recordReadingTime: 3660, updateTime: 1748563200, isStartReading: 1 } },
);
const rows = await book()({ bookId: 'b1' });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(rows.find((r) => r.section === 'info' && r.key === 'title').value).toBe('三体');
expect(rows.find((r) => r.section === 'info' && r.key === 'rating').value).toMatch(/神作/);
const chapterRow = rows.find((r) => r.section === 'chapter');
expect(chapterRow).toMatchObject({ key: '107' });
expect(chapterRow.link).toBe('weread://reading?bId=b1&chapterUid=107');
const progressRow = rows.find((r) => r.section === 'progress' && r.key === 'progress');
expect(progressRow.value).toBe('45%');
const cumulative = rows.find((r) => r.section === 'progress' && r.key === 'cumulative');
expect(cumulative.value).toBe('1小时1分钟');
});
it('skips /book/chapterinfo when --no-chapters and /book/getprogress when --no-progress', async () => {
const fetchMock = stubGateway(
{ errcode: 0, bookId: 'b1', title: '三体' },
);
await book()({ bookId: 'b1', 'no-chapters': true, 'no-progress': true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects empty bookId before fetch', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(book()({ bookId: '' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('weread-official notes', () => {
const notes = () => getRegistry().get('weread-official/notes').func;
it('returns a notebooks overview with totalNotes = review + note + bookmark counts', async () => {
stubGateway({
errcode: 0,
totalBookCount: 1,
books: [{
bookId: 'b1',
book: { title: '三体', author: '刘慈欣' },
reviewCount: 3, noteCount: 12, bookmarkCount: 4,
readingProgress: 45, markedStatus: 0, sort: 1778312777,
}],
hasMore: 0,
});
const rows = await notes()({});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ kind: 'notebook', bookId: 'b1', totalNotes: 19, reviewCount: 3, noteCount: 12, bookmarkCount: 4 });
});
it('merges /book/bookmarklist + /review/list/mine when bookId is provided', async () => {
stubGateway(
{
errcode: 0,
chapters: [{ chapterUid: 107, title: '第一章' }],
updated: [{ bookmarkId: 'bm1', chapterUid: 107, markText: 'highlighted text', range: '900-2004', createTime: 1748563200 }],
},
{
errcode: 0,
reviews: [{ review: { reviewId: 'rv1', content: 'great chapter', chapterUid: 107, star: 100, isFinish: 0, createTime: 1748563200, chapterName: '第一章' } }],
},
);
const rows = await notes()({ bookId: 'b1' });
const highlight = rows.find((r) => r.kind === 'highlight');
const thought = rows.find((r) => r.kind === 'thought');
expect(highlight).toBeDefined();
expect(thought).toBeDefined();
expect(highlight.chapter).toBe('第一章');
expect(highlight.range).toBe('900-2004');
expect(highlight.link).toBe('weread://bestbookmark?bookId=b1&chapterUid=107&rangeStart=900&rangeEnd=2004');
expect(thought.thought).toBe('great chapter');
});
it('throws EmptyResultError when both bookmarks and reviews are empty for a book', async () => {
stubGateway(
{ errcode: 0, chapters: [], updated: [] },
{ errcode: 0, reviews: [] },
);
await expect(notes()({ bookId: 'b1' })).rejects.toBeInstanceOf(EmptyResultError);
});
});
describe('weread-official review', () => {
const review = () => getRegistry().get('weread-official/review').func;
it('maps the type alias to reviewListType and shapes nested reviews', async () => {
const mock = stubGateway({
errcode: 0,
reviewsCnt: 1,
reviews: [{
idx: 7,
review: {
reviewId: 'rv1',
review: {
reviewId: 'rv1',
content: 'amazing',
star: 100,
isFinish: 1,
chapterName: '终章',
createTime: 1748563200,
author: { name: 'Alice' },
},
},
}],
});
const rows = await review()({ bookId: 'b1', type: 'recommend' });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ reviewId: 'rv1', author: 'Alice', isFinish: true, chapter: '终章' });
expect(rows[0].starLabel).toBe('⭐⭐⭐⭐⭐');
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.reviewListType).toBe(1);
});
it('rejects invalid type alias', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(review()({ bookId: 'b1', type: 'bogus' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('weread-official readdata', () => {
const readdata = () => getRegistry().get('weread-official/readdata').func;
it('produces summary + longest + readStat + preferCategory rows', async () => {
stubGateway({
errcode: 0,
baseTime: 1748563200,
readDays: 22,
totalReadTime: 7320,
dayAverageReadTime: 1200,
compare: 0.2,
readLongest: [{ book: { title: '三体', author: '刘慈欣' }, readTime: 3600, tags: ['笔记最多'] }],
readStat: [{ stat: '读过', counts: '12本' }, { stat: '笔记', counts: '120条' }],
preferCategory: [{ categoryTitle: '科幻', readingTime: 7200, readingCount: 3 }],
preferAuthor: [{ name: '刘慈欣', readTime: '5小时30分钟', count: 4 }],
});
const rows = await readdata()({ mode: 'weekly' });
const summaryTotal = rows.find((r) => r.section === 'summary' && r.key === 'totalReadTime');
expect(summaryTotal.value).toBe('2小时2分钟');
expect(summaryTotal.detail).toBe('7320s');
const compare = rows.find((r) => r.section === 'summary' && r.key === 'compareToPrev');
expect(compare.value).toBe('20.0%');
expect(rows.some((r) => r.section === 'longest' && r.key === '三体')).toBe(true);
expect(rows.some((r) => r.section === 'readStat' && r.key === '读过')).toBe(true);
expect(rows.some((r) => r.section === 'preferCategory' && r.key === '科幻')).toBe(true);
expect(rows.some((r) => r.section === 'preferAuthor' && r.key === '刘慈欣')).toBe(true);
});
it('rejects unknown mode before fetch', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(readdata()({ mode: 'fortnight' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('weread-official discover', () => {
const discover = () => getRegistry().get('weread-official/discover').func;
it('calls /book/recommend when no bookId provided', async () => {
const mock = stubGateway({
errcode: 0,
books: [{ bookId: 'b1', title: '三体', author: '刘慈欣', newRating: 920, searchIdx: 1, reason: '科幻经典' }],
});
const rows = await discover()({});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ mode: 'recommend', bookId: 'b1', reason: '科幻经典' });
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.api_name).toBe('/book/recommend');
});
it('calls /book/similar when bookId given and unwraps booksimilar.books', async () => {
const mock = stubGateway({
errcode: 0,
booksimilar: {
sessionId: 'sess1',
books: [{ idx: 1, book: { bookInfo: { bookId: 'b2', title: '三体II', author: '刘慈欣' } } }],
},
});
const rows = await discover()({ bookId: 'b1' });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ mode: 'similar', bookId: 'b2', title: '三体II' });
const body = JSON.parse(mock.mock.calls[0][1].body);
expect(body.api_name).toBe('/book/similar');
expect(body.bookId).toBe('b1');
});
});
describe('weread-official list-apis', () => {
const listApis = () => getRegistry().get('weread-official/list-apis').func;
it('normalises gateway inventory shapes and appends a client SKILL_VERSION row', async () => {
stubGateway({
errcode: 0,
apis: [
{ api_name: '/store/search', description: 'search store', required: ['keyword'], optional: ['scope', 'maxIdx'] },
{ name: '/shelf/sync', summary: 'shelf', params: { required: [], optional: [] } },
],
});
const rows = await listApis()({});
expect(rows.find((r) => r.apiName === '/store/search')).toMatchObject({ required: 'keyword', optional: 'scope, maxIdx' });
const client = rows[rows.length - 1];
expect(client.apiName).toBe('(client)');
expect(client.extras).toMatch(/^SKILL_VERSION=/);
});
it('throws EmptyResultError when the gateway returns no inventory shape', async () => {
stubGateway({ errcode: 0, apis: [] });
await expect(listApis()({})).rejects.toBeInstanceOf(EmptyResultError);
});
});
describe('weread-official auth wiring', () => {
it('every command refuses to run without WEREAD_API_KEY', async () => {
vi.unstubAllEnvs();
vi.stubEnv('WEREAD_API_KEY', '');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
// pick a representative subset; each command's first call is callGateway which checks env first
const cases = [
['search', { keyword: '三体' }],
['shelf', {}],
['book', { bookId: 'b1' }],
['readdata', { mode: 'weekly' }],
['list-apis', {}],
];
for (const [name, args] of cases) {
await expect(getRegistry().get(`weread-official/${name}`).func(args)).rejects.toBeInstanceOf(AuthRequiredError);
}
expect(fetchMock).not.toHaveBeenCalled();
});
});