chore: fix ci

This commit is contained in:
TonyRL
2026-08-03 16:51:12 +08:00
parent 49970c8276
commit eabdca8f0c
9 changed files with 14 additions and 1054 deletions
-251
View File
@@ -1,251 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import type { Data } from '@/types';
import { route } from './routes/gov/zhengce/govall';
const apiUrl = 'https://sousuoht.www.gov.cn/athena/forward/2B22E8E39E850E17F95A016A74FCB6B673336FA8B6FEC0E2955907EF9AEE06BE';
const articleUrl = 'http://www.gov.cn/gongbao/content/2009/content_1322126.htm';
describe('gov.cn information search route', () => {
it('supports the documented legacy advanced-search parameters', async () => {
const { default: server } = await import('@/setup.test');
let requestBody: any;
server.use(
http.get('http://sousuo.gov.cn/list.htm', () => HttpResponse.text('<html></html>')),
http.post(apiUrl, async ({ request }) => {
requestBody = await request.json();
expect(request.headers.get('athenaAppKey')).toBeTruthy();
expect(request.headers.get('athenaAppName')).toBe(encodeURIComponent('国网搜索'));
return HttpResponse.json({
resultCode: {
code: 200,
},
result: {
data: {
middle: {
list: [
{
title: '中华人民共和国国务院令(第<em>555</em>号)<br>  流动人口计划生育工作条例',
title_no_tag: '中华人民共和国国务院令(第555号)<br>  流动人口计划生育工作条例',
url: articleUrl,
summary: '搜索接口摘要',
time: '2009-05-30 23:59:59',
},
],
},
},
},
});
}),
http.get(articleUrl, () => HttpResponse.text('<div id="UCAP-CONTENT"><p>文章全文</p></div>'))
);
const ctx = {
req: {
param: (name: string) => (name === 'advance' ? 'orpro=555&notpro=2&search_field=title' : undefined),
},
} as unknown as Context;
const result = (await route.handler(ctx)) as Data;
expect(requestBody).toMatchObject({
code: '17da70961a7',
dataTypeId: '107',
orderBy: 'time',
searchBy: 'title',
pageNo: 1,
pageSize: 20,
isDefaultAdvanced: 1,
isAdvancedSearch: 1,
advancedFilters: [
{
fieldName: 'containsAll',
searchWord: [],
},
{
fieldName: 'containsOne',
searchWord: ['555'],
},
{
fieldName: 'none',
searchWord: ['2'],
},
],
});
expect(result.link).toMatch(/^https:\/\/sousuo\.www\.gov\.cn\/sousuo\/search\.shtml\?/);
expect(result.item).toEqual([
{
title: '中华人民共和国国务院令(第555号) 流动人口计划生育工作条例',
link: articleUrl,
description: '<p>文章全文</p>',
pubDate: new Date('2009-05-30T15:59:59.000Z'),
},
]);
});
it('maps legacy keyword and date filters to the Athena request', async () => {
const { default: server } = await import('@/setup.test');
let requestBody: any;
server.use(
http.post(apiUrl, async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
resultCode: {
code: 200,
},
result: {
data: {
middle: {
list: [],
},
},
},
});
})
);
const ctx = {
req: {
param: (name: string) =>
name === 'advance'
? 'allpro=%E5%8C%BB%E7%96%97+%E4%BF%9D%E9%9A%9C&inpro=%E5%AE%8C%E6%95%B4+%E7%9F%AD%E8%AF%AD&orpro=%E5%8C%BB%E4%BF%9D+%E7%A4%BE%E4%BF%9D&notpro=%E6%95%99%E8%82%B2&searchfield=content&pubmintimeYear=2009&pubmintimeMonth=5&pubmaxtimeYear=2009&pubmaxtimeMonth=5'
: undefined,
},
} as unknown as Context;
await route.handler(ctx);
expect(requestBody).toMatchObject({
searchBy: 'all',
granularity: 'CUSTOM',
beginDateTime: Date.UTC(2009, 4, 1) - 8 * 60 * 60 * 1000,
endDateTime: Date.UTC(2009, 5, 1) - 8 * 60 * 60 * 1000 - 1,
advancedFilters: [
{
fieldName: 'containsAll',
searchWord: ['医疗', '保障', '完整 短语'],
},
{
fieldName: 'containsOne',
searchWord: ['医保', '社保'],
},
{
fieldName: 'none',
searchWord: ['教育'],
},
],
});
});
it('requests the latest items when advanced-search parameters are omitted', async () => {
const { default: server } = await import('@/setup.test');
let requestBody: any;
server.use(
http.post(apiUrl, async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
resultCode: {
code: 200,
},
result: {
data: {
middle: {
list: [],
},
},
},
});
})
);
const ctx = {
req: {
param: () => {},
},
} as unknown as Context;
const result = (await route.handler(ctx)) as Data;
expect(requestBody).toMatchObject({
allData: true,
pageNo: 1,
pageSize: 20,
searchBy: 'all',
});
expect(requestBody).not.toHaveProperty('advancedFilters');
expect(result.link).toContain('allData=true');
});
it('uses API content when an article page cannot be fetched', async () => {
const { default: server } = await import('@/setup.test');
const unavailableArticleUrl = 'https://www.gov.cn/test/unavailable-article.htm';
server.use(
http.post(apiUrl, () =>
HttpResponse.json({
resultCode: {
code: 200,
},
result: {
data: {
middle: {
list: [
{
title: '<em>测试</em><br/>标题',
url: unavailableArticleUrl,
content: '接口正文',
},
],
},
},
},
})
),
http.get(unavailableArticleUrl, () => new HttpResponse(null, { status: 500 }))
);
const ctx = {
req: {
param: () => {},
},
} as unknown as Context;
const result = (await route.handler(ctx)) as Data;
expect(result.item).toEqual([
{
title: '测试 标题',
link: unavailableArticleUrl,
description: '接口正文',
},
]);
});
it('reports an actionable error when the search API fails', async () => {
const { default: server } = await import('@/setup.test');
server.use(
http.post(apiUrl, () =>
HttpResponse.json({
resultCode: {
code: 1000,
},
})
)
);
const ctx = {
req: {
param: () => {},
},
} as unknown as Context;
await expect(route.handler(ctx)).rejects.toThrow('中国政府网搜索接口请求失败,错误代码:1000');
});
});
-202
View File
@@ -1,202 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { route } from '@/routes/people';
import type { Data } from '@/types';
const rootUrl = 'http://politics.people.com.cn';
const currentUrl = `${rootUrl}/GB/1024`;
const articleUrl = `${rootUrl}/n1/2026/0801/c1001-40771961.html`;
function createCtx(site: string, category = '') {
return {
req: {
param: () => ({ site, category }),
query: (name: string) => (name === 'limit' ? '1' : undefined),
},
} as unknown as Context;
}
describe('GET /people/:site?/:category?', () => {
it('uses a maintained politics page when the channel homepage is forbidden', async () => {
const { default: server } = await import('@/setup.test');
server.use(
http.get(`${rootUrl}/GB/`, () => HttpResponse.text('Forbidden', { status: 403 })),
http.get(currentUrl, () =>
HttpResponse.html(`
<html>
<head><meta charset="utf-8"><title>高层动态--时政--人民网</title></head>
<body>
<div class="jsnew_line">
<a href="/n1/2026/0801/c1001-40771961.html">时政即时新闻</a>
</div>
</body>
</html>
`)
),
http.get(articleUrl, () =>
HttpResponse.html(`
<html>
<body>
<b id="newstime">2026年08月01日10:30</b>
<div id="rm_txt_zw"><p>时政正文</p></div>
</body>
</html>
`)
)
);
const feed = (await route.handler(createCtx('politics'))) as Data;
expect(feed.title).toBe('高层动态--时政--人民网');
expect(feed.link).toBe(currentUrl);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '时政即时新闻',
link: articleUrl,
description: '<p>时政正文</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-08-01T02:30:00.000Z');
});
it('maps the retired society category to the current channel homepage', async () => {
const { default: server } = await import('@/setup.test');
const societyRootUrl = 'http://society.people.com.cn/';
const societyArticleUrl = `${societyRootUrl}n1/2026/0803/c1008-40772964.html`;
server.use(
http.get(`${societyRootUrl}GB/1008`, () => HttpResponse.text('Retired category must not be used', { status: 500 })),
http.get(societyRootUrl, () =>
HttpResponse.html(`
<html>
<head><meta charset="utf-8"><title>社会·法治--人民网</title></head>
<body>
<div class="jsnew_line">
<a href="/n1/2026/0803/c1008-40772964.html">社会即时新闻</a>
</div>
</body>
</html>
`)
),
http.get(societyArticleUrl, () =>
HttpResponse.html(`
<html>
<body>
<b id="newstime">2026年08月03日09:15</b>
<div class="rm_txt_con"><p>社会正文</p></div>
</body>
</html>
`)
)
);
const feed = (await route.handler(createCtx('society', '1008'))) as Data;
expect(feed.title).toBe('社会·法治--人民网');
expect(feed.link).toBe(societyRootUrl);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '社会即时新闻',
link: societyArticleUrl,
description: '<p>社会正文</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-08-03T01:15:00.000Z');
});
it('follows an official channel migration and resolves article links against its destination', async () => {
const { default: server } = await import('@/setup.test');
const legalRootUrl = 'http://legal.people.com.cn/';
const societyRootUrl = 'http://society.people.com.cn/';
const articleUrl = `${societyRootUrl}n1/2026/0803/c1008-40772812.html`;
server.use(
http.get(`${legalRootUrl}GB/`, () => HttpResponse.text('Legacy path must not be used', { status: 500 })),
http.get(legalRootUrl, () =>
HttpResponse.html(`
<html>
<head>
<meta http-equiv="refresh" content="0;url=${societyRootUrl}">
</head>
</html>
`)
),
http.get(societyRootUrl, () =>
HttpResponse.html(`
<html>
<head><meta charset="utf-8"><title>社会·法治--人民网</title></head>
<body>
<div class="jsnew_line">
<a href="/n1/2026/0803/c1008-40772812.html">法治即时新闻</a>
</div>
</body>
</html>
`)
),
http.get(articleUrl, () =>
HttpResponse.html(`
<html>
<body>
<b id="newstime">2026年08月03日10:20</b>
<div id="rm_txt_zw"><p>法治正文</p></div>
</body>
</html>
`)
)
);
const feed = (await route.handler(createCtx('legal'))) as Data;
expect(feed.title).toBe('社会·法治--人民网');
expect(feed.link).toBe(societyRootUrl);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '法治即时新闻',
link: articleUrl,
description: '<p>法治正文</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-08-03T02:20:00.000Z');
});
it.each(['ftp://legal.people.com.cn/file', 'http://['])('ignores an unsafe or malformed channel migration to %s', async (redirectTarget) => {
const { default: server } = await import('@/setup.test');
const legalRootUrl = 'http://legal.people.com.cn/';
const legalArticleUrl = `${legalRootUrl}n1/2026/0803/c1008-40772809.html`;
server.use(
http.get(legalRootUrl, () =>
HttpResponse.html(`
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${redirectTarget}">
<title>法治测试页--人民网</title>
</head>
<body>
<div class="jsnew_line">
<a href="/n1/2026/0803/c1008-40772809.html">法治测试新闻</a>
</div>
</body>
</html>
`)
),
http.get(legalArticleUrl, () =>
HttpResponse.html(`
<b id="newstime">2026年08月03日10:30</b>
<div id="rm_txt_zw"><p>法治测试正文</p></div>
`)
)
);
const feed = (await route.handler(createCtx('legal'))) as Data;
expect(feed.title).toBe('法治测试页--人民网');
expect(feed.link).toBe(legalRootUrl);
expect(feed.item?.[0]).toMatchObject({
title: '法治测试新闻',
link: legalArticleUrl,
description: '<p>法治测试正文</p>',
});
});
});
-75
View File
@@ -1,75 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { route } from '@/routes/people';
import type { Data } from '@/types';
const rootUrl = 'http://cpc.people.com.cn';
const currentUrl = `${rootUrl}/GB/64093/64387`;
const firstArticleUrl = `${rootUrl}/n1/2026/0803/c64387-40772759.html`;
const secondArticleUrl = `${rootUrl}/n1/2026/0803/c64387-40772755.html`;
function createCtx(limit?: string) {
return {
req: {
param: () => ({ site: 'cpc', category: '24h' }),
query: (name: string) => (name === 'limit' ? limit : undefined),
},
} as unknown as Context;
}
describe('GET /people/cpc/24h', () => {
it('uses the maintained CPC news list and modern article content selector', async () => {
const { default: server } = await import('@/setup.test');
const secondDetailRequest = vi.fn();
server.use(
http.get(currentUrl, () =>
HttpResponse.html(`
<html>
<head><meta charset="utf-8"><title>综合报道</title></head>
<body>
<div class="p2j_con02">
<div class="fl">
<ul>
<li><a href="/n1/2026/0803/c64387-40772759.html">第一条新闻</a></li>
<li><a href="/n1/2026/0803/c64387-40772755.html">第二条新闻</a></li>
</ul>
</div>
</div>
</body>
</html>
`)
),
http.get(`${rootUrl}/GB/87228`, () => HttpResponse.text('Archived page must not be used', { status: 500 })),
http.get(firstArticleUrl, () =>
HttpResponse.html(`
<html>
<body>
<b id="newstime">2026年08月03日08:15</b>
<div id="rm_txt_zw"><p>正文一</p></div>
</body>
</html>
`)
),
http.get(secondArticleUrl, () => {
secondDetailRequest();
return HttpResponse.html('<div id="rm_txt_zw"><p>正文二</p></div>');
})
);
const feed = (await route.handler(createCtx('1'))) as Data;
expect(feed.title).toBe('综合报道');
expect(feed.link).toBe(currentUrl);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '第一条新闻',
link: firstArticleUrl,
description: '<p>正文一</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-08-03T00:15:00.000Z');
expect(secondDetailRequest).not.toHaveBeenCalled();
});
});
-71
View File
@@ -1,71 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { route } from '@/routes/people';
import type { Data } from '@/types';
const rootUrl = 'http://edu.people.com.cn';
const currentUrl = `${rootUrl}/`;
const firstArticleUrl = `${rootUrl}/n1/2026/0803/c1006-40772728.html`;
const secondArticleUrl = `${rootUrl}/n1/2026/0803/c1006-40772725.html`;
function createCtx(limit?: string) {
return {
req: {
param: () => ({ site: 'edu', category: '' }),
query: (name: string) => (name === 'limit' ? limit : undefined),
},
} as unknown as Context;
}
describe('GET /people/edu', () => {
it('extracts the current education news list and applies limit before details', async () => {
const { default: server } = await import('@/setup.test');
const secondDetailRequest = vi.fn();
server.use(
http.get(`${rootUrl}/GB/`, () => HttpResponse.text('Legacy path must not be used', { status: 500 })),
http.get(currentUrl, () =>
HttpResponse.html(`
<html>
<head><meta charset="utf-8"><title>教育--人民网</title></head>
<body>
<div class="jsnew_line">
<a href="/n1/2026/0803/c1006-40772728.html">第一条教育新闻</a>
<a href="/n1/2026/0803/c1006-40772725.html">第二条教育新闻</a>
</div>
</body>
</html>
`)
),
http.get(firstArticleUrl, () =>
HttpResponse.html(`
<html>
<body>
<b id="newstime">2026年08月03日08:15</b>
<div id="rm_txt_zw"><p>教育正文</p></div>
</body>
</html>
`)
),
http.get(secondArticleUrl, () => {
secondDetailRequest();
return HttpResponse.html('<div id="rm_txt_zw"><p>第二篇正文</p></div>');
})
);
const feed = (await route.handler(createCtx('1'))) as Data;
expect(feed.title).toBe('教育--人民网');
expect(feed.link).toBe(currentUrl);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '第一条教育新闻',
link: firstArticleUrl,
description: '<p>教育正文</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-08-03T00:15:00.000Z');
expect(secondDetailRequest).not.toHaveBeenCalled();
});
});
-141
View File
@@ -1,141 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { route } from '@/routes/people/liuyan';
import type { Data } from '@/types';
const rootUrl = 'https://liuyan.people.com.cn';
const apiUrl = `${rootUrl}/threads/queryThreadsList`;
const forumUrl = `${rootUrl}/threads/list?fid=539`;
const baseItem = {
tid: 1001,
subject: '留言标题',
content: '留言正文 <script>alert(1)</script>\n第二行',
nickName: '网友甲',
dateline: 1_785_000_000,
threadsCheckTime: 1_785_000_100,
forumName: '北京市委书记',
typeName: '建言',
domainName: '交通',
stateInfo: '办理中',
answerContent: null,
answerDateline: null,
answerOrganization: null,
};
function createCtx({ id, state, limit }: { id?: string; state?: string; limit?: string } = {}) {
return {
req: {
param: (name: string) => ({ id, state })[name],
query: (name: string) => (name === 'limit' ? limit : undefined),
},
} as unknown as Context;
}
async function registerApiMock(responseData: Array<Record<string, unknown>>, success = true, expectedState = '1') {
const { default: server } = await import('@/setup.test');
const response = { result: success ? 'success' : 'error', responseData, success };
const detailRequest = vi.fn();
server.use(
http.post(apiUrl, async ({ request }) => {
expect(request.headers.get('referer')).toBe(forumUrl);
const form = await request.formData();
expect(form.get('fid')).toBe('539');
expect(form.get('state')).toBe(expectedState);
expect(form.get('lastItem')).toBe('0');
return new HttpResponse(JSON.stringify(response), {
headers: { 'Content-Type': 'text/html;charset=UTF-8' },
});
}),
http.post('http://liuyan.people.com.cn/threads/queryThreadsList', () => HttpResponse.json({}, { status: 500 })),
http.get(`${rootUrl}/threads/content`, ({ request }) => {
detailRequest(request.url);
return HttpResponse.html('<div id="app"></div>');
}),
http.get('http://liuyan.people.com.cn/threads/content', ({ request }) => {
detailRequest(request.url);
return HttpResponse.html('<div id="app"></div>');
})
);
return detailRequest;
}
describe('GET /people/liuyan/:id/:state?', () => {
it('builds escaped feed items directly from the list API and applies limit', async () => {
const detailRequest = await registerApiMock([
baseItem,
{
...baseItem,
tid: 1002,
subject: '第二条留言',
},
]);
const feed = (await route.handler(createCtx({ id: '539', limit: '1' }))) as Data;
expect(feed.title).toBe('北京市委书记 - 领导留言板 - 人民网');
expect(feed.link).toBe(`${forumUrl}#state=1`);
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '留言标题',
author: '网友甲',
link: `${rootUrl}/threads/content?tid=1001`,
category: ['北京市委书记', '建言', '交通', '办理中'],
});
expect(feed.item?.[0].description).toContain('留言正文 &lt;script&gt;alert(1)&lt;/script&gt;<br>第二行');
expect(feed.item?.[0].description).not.toContain('<script>');
expect(new Date(feed.item?.[0].pubDate ?? '').getTime()).toBe(baseItem.dateline * 1000);
expect(detailRequest).not.toHaveBeenCalled();
});
it('passes a supported state to the API and feed link', async () => {
await registerApiMock([baseItem], true, '3');
const feed = (await route.handler(createCtx({ id: '539', state: '3' }))) as Data;
expect(feed.link).toBe(`${forumUrl}#state=3`);
});
it('includes an official answer when the API provides one', async () => {
await registerApiMock([
{
...baseItem,
answerContent: '回复内容\n下一行',
answerDateline: 1_785_000_200,
answerOrganization: '北京市交通委',
},
]);
const feed = (await route.handler(createCtx({ id: '539' }))) as Data;
expect(feed.item?.[0].description).toContain('<strong>北京市交通委</strong>');
expect(feed.item?.[0].description).toContain('回复内容<br>下一行');
expect(new Date(feed.item?.[0].updated ?? '').getTime()).toBe(1_785_000_200_000);
});
it('requires a forum id instead of falling through to the generic People route', async () => {
await expect(route.handler(createCtx())).rejects.toBeInstanceOf(InvalidParameterError);
await expect(route.handler(createCtx())).rejects.toThrow('Forum id is required');
});
it('rejects a non-numeric forum id', async () => {
await expect(route.handler(createCtx({ id: 'invalid' }))).rejects.toBeInstanceOf(InvalidParameterError);
await expect(route.handler(createCtx({ id: 'invalid' }))).rejects.toThrow('Invalid forum id');
});
it('rejects an unsupported state', async () => {
await expect(route.handler(createCtx({ id: '539', state: '9' }))).rejects.toBeInstanceOf(InvalidParameterError);
await expect(route.handler(createCtx({ id: '539', state: '9' }))).rejects.toThrow('Invalid state');
});
it('reports an unsuccessful upstream response clearly', async () => {
await registerApiMock([], false);
await expect(route.handler(createCtx({ id: '539' }))).rejects.toThrow('Failed to fetch messages from the Peoples Daily message board');
});
});
-171
View File
@@ -1,171 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { route } from '@/routes/people/paper';
import type { Data } from '@/types';
import cache from '@/utils/cache';
const rootUrl = 'https://paper.people.com.cn/rmrb/pc/';
const indexUrl = `${rootUrl}layout/index.html`;
const pageOneUrl = `${rootUrl}layout/202608/03/node_01.html`;
const pageTwoUrl = `${rootUrl}layout/202608/03/node_02.html`;
const articleOneUrl = `${rootUrl}content/202608/03/content_1.html`;
const articleTwoUrl = `${rootUrl}content/202608/03/content_2.html`;
const articleThreeUrl = `${rootUrl}content/202608/03/content_3.html`;
const indexHtml = `
<ul id="list">
<li><a href="202608/03/node_01.html">第01版 要闻</a></li>
<li><a href="202608/03/node_02.html">第02版 评论</a></li>
</ul>
`;
const pageOneHtml = `
<ul class="news-list">
<li><a href="../../../content/202608/03/content_1.html">列表标题一</a></li>
<li><a href="../../../content/202608/03/content_2.html">列表标题二</a></li>
</ul>
`;
const pageTwoHtml = `
<ul class="news-list">
<li><a href="../../../content/202608/03/content_3.html">列表标题三</a></li>
</ul>
`;
function createArticleHtml(title: string, author: string, page: string) {
return `
<div class="article">
<h1>${title}</h1>
<p class="sec">
${author}
<span class="date">
《人民日报》(<span class="newstime">2026年08月03日</span> 第 ${page} 版)
</span>
</p>
<div id="ozoom">
<p>
正文 ${title}
<img src="../../../pic/202608/03/image.jpg">
<a href="../../../content/202608/03/source.html">相关链接</a>
</p>
</div>
</div>
`;
}
function createCtx(page?: string, limit?: string) {
return {
req: {
param: (name: string) => (name === 'page' ? page : undefined),
query: (name: string) => (name === 'limit' ? limit : undefined),
},
} as unknown as Context;
}
describe('GET /people/paper', () => {
beforeEach(() => cache.clients.memoryCache?.clear());
it.each([
['the default route', undefined],
['the explicit all route', 'all'],
])('aggregates all pages and applies limit before fetching article details for %s', async (_, page) => {
const { default: server } = await import('@/setup.test');
const articleThreeHandler = vi.fn(() => HttpResponse.html(createArticleHtml('正文标题三', '记者三', '02')));
server.use(
http.get(indexUrl, () => HttpResponse.html(indexHtml)),
http.get(pageOneUrl, () => HttpResponse.html(pageOneHtml)),
http.get(pageTwoUrl, () => HttpResponse.html(pageTwoHtml)),
http.get(articleOneUrl, () => HttpResponse.html(createArticleHtml('正文标题一', '记者一', '01'))),
http.get(articleTwoUrl, () => HttpResponse.html(createArticleHtml('正文标题二', '记者二', '01'))),
http.get(articleThreeUrl, articleThreeHandler)
);
const feed = (await route.handler(createCtx(page, '2'))) as Data;
expect(feed.title).toBe('人民日报电子版 - 2026年08月03日');
expect(feed.link).toBe(indexUrl);
expect(feed.item).toHaveLength(2);
expect(feed.item?.[0]).toMatchObject({
title: '正文标题一',
link: articleOneUrl,
author: '记者一',
category: ['第01版 要闻'],
});
expect(new Date(feed.item?.[0].pubDate ?? '').getFullYear()).toBe(2026);
expect(feed.item?.[0].description).toContain('正文 正文标题一');
expect(feed.item?.[0].description).toContain(`${rootUrl}pic/202608/03/image.jpg`);
expect(feed.item?.[0].description).toContain(`${rootUrl}content/202608/03/source.html`);
expect(articleThreeHandler).not.toHaveBeenCalled();
});
it('fetches only the requested page', async () => {
const { default: server } = await import('@/setup.test');
const pageOneHandler = vi.fn(() => HttpResponse.html(pageOneHtml));
server.use(
http.get(indexUrl, () => HttpResponse.html(indexHtml)),
http.get(pageOneUrl, pageOneHandler),
http.get(pageTwoUrl, () => HttpResponse.html(pageTwoHtml)),
http.get(articleThreeUrl, () => HttpResponse.html(createArticleHtml('正文标题三', '记者三', '02')))
);
const feed = (await route.handler(createCtx('02', '3'))) as Data;
expect(feed.title).toBe('人民日报电子版 - 第02版 评论 - 2026年08月03日');
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '正文标题三',
link: articleThreeUrl,
category: ['第02版 评论'],
});
expect(pageOneHandler).not.toHaveBeenCalled();
});
it('limits the default route to 30 articles before fetching details', async () => {
const { default: server } = await import('@/setup.test');
const articleLinks = Array.from({ length: 31 }, (_, index) => `<li><a href="../../../content/202608/03/default_${index + 1}.html">文章 ${index + 1}</a></li>`).join('');
const detailHandler = vi.fn(({ request }: { request: Request }) => HttpResponse.html(createArticleHtml(new URL(request.url).pathname, '记者', '01')));
server.use(
http.get(indexUrl, () => HttpResponse.html('<ul id="list"><li><a href="202608/03/node_01.html">第01版 要闻</a></li></ul>')),
http.get(pageOneUrl, () => HttpResponse.html(`<ul class="news-list">${articleLinks}</ul>`)),
http.get(new RegExp(`${rootUrl}content/202608/03/default_\\d+\\.html`), detailHandler)
);
const feed = (await route.handler(createCtx())) as Data;
expect(feed.item).toHaveLength(30);
expect(detailHandler).toHaveBeenCalledTimes(30);
});
it('keeps list metadata when one article detail request fails', async () => {
const { default: server } = await import('@/setup.test');
server.use(
http.get(indexUrl, () => HttpResponse.html('<ul id="list"><li><a href="202608/03/node_01.html">第01版 要闻</a></li></ul>')),
http.get(pageOneUrl, () => HttpResponse.html('<ul class="news-list"><li><a href="../../../content/202608/03/content_1.html">列表标题一</a></li></ul>')),
http.get(articleOneUrl, () => HttpResponse.text('upstream failure', { status: 500 }))
);
const feed = (await route.handler(createCtx())) as Data;
expect(feed.item).toHaveLength(1);
expect(feed.item?.[0]).toMatchObject({
title: '列表标题一',
link: articleOneUrl,
category: ['第01版 要闻'],
});
expect(feed.item?.[0].description).toBeUndefined();
});
it('rejects a page that is not present in the current edition', async () => {
const { default: server } = await import('@/setup.test');
server.use(http.get(indexUrl, () => HttpResponse.html(indexHtml)));
await expect(route.handler(createCtx('99'))).rejects.toBeInstanceOf(InvalidParameterError);
await expect(route.handler(createCtx('99'))).rejects.toThrow('Invalid page');
});
});
-138
View File
@@ -1,138 +0,0 @@
import type { Context } from 'hono';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { route } from '@/routes/people/xjpjh';
import type { Data } from '@/types';
import cache from '@/utils/cache';
const rootUrl = 'http://jhsjk.people.cn';
const defaultResultUrl = `${rootUrl}/result?keywords=&year=0`;
const firstArticleUrl = `${rootUrl}/article/40772030`;
const secondArticleUrl = `${rootUrl}/article/40772028`;
const thirdArticleUrl = `${rootUrl}/article/40772029`;
function createCtx({ keyword, year, limit }: { keyword?: string; year?: string; limit?: string } = {}) {
return {
req: {
param: (name: string) => ({ keyword, year })[name],
query: (name: string) => (name === 'limit' ? limit : undefined),
},
} as unknown as Context;
}
function createResultHtml() {
return `
<ul class="list_14 p1_2 clearfix" id="news_list">
<li><a href="article/40772030">第一篇讲话</a><span>[2026-08-01]</span></li>
<li><p>第一篇摘要</p></li>
<li><a href="article/40772028">第二篇讲话</a><span>[2026-07-31]</span></li>
<li><p>第二篇摘要</p></li>
<li><a href="article/40772029">第三篇讲话</a><span>[2026-07-30]</span></li>
</ul>
`;
}
function createArticleHtml(content: string, date: string) {
return `
<div class="d2txt_1 clearfix">来源:人民网 发布时间:${date}</div>
<div class="d2txt_con clearfix"><p>${content}</p></div>
`;
}
describe('GET /people/xjpjh/:keyword?/:year?', () => {
beforeEach(() => cache.clients.memoryCache?.clear());
it('selects only linked results and applies limit before fetching details', async () => {
const { default: server } = await import('@/setup.test');
const thirdDetailRequest = vi.fn();
server.use(
http.get(`${rootUrl}/result`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('keywords')).toBe('');
expect(url.searchParams.get('year')).toBe('0');
return HttpResponse.html(createResultHtml());
}),
http.get(`${rootUrl}/undefined`, () => HttpResponse.text('Summary rows must not be fetched', { status: 500 })),
http.get(firstArticleUrl, () => HttpResponse.html(createArticleHtml('第一篇正文', '2026-08-01'))),
http.get(secondArticleUrl, () => HttpResponse.html(createArticleHtml('第二篇正文', '2026-07-31'))),
http.get(thirdArticleUrl, () => {
thirdDetailRequest();
return HttpResponse.html(createArticleHtml('第三篇正文', '2026-07-30'));
})
);
const feed = (await route.handler(createCtx({ limit: '2' }))) as Data;
expect(feed.title).toBe('习近平系列重要讲话-all-all');
expect(feed.link).toBe(defaultResultUrl);
expect(feed.item).toHaveLength(2);
expect(feed.item?.[0]).toMatchObject({
title: '第一篇讲话',
link: firstArticleUrl,
description: '<p>第一篇正文</p>',
});
expect(new Date(feed.item?.[0].pubDate ?? '').toISOString()).toBe('2026-07-31T16:00:00.000Z');
expect(feed.item?.[1]).toMatchObject({
title: '第二篇讲话',
link: secondArticleUrl,
description: '<p>第二篇正文</p>',
});
expect(thirdDetailRequest).not.toHaveBeenCalled();
});
it('passes keyword and calendar year directly to the current search page', async () => {
const { default: server } = await import('@/setup.test');
const resultUrl = `${rootUrl}/result?keywords=%E7%BB%8F%E6%B5%8E&year=2026`;
server.use(
http.get(`${rootUrl}/result`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('keywords')).toBe('经济');
expect(url.searchParams.get('year')).toBe('2026');
return HttpResponse.html('<ul id="news_list"><li><a href="article/40772030">经济讲话</a></li></ul>');
}),
http.get(firstArticleUrl, () => HttpResponse.html(createArticleHtml('经济正文', '2026-08-01')))
);
const feed = (await route.handler(createCtx({ keyword: '经济', year: '2026', limit: '1' }))) as Data;
expect(feed.title).toBe('习近平系列重要讲话-经济-2026');
expect(feed.link).toBe(resultUrl);
expect(feed.item?.[0]).toMatchObject({
title: '经济讲话',
link: firstArticleUrl,
description: '<p>经济正文</p>',
});
});
it.each([
['a negative', '-1'],
['a non-numeric', 'invalid'],
['an excessive', '999'],
])('keeps detail requests within the previous maximum for %s limit', async (_, limit) => {
const { default: server } = await import('@/setup.test');
const detailRequest = vi.fn();
const links = Array.from({ length: 11 }, (__, index) => `<li><a href="article/${index + 1}">讲话 ${index + 1}</a></li>`).join('');
server.use(
http.get(`${rootUrl}/result`, () => HttpResponse.html(`<ul id="news_list">${links}</ul>`)),
http.get(new RegExp(`${rootUrl}/article/\\d+`), () => {
detailRequest();
return HttpResponse.html(createArticleHtml('讲话正文', '2026-08-01'));
})
);
const feed = (await route.handler(createCtx({ limit }))) as Data;
expect(feed.item).toHaveLength(10);
expect(detailRequest).toHaveBeenCalledTimes(10);
});
it('rejects an invalid year', async () => {
await expect(route.handler(createCtx({ keyword: 'all', year: 'invalid' }))).rejects.toBeInstanceOf(InvalidParameterError);
await expect(route.handler(createCtx({ keyword: 'all', year: 'invalid' }))).rejects.toThrow('Invalid year');
});
});
+2 -2
View File
@@ -21,7 +21,7 @@ export const findOrphanFiles = async (): Promise<string[]> => {
const entries = await fs.readdir(path.join(repoRoot, 'lib'), { recursive: true, withFileTypes: true });
const candidates = entries
.filter((entry) => excludedDirs.every((dir) => !`${entry.parentPath}${path.sep}`.startsWith(dir)))
.filter((entry) => entry.isFile() && /\.test\.tsx?$/.test(entry.name))
.filter((entry) => entry.isFile() && /\.(?:spec|test)\.tsx?$/.test(entry.name))
.map((entry) => {
const absolute = path.join(entry.parentPath, entry.name);
return { absolute, relative: path.relative(repoRoot, absolute).replaceAll('\\', '/') };
@@ -30,7 +30,7 @@ export const findOrphanFiles = async (): Promise<string[]> => {
const orphans = await Promise.all(
candidates.map(async ({ absolute, relative }) => {
const base = absolute.replace(/\.test\.tsx?$/, '');
const base = absolute.replace(/\.(?:spec|test)\.tsx?$/, '');
const [tsExists, tsxExists] = await Promise.all([fileExists(`${base}.ts`), fileExists(`${base}.tsx`)]);
return tsExists || tsxExists ? null : relative;
})
+12 -3
View File
@@ -1,3 +1,4 @@
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
@@ -6,7 +7,8 @@ const criticalFailure = 'auto: DO NOT merge';
const routeTestFailed = 'auto: not ready to review';
const allowedUser = new Set(['dependabot[bot]', 'pull[bot]']); // dependabot and downstream PR requested by pull[bot]
const requiredHeadings = ['Involved Issue / 该 PR 相关 Issue', 'Example for the Proposed Route(s) / 路由地址示例', 'New RSS Route Checklist / 新 RSS 路由检查表', 'Note / 说明'];
const routeHeading = 'Example for the Proposed Route(s) / 路由地址示例';
const routeHeading = requiredHeadings[1];
const checklistHeading = requiredHeadings[2];
const requiredHeadingDepth = 2;
/** @type {boolean} */
@@ -141,7 +143,7 @@ export default async function identify({ github, context, core }, body, number,
let routes;
if (body) {
const ast = unified().use(remarkParse).parse(body);
const ast = unified().use(remarkParse).use(remarkGfm).parse(body);
let searchStart = 0;
let searchEnd = ast.children.length;
@@ -150,8 +152,15 @@ export default async function identify({ github, context, core }, body, number,
/** @param {string} text */
const findHeading = (text) => ast.children.findIndex((node) => node.type === 'heading' && node.depth === requiredHeadingDepth && node.children?.some((child) => child.type === 'text' && child.value.trim() === text));
/** @param {string} text */
const missingChecklist = (text) => {
const headingIndex = findHeading(text);
const nextHeading = ast.children.findIndex((node, i) => i > headingIndex && node.type === 'heading');
return ast.children.slice(headingIndex + 1, nextHeading === -1 ? undefined : nextHeading).every((node) => node.type !== 'list' || !node.children?.some((item) => typeof item.checked === 'boolean'));
};
const missingHeadings = requiredHeadings.filter((text) => findHeading(text) === -1);
if (missingHeadings.length > 0) {
if (missingHeadings.length > 0 || missingChecklist(checklistHeading)) {
searchStart = -1; // skip search
} else {
const headingIndex = findHeading(routeHeading);