mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
- 将前端二维码生成从 `qrcode.show` 远程服务切换为 `qrcode-generator` 本地库(UMD 格式),消除对外部 CDN 渲染服务的依赖,降低隐私风险和第三方服务不可用时的故障点 - 重写 `generateQRCodeLocal` 函数适配 `qrcode-generator` API,使用 `qrcode(typeNumber, errorCorrectionLevel).addData().make().createDataURL()` 模式,并基于 QR 码模块数动态计算 `cellSize`,新增大尺寸预览图支持(400px) - 移除所有页面中的 `qrcode.show` 域名引用,包括 CSP `connectSrc` 策略、preconnect 标签、resource-hints DNS 预解析配置及 Simyo/Giffgaff API 配置中的 `qrcode` 端点 - 完善二维码生成的日志与监控:在 CDN 加载成功后输出 `console.log`,本地/后端生成成功及失败时分别输出对应级别日志,并在 `trackQRCodeEvent` 中增加 `isBrowser` 守卫避免非浏览器环境报错 - 增加生成耗时和 QR 码长度(不含内容)到后端 BFF 日志,同时统一使用 `Date.now()` 计算请求耗时,避免 LPA 激活码等敏感信息进入日志 - 全面更新所有相关测试用例,适配新的 `window.qrcode` 工厂函数 mock,新增日志输出和 Sentry 上报断言,验证本地成功、本地失败降级、后端成功、后端失败四种场景的可观测性
302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
/**
|
||
* qrcode-generator 模块单元测试
|
||
*/
|
||
|
||
describe('qrcode-generator', () => {
|
||
/** 创建本地生成失败的 qrcode-generator mock */
|
||
function createFailingQRMock() {
|
||
return jest.fn(() => ({
|
||
addData: jest.fn(),
|
||
make: jest.fn(() => { throw new Error('local failed'); }),
|
||
getModuleCount: jest.fn(() => 25),
|
||
createDataURL: jest.fn()
|
||
}));
|
||
}
|
||
|
||
beforeEach(() => {
|
||
jest.resetModules();
|
||
fetch.mockReset();
|
||
delete window.QRCode;
|
||
delete window.qrcode;
|
||
});
|
||
|
||
it('应该使用浏览器本地 qrcode.js 生成 img 容器', async () => {
|
||
// qrcode-generator 包的 mock:window.qrcode 是一个工厂函数
|
||
const mockQRInstance = {
|
||
addData: jest.fn(),
|
||
make: jest.fn(),
|
||
getModuleCount: jest.fn(() => 25), // 25x25 模块的 QR 码
|
||
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
|
||
};
|
||
window.qrcode = jest.fn(() => mockQRInstance);
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
const result = await generateQRCodeLocal('LPA:1$example', 300);
|
||
|
||
expect(result.source).toBe('local');
|
||
expect(result.container.className).toBe('qrcode-container');
|
||
expect(result.container.querySelector('img').getAttribute('src')).toContain('data:image/png;base64,local-');
|
||
expect(window.qrcode).toHaveBeenCalledWith(0, 'M');
|
||
expect(mockQRInstance.addData).toHaveBeenCalledWith('LPA:1$example');
|
||
expect(mockQRInstance.make).toHaveBeenCalled();
|
||
expect(mockQRInstance.createDataURL).toHaveBeenCalledTimes(2); // 正常尺寸 + 大尺寸
|
||
});
|
||
|
||
it('本地生成失败时应该调用后端 BFF 降级', async () => {
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' })
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
const result = await generateQRCodeWithFallback('LPA:1$example', 300);
|
||
|
||
expect(result.source).toBe('backend');
|
||
expect(fetch).toHaveBeenCalledWith('/bff/qrcode-generate', expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({ data: 'LPA:1$example', size: 300 })
|
||
}));
|
||
});
|
||
|
||
it('本地和后端都失败时应该抛出最终错误', async () => {
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 500
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeWithFallback('LPA:1$example', 300))
|
||
.rejects.toThrow('QR code generation failed after local and backend fallback');
|
||
});
|
||
|
||
it('应该拒绝过长的二维码内容', async () => {
|
||
// 模拟 qrcode-generator 已加载
|
||
window.qrcode = jest.fn();
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeLocal('x'.repeat(2049), 300))
|
||
.rejects.toThrow('QR code data length must be between');
|
||
});
|
||
|
||
it('应该拒绝非整数的 size', async () => {
|
||
// 模拟 qrcode-generator 已加载
|
||
window.qrcode = jest.fn();
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeLocal('LPA:1$example', 300.5))
|
||
.rejects.toThrow('QR code size must be an integer');
|
||
});
|
||
|
||
it('应该拒绝低于最小值的 size', async () => {
|
||
// 模拟 qrcode-generator 已加载
|
||
window.qrcode = jest.fn();
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeLocal('LPA:1$example', 199))
|
||
.rejects.toThrow('QR code size must be between 200 and 600');
|
||
});
|
||
|
||
it('应该拒绝高于最大值的 size', async () => {
|
||
// 模拟 qrcode-generator 已加载
|
||
window.qrcode = jest.fn();
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeLocal('LPA:1$example', 601))
|
||
.rejects.toThrow('QR code size must be between 200 and 600');
|
||
});
|
||
|
||
it('应该拒绝非字符串的 data', async () => {
|
||
// 模拟 qrcode-generator 已加载
|
||
window.qrcode = jest.fn();
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeLocal(12345, 300))
|
||
.rejects.toThrow('QR code data must be a string');
|
||
});
|
||
|
||
it('后端超时时应该抛出超时错误', async () => {
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
// Mock fetch 超时(AbortError)
|
||
fetch.mockImplementationOnce(() =>
|
||
new Promise((resolve, reject) => {
|
||
setTimeout(() => {
|
||
const error = new Error('The operation was aborted');
|
||
error.name = 'AbortError';
|
||
reject(error);
|
||
}, 10);
|
||
})
|
||
);
|
||
|
||
const { generateQRCodeBackend } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
// 直接测试 generateQRCodeBackend 的超时转换逻辑
|
||
await expect(generateQRCodeBackend('LPA:1$example', 300))
|
||
.rejects.toThrow('Backend QR code generation timed out after 10000ms');
|
||
});
|
||
|
||
it('后端返回无效 JSON 时应该抛出错误', async () => {
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: () => Promise.resolve({ success: false })
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
|
||
await expect(generateQRCodeWithFallback('LPA:1$example', 300))
|
||
.rejects.toThrow('QR code generation failed after local and backend fallback');
|
||
});
|
||
|
||
// ========== 日志和 Sentry 上报测试 ==========
|
||
|
||
it('本地生成成功时应输出 console.log', async () => {
|
||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||
|
||
const mockQRInstance = {
|
||
addData: jest.fn(),
|
||
make: jest.fn(),
|
||
getModuleCount: jest.fn(() => 25),
|
||
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
|
||
};
|
||
window.qrcode = jest.fn(() => mockQRInstance);
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
await generateQRCodeLocal('LPA:1$example', 300);
|
||
|
||
expect(consoleSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('[QRCode] Local generation success')
|
||
);
|
||
expect(consoleSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('size=300')
|
||
);
|
||
consoleSpy.mockRestore();
|
||
});
|
||
|
||
it('本地生成失败时应输出 console.warn 并上报 Sentry', async () => {
|
||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||
window.Sentry = { captureMessage: jest.fn() };
|
||
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' })
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
await generateQRCodeWithFallback('LPA:1$example', 300);
|
||
|
||
// 验证 console.warn 输出降级日志(第一个参数包含关键信息)
|
||
expect(consoleSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('[QRCode] Local generation failed'),
|
||
expect.anything()
|
||
);
|
||
|
||
// 验证 Sentry captureMessage 被调用
|
||
expect(window.Sentry.captureMessage).toHaveBeenCalledWith(
|
||
'QR Code qr_fallback failed',
|
||
expect.objectContaining({
|
||
level: 'warning',
|
||
tags: expect.objectContaining({
|
||
module: 'qrcode-generation',
|
||
source: 'local',
|
||
type: 'qr_fallback'
|
||
})
|
||
})
|
||
);
|
||
|
||
delete window.Sentry;
|
||
consoleSpy.mockRestore();
|
||
});
|
||
|
||
it('后端生成成功时应输出 console.log', async () => {
|
||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend-qr-data' })
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
await generateQRCodeWithFallback('LPA:1$example', 300);
|
||
|
||
expect(consoleSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('[QRCode] Backend generation success')
|
||
);
|
||
consoleSpy.mockRestore();
|
||
});
|
||
|
||
it('后端生成失败时应输出 console.error 并上报 Sentry', async () => {
|
||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||
window.Sentry = { captureMessage: jest.fn() };
|
||
|
||
window.qrcode = createFailingQRMock();
|
||
|
||
fetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 500
|
||
});
|
||
|
||
const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js');
|
||
await expect(generateQRCodeWithFallback('LPA:1$example', 300))
|
||
.rejects.toThrow('QR code generation failed after local and backend fallback');
|
||
|
||
// 验证 console.error 输出后端失败日志(第一个参数包含关键信息)
|
||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('[QRCode] Backend fallback failed'),
|
||
expect.anything()
|
||
);
|
||
|
||
// 验证 Sentry 上报了后端失败事件
|
||
expect(window.Sentry.captureMessage).toHaveBeenCalledWith(
|
||
'QR Code qr_generation failed',
|
||
expect.objectContaining({
|
||
level: 'warning',
|
||
tags: expect.objectContaining({
|
||
source: 'failed'
|
||
})
|
||
})
|
||
);
|
||
|
||
delete window.Sentry;
|
||
consoleErrorSpy.mockRestore();
|
||
consoleWarnSpy.mockRestore();
|
||
});
|
||
|
||
it('CDN 加载成功时应输出 console.log', async () => {
|
||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||
|
||
const mockQRInstance = {
|
||
addData: jest.fn(),
|
||
make: jest.fn(),
|
||
getModuleCount: jest.fn(() => 25),
|
||
createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`)
|
||
};
|
||
window.qrcode = jest.fn(() => mockQRInstance);
|
||
|
||
const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js');
|
||
await generateQRCodeLocal('LPA:1$example', 300);
|
||
|
||
// 如果 window.qrcode 已存在,CDN 不会被加载,所以检查是否有 CDN 或 Local 日志
|
||
const allCalls = consoleSpy.mock.calls.flat().join(' ');
|
||
expect(allCalls).toMatch(/\[QRCode\].*(CDN loaded|Local generation success)/);
|
||
|
||
consoleSpy.mockRestore();
|
||
});
|
||
});
|