Files
eSIM-Tools/tests/modules/qrcode-generator.test.js
Abner 6379bbef90 ♻️ refactor: 移除外部二维码服务依赖,改用本地生成方案
- 将前端二维码生成从 `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 上报断言,验证本地成功、本地失败降级、后端成功、后端失败四种场景的可观测性
2026-06-23 21:22:16 +08:00

302 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 包的 mockwindow.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();
});
});