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

425 lines
19 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './utils.js';
import { __test__ as priceTest } from './price.js';
import { __test__ as trainTest } from './train.js';
import { __test__ as trainsTest } from './trains.js';
import './orders.js';
const { parseStationBundle, resolveStation, validateDate, buildCookieHeader, parseTrainRecord, maskEmail, maskMobile, maskChineseName, unwrapEvaluateResult, requireEvaluateObject, isAuthLikePayload } = __test__;
const { parsePriceData, queryStopsForPrice, queryPrice, TRAIN_NO_RE: PRICE_TRAIN_NO_RE } = priceTest;
const { queryStops, TRAIN_NO_RE: TRAIN_TRAIN_NO_RE } = trainTest;
const { queryLeftTickets, extractQueryEndpoint } = trainsTest;
afterEach(() => {
vi.unstubAllGlobals();
});
describe('12306 utils - parseStationBundle', () => {
it('parses the `@`-delimited station bundle into structured records', () => {
const bundle = "var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bji|北京|BJP|beijing|bj|2|0357|北京|||@aoh|上海虹桥|AOH|shanghaihongqiao|shhq|10|7600|上海|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(3);
expect(stations[1]).toEqual({
short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京',
});
});
it('skips records that lack a telecode', () => {
const bundle = "var station_names ='@xxx|||||||||@bji|北京|BJP|beijing|bj|2|0357|北京|||';";
const stations = parseStationBundle(bundle);
expect(stations).toHaveLength(1);
expect(stations[0].code).toBe('BJP');
});
it('throws CommandExecutionError when the bundle has no parseable station rows', () => {
expect(() => parseStationBundle("var station_names ='@xxx|||||||||';")).toThrow(CommandExecutionError);
});
});
describe('12306 utils - resolveStation', () => {
const stations = [
{ short: 'bjb', name: '北京北', code: 'VAP', pinyin: 'beijingbei', abbr: 'bjb', city: '北京' },
{ short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京' },
{ short: 'aoh', name: '上海虹桥', code: 'AOH', pinyin: 'shanghaihongqiao', abbr: 'shhq', city: '上海' },
];
it('matches by exact Chinese name', () => {
expect(resolveStation(stations, '上海虹桥').code).toBe('AOH');
});
it('matches by uppercase telecode', () => {
expect(resolveStation(stations, 'BJP').code).toBe('BJP');
});
it('matches by full pinyin (case-insensitive)', () => {
expect(resolveStation(stations, 'Beijing').code).toBe('BJP');
});
it('matches by short alias / abbr', () => {
expect(resolveStation(stations, 'shhq').code).toBe('AOH');
});
it('throws ArgumentError for empty input', () => {
expect(() => resolveStation(stations, ' ')).toThrow(ArgumentError);
});
it('throws ArgumentError for unknown station', () => {
expect(() => resolveStation(stations, '某不存在站')).toThrow(ArgumentError);
});
it('throws ArgumentError for telecode-shaped but unknown input', () => {
expect(() => resolveStation(stations, 'XYZ')).toThrow(ArgumentError);
});
});
describe('12306 utils - validateDate', () => {
it('accepts valid YYYY-MM-DD', () => {
expect(validateDate('2026-05-22')).toBe('2026-05-22');
});
it('throws ArgumentError on wrong format', () => {
expect(() => validateDate('2026/05/22')).toThrow(ArgumentError);
expect(() => validateDate('26-05-22')).toThrow(ArgumentError);
expect(() => validateDate('today')).toThrow(ArgumentError);
expect(() => validateDate('')).toThrow(ArgumentError);
});
it('throws ArgumentError on impossible calendar dates', () => {
expect(() => validateDate('2026-02-30')).toThrow(ArgumentError);
expect(() => validateDate('2026-13-01')).toThrow(ArgumentError);
});
});
describe('12306 utils - buildCookieHeader', () => {
it('joins set-cookie lines into a single Cookie header', () => {
const headers = [
'JSESSIONID=ABC123; Path=/otn',
'BIGipServerotn=xxx.yyy; Path=/',
'route=zzz; Expires=Sat, 01 Jan 2027 00:00:00 GMT',
];
expect(buildCookieHeader(headers)).toBe('JSESSIONID=ABC123; BIGipServerotn=xxx.yyy; route=zzz');
});
it('returns empty string for empty input', () => {
expect(buildCookieHeader([])).toBe('');
expect(buildCookieHeader(undefined)).toBe('');
});
});
describe('12306 utils - parseTrainRecord', () => {
const stationByCode = new Map([
['VNP', { name: '北京南', code: 'VNP' }],
['AOH', { name: '上海虹桥', code: 'AOH' }],
]);
it('extracts the canonical train fields from a wire record', () => {
// 33 `|`-separated fields, with positions used by parseTrainRecord populated.
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN';
fields[1] = '预订';
fields[2] = '240000G54700';
fields[3] = 'G547';
fields[6] = 'VNP';
fields[7] = 'AOH';
fields[8] = '06:18';
fields[9] = '12:11';
fields[10] = '05:53';
fields[11] = 'Y';
fields[23] = ''; // soft sleeper
fields[26] = '无'; // no seat
fields[28] = ''; // hard sleeper
fields[29] = ''; // hard seat
fields[30] = '有'; // second seat
fields[31] = '有'; // first seat
fields[32] = '无'; // business seat
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row).toEqual({
train_no: '240000G54700',
code: 'G547',
from_station: '北京南',
to_station: '上海虹桥',
from_code: 'VNP',
to_code: 'AOH',
start_time: '06:18',
arrive_time: '12:11',
duration: '05:53',
available: true,
business_seat: '无',
first_seat: '有',
second_seat: '有',
soft_sleeper: '',
hard_sleeper: '',
hard_seat: '',
no_seat: '无',
});
});
it('does not expose the booking-handshake secret token', () => {
const fields = new Array(36).fill('');
fields[0] = 'SECRET_TOKEN_DO_NOT_LEAK';
fields[2] = 't_no'; fields[3] = 'X1'; fields[6] = 'VNP'; fields[7] = 'AOH';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(Object.values(row)).not.toContain('SECRET_TOKEN_DO_NOT_LEAK');
expect('secret' in row).toBe(false);
});
it('falls back to the telecode when the station bundle has no name', () => {
const fields = new Array(36).fill('');
fields[2] = 'X'; fields[3] = 'X'; fields[6] = 'ZZZ'; fields[7] = 'YYY';
const row = parseTrainRecord(fields.join('|'), stationByCode);
expect(row.from_station).toBe('ZZZ');
expect(row.to_station).toBe('YYY');
});
it('returns null for short records', () => {
expect(parseTrainRecord('a|b|c', stationByCode)).toBeNull();
});
});
describe('12306 utils - mask helpers', () => {
it('masks the local-part of an email', () => {
expect(maskEmail('hello@example.com')).toBe('h***o@example.com');
expect(maskEmail('ab@x.cn')).toBe('a*@x.cn');
expect(maskEmail('a@x.cn')).toBe('a*@x.cn');
expect(maskEmail('')).toBe('');
expect(maskEmail('not-an-email')).toBe('not-an-email');
});
it('masks Chinese mobile numbers while preserving 12306-side masks', () => {
expect(maskMobile('13800001234')).toBe('138****1234');
expect(maskMobile('138****1234')).toBe('138****1234');
expect(maskMobile('')).toBe('');
expect(maskMobile('123')).toBe('**3');
});
it('masks Chinese real names', () => {
expect(maskChineseName('张三')).toBe('张*');
expect(maskChineseName('李四明')).toBe('李*明');
expect(maskChineseName('欧阳锋')).toBe('欧*锋');
expect(maskChineseName('张')).toBe('张');
expect(maskChineseName('')).toBe('');
});
});
describe('12306 price - parsePriceData', () => {
it('returns seat rows sorted by descending price and drops dup numeric codes', () => {
const data = {
train_no: '24000000G10L',
'OT': [],
'A9': '¥2158.0',
'9': '21580',
'P': '¥1163.0',
'M': '¥1035.0',
'O': '¥626.0',
'WZ': '¥626.0',
'INVALID': 'not-a-price',
};
const rows = parsePriceData(data);
const codes = rows.map((r) => r.seat_code);
expect(codes).not.toContain('9');
expect(codes).not.toContain('OT');
expect(codes).not.toContain('train_no');
expect(codes).not.toContain('INVALID');
expect(codes).toEqual(['A9', 'P', 'M', 'O', 'WZ']);
expect(rows[0]).toEqual({ seat_code: 'A9', seat_name: '商务座', price: '2158.0', currency: 'CNY' });
expect(rows[4]).toEqual({ seat_code: 'WZ', seat_name: '无座', price: '626.0', currency: 'CNY' });
});
it('keeps unknown letter codes with the letter as the name', () => {
const data = { 'A9': '¥100.0', 'ZZ': '¥50.0' };
const rows = parsePriceData(data);
const zz = rows.find((r) => r.seat_code === 'ZZ');
expect(zz?.seat_name).toBe('ZZ');
});
});
describe('12306 train_no validation regex', () => {
// 12306 train_no values returned by /otn/leftTicket/query sometimes contain
// lowercase letters (e.g. "5l000G1970A3" for G1970 上海虹桥 -> 宝鸡南).
// Both `12306 price` and `12306 train` must accept the raw value emitted
// by `12306 trains`, otherwise the two adapters drift apart and downstream
// calls fail with ARGUMENT before ever hitting 12306.
for (const [label, re] of [['price', PRICE_TRAIN_NO_RE], ['train', TRAIN_TRAIN_NO_RE]]) {
describe(label, () => {
it('accepts an all-uppercase train_no', () => {
expect(re.test('24000000G10L')).toBe(true);
});
it('accepts a train_no with lowercase letters (real 12306 payload)', () => {
expect(re.test('5l000G1970A3')).toBe(true);
});
it('rejects public codes like G1970', () => {
expect(re.test('G1970')).toBe(false);
});
it('rejects values with disallowed characters', () => {
expect(re.test('5l000-G1970A3')).toBe(false);
});
});
}
});
describe('12306 public API typed boundaries', () => {
const nonJsonFetch = async () => ({
ok: true,
json: async () => {
throw new SyntaxError('Unexpected token <');
},
});
it('wraps non-JSON train stop bodies as CommandExecutionError', async () => {
await expect(queryStops('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('wraps non-JSON price helper bodies as CommandExecutionError', async () => {
await expect(queryStopsForPrice('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(queryPrice('cookie=1', '24000000G10L', '01', '02', 'OM9', '2026-05-22', nonJsonFetch))
.rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('12306 trains endpoint rotation', () => {
const successBody = { data: { result: ['row|payload'] } };
it('extracts only leftTicket query endpoints from rotation hints', () => {
expect(extractQueryEndpoint('leftTicket/queryB')).toBe('queryB');
expect(extractQueryEndpoint('/otn/leftTicket/queryC')).toBe('queryC');
expect(extractQueryEndpoint('https://kyfw.12306.cn/otn/leftTicket/queryD')).toBe('queryD');
expect(extractQueryEndpoint('/otn/error.html')).toBe('');
expect(extractQueryEndpoint('https://example.com/leftTicket/queryB')).toBe('');
expect(extractQueryEndpoint('leftTicket/querybad')).toBe('');
});
it('follows a 302 JSON c_url rotation signal before trying fallback endpoints', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryB' }), { status: 302 }))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
});
it('follows a 302 Location header rotation signal when the body is not JSON', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('<html>redirect</html>', {
status: 302,
headers: { location: '/otn/leftTicket/queryB' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryB?');
});
it('typed-fails a 302 that does not identify a leftTicket query endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('<html>error</html>', {
status: 302,
headers: { location: '/otn/error.html' },
}));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22'))
.rejects.toBeInstanceOf(CommandExecutionError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('deduplicates rotation endpoints request-locally and keeps fallback bounded', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ c_url: 'leftTicket/queryG' }), { status: 302 }))
.mockResolvedValueOnce(new Response(JSON.stringify(successBody), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(queryLeftTickets('cookie=1', 'BJP', 'AOH', '2026-05-22')).resolves.toEqual(['row|payload']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toContain('/leftTicket/queryG?');
expect(fetchMock.mock.calls[1][0]).toContain('/leftTicket/queryO?');
});
});
describe('12306 browser evaluate boundaries', () => {
it('unwraps Browser Bridge {session,data} evaluate envelopes only at the boundary', () => {
expect(unwrapEvaluateResult({ session: 's1', data: 'JSESSIONID=1; tk=2' })).toBe('JSESSIONID=1; tk=2');
expect(unwrapEvaluateResult({ status: true, data: { value: 1 } })).toEqual({ status: true, data: { value: 1 } });
expect(requireEvaluateObject({ session: 's1', data: { status: true } }, 'test')).toEqual({ status: true });
expect(() => requireEvaluateObject({ session: 's1', data: null }, 'test')).toThrow(CommandExecutionError);
});
it('classifies 12306 login-like API envelopes as auth failures', () => {
expect(isAuthLikePayload({ status: false, messages: ['用户未登录'] })).toBe(true);
expect(isAuthLikePayload({ status: false, validateMessages: { global: ['请登录后再试'] } })).toBe(true);
expect(isAuthLikePayload({ status: false, messages: ['系统繁忙'] })).toBe(false);
});
it('masks passenger names in orders by default and supports explicit sensitive opt-in', async () => {
const command = getRegistry().get('12306/orders');
const makePage = () => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return { session: 'browser', data: 'JSESSIONID=abc; tk=def' };
return {
session: 'browser',
data: {
status: true,
data: {
orderDBList: [{
sequence_no: 'E123',
order_date: '2026-05-18 10:00',
train_code_page: 'G1',
from_station_name_page: '北京南',
to_station_name_page: '上海虹桥',
start_train_date_page: '2026-05-22 07:00',
ticket_status_name: '未出行',
ticket_total_price_page: '626.0',
tickets: [{ passenger_name: '张三' }, { passenger_name: '李四明' }],
}],
},
},
};
},
});
await expect(command.func(makePage(), {})).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张*, 李*明' },
]);
await expect(command.func(makePage(), { 'include-sensitive': true })).resolves.toMatchObject([
{ order_id: 'E123', passengers: '张三, 李四明' },
]);
});
it('maps login-like order payloads to AuthRequiredError instead of parser drift', async () => {
const command = getRegistry().get('12306/orders');
const page = {
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return { status: false, messages: ['用户未登录'] };
},
};
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
});
it('treats missing order list shape as parser drift but known empty arrays as empty result', async () => {
const command = getRegistry().get('12306/orders');
const makePage = (payload) => ({
goto: async () => {},
evaluate: async (script) => {
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
return payload;
},
});
await expect(command.func(makePage({ status: true, data: {} }), {}))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ status: true, data: { orderDBList: [] } }), {}))
.rejects.toBeInstanceOf(EmptyResultError);
});
});