文件
OmniCard/backend/test/mpuniapp/nfc-scan.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

572 行
20 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* OmniCard AI (CRMmp) - 名片主 NFC 贴卡激活测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD pages/owner/nfc/scan 设计编写
*/
import * as fs from 'fs';
import * as path from 'path';
const SCAN_PAGE = path.resolve(__dirname, '../../../mpuniapp/pages/owner/nfc/scan.uvue');
const SCAN_UTS = path.resolve(__dirname, '../../../mpuniapp/pages/owner/nfc/useScan.uts');
type NFCRecordFixture = {
id: string | number;
uid: string;
tenantId: string | number;
cardId?: string | number | null;
batchNo: string;
material: 'wood' | 'metal' | 'pvc';
status: 'active' | 'suspended' | 'unbound';
sceneMode: 'business' | 'social';
};
type ActivateByTapInput = {
uid: string;
cardId?: string | number | null;
deviceCardId?: string | number | null;
};
type ScanState = {
uid: string;
cardIdRaw: string;
deviceCardId: number | null;
submitting: boolean;
scanning: boolean;
manualInput: boolean;
errorMessage: string;
empty: boolean;
record: NFCRecordFixture | null;
};
type UseScanModule = {
parseUid: (raw: string | number | null | undefined) => string;
parseCardId: (raw: string | number | null | undefined) => number | null;
activateByTap: (
input: ActivateByTapInput,
existing?: { cardId?: string | number | null } | null
) => Promise<NFCRecordFixture>;
startScan: (state: ScanState) => boolean;
createScanState: () => ScanState;
buildNdefUri: (cardId: string | number | null | undefined) => string;
writeNdefToTag: (uri: string | null | undefined) => Promise<boolean>;
resolveScanText: (key: string) => string;
};
type NdefWriteOption = {
uris?: string[];
records?: unknown[];
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
};
const WRITE_SUCCESS_TOAST_RE =
/已写入(?!失败)|写入成功|Written to tag|Write successful/i;
function assertNoWriteSuccessToast(toasts: UniToastOption[]): void {
expect(toasts.some(t => WRITE_SUCCESS_TOAST_RE.test(t.title))).toBe(false);
}
type UniToastOption = {
title: string;
icon?: string;
};
type UniRequestSuccessRes = {
statusCode: number;
data: unknown;
header: Record<string, string>;
};
type UniRequestOption = {
url: string;
method?: string;
data?: Record<string, unknown>;
success?: (res: UniRequestSuccessRes) => void;
fail?: (err: { errMsg: string }) => void;
complete?: () => void;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
function buildNfcRecord(overrides?: Partial<NFCRecordFixture>): NFCRecordFixture {
return {
id: 41,
uid: '04AABBCCDD',
tenantId: 8,
cardId: 12,
batchNo: 'B202609',
material: 'pvc',
status: 'active',
sceneMode: 'business',
...overrides,
};
}
describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)', () => {
let storageMap: Map<string, string>;
let toastCalls: UniToastOption[];
let lastRequestOptions: UniRequestOption | null;
let requestCallCount: number;
let writeNdefCalls: NdefWriteOption[];
let writeMode: 'success' | 'fail' | 'cancel' | 'noAdapter' | 'noWriteApi';
let mockRequestResponse: {
statusCode?: number;
data?: {
code?: number;
message?: string;
data?: unknown;
};
failError?: { errMsg: string };
};
beforeEach(() => {
storageMap = new Map();
toastCalls = [];
lastRequestOptions = null;
requestCallCount = 0;
writeNdefCalls = [];
writeMode = 'success';
mockRequestResponse = {
statusCode: 200,
data: {
code: 1000,
message: 'success',
data: buildNfcRecord(),
},
};
jest.resetModules();
(global as { uni?: Record<string, unknown> }).uni = {
getStorageSync: (key: string) => storageMap.get(key) || '',
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
removeStorageSync: (key: string) => storageMap.delete(key),
showToast: (options: UniToastOption) => {
toastCalls.push(options);
},
reLaunch: jest.fn(),
getLocale: () => 'zh-CN',
getNFCAdapter: () => {
if (writeMode === 'noAdapter') {
return null;
}
return {
startDiscovery: (opts: {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => {
opts.fail?.({ errMsg: 'startDiscovery:fail' });
},
onDiscovered: () => {},
getNdef: () => {
if (writeMode === 'noWriteApi') {
return null;
}
return {
writeNdefMessage: (opts: NdefWriteOption) => {
writeNdefCalls.push(opts);
if (writeMode === 'success') {
opts.success?.();
return;
}
if (writeMode === 'cancel') {
opts.fail?.({ errMsg: 'writeNdefMessage:fail cancel' });
return;
}
opts.fail?.({ errMsg: 'writeNdefMessage:fail' });
},
};
},
};
},
request: (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
setTimeout(() => {
if (mockRequestResponse.failError) {
options.fail?.(mockRequestResponse.failError);
} else {
options.success?.({
statusCode: mockRequestOptionsStatus(),
data: mockRequestResponse.data,
header: {},
});
}
options.complete?.();
}, 0);
return {
abort: jest.fn(),
};
},
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
});
function mockRequestOptionsStatus(): number {
return mockRequestResponse.statusCode != null ? mockRequestResponse.statusCode : 200;
}
function loadUseScan(): UseScanModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../mpuniapp/pages/owner/nfc/useScan.uts') as UseScanModule;
}
describe('1. parseUid 去空且非空才合法', () => {
it('1.1 空白与空串解析为空,视为非法', () => {
const { parseUid } = loadUseScan();
expect(parseUid('')).toBe('');
expect(parseUid(' ')).toBe('');
expect(parseUid(null)).toBe('');
expect(parseUid(undefined)).toBe('');
});
it('1.2 两端空白被去掉,非空结果合法', () => {
const { parseUid } = loadUseScan();
expect(parseUid(' 04:AA:BB:CC ')).toBe('04:AA:BB:CC');
expect(parseUid('04AABBCCDD')).toBe('04AABBCCDD');
expect(parseUid(4012)).toBe('4012');
});
});
describe('2. parseCardId 仅正整数合法', () => {
it('2.1 空值、零、负数、小数、非数字均为非法', () => {
const { parseCardId } = loadUseScan();
expect(parseCardId('')).toBeNull();
expect(parseCardId(' ')).toBeNull();
expect(parseCardId(null)).toBeNull();
expect(parseCardId(undefined)).toBeNull();
expect(parseCardId(0)).toBeNull();
expect(parseCardId('0')).toBeNull();
expect(parseCardId(-1)).toBeNull();
expect(parseCardId('-8')).toBeNull();
expect(parseCardId(1.5)).toBeNull();
expect(parseCardId('12.3')).toBeNull();
expect(parseCardId('abc')).toBeNull();
});
it('2.2 正整数字符串与数字解析为 number', () => {
const { parseCardId } = loadUseScan();
expect(parseCardId(12)).toBe(12);
expect(parseCardId('12')).toBe(12);
expect(parseCardId(' 8 ')).toBe(8);
});
});
describe('3. activateByTap 调用 bind 或 activate', () => {
it('3.1 入参 cardId 合法时调用 bindNfcDevice', async () => {
const { activateByTap } = loadUseScan();
const record = await activateByTap({ uid: ' 04AABBCCDD ', cardId: '12' });
expect(requestCallCount).toBe(1);
expect(lastRequestOptions).not.toBeNull();
expect(lastRequestOptions?.method).toBe('POST');
expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/bind');
expect(lastRequestOptions?.data?.uid).toBe('04AABBCCDD');
expect(lastRequestOptions?.data?.cardId).toBe(12);
expect(record.uid).toBe('04AABBCCDD');
expect(record.cardId).toBe(12);
expect(record.status).toBe('active');
});
it('3.2 设备已有 cardId 且入参 cardId 空时调用 activateNfcDevice', async () => {
const { activateByTap } = loadUseScan();
mockRequestResponse.data = {
code: 1000,
message: 'success',
data: buildNfcRecord({ cardId: 88 }),
};
const record = await activateByTap(
{ uid: '04AABBCCDD', cardId: '' },
{ cardId: 88 }
);
expect(requestCallCount).toBe(1);
expect(lastRequestOptions).not.toBeNull();
expect(lastRequestOptions?.method).toBe('POST');
expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/activate');
expect(lastRequestOptions?.data?.uid).toBe('04AABBCCDD');
expect(lastRequestOptions?.data?.cardId).toBeUndefined();
expect(record.cardId).toBe(88);
});
it('3.3 入参带 deviceCardId 且 cardId 空时走 activate', async () => {
const { activateByTap } = loadUseScan();
await activateByTap({ uid: '04AABBCCDD', cardId: null, deviceCardId: 5 });
expect(requestCallCount).toBe(1);
expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/activate');
});
it('3.4 空 UID 不发请求并抛出错误信息', async () => {
const { activateByTap } = loadUseScan();
await expect(activateByTap({ uid: ' ', cardId: 12 })).rejects.toThrow();
expect(requestCallCount).toBe(0);
});
it('3.5 无入参 cardId 且设备亦无 cardId 时不发请求并抛错', async () => {
const { activateByTap } = loadUseScan();
await expect(activateByTap({ uid: '04AABBCCDD', cardId: '' })).rejects.toThrow();
expect(requestCallCount).toBe(0);
});
it('3.6 接口失败时抛出服务端错误信息', async () => {
const { activateByTap } = loadUseScan();
mockRequestResponse.data = {
code: 1001,
message: '设备未在台账中',
data: null,
};
await expect(activateByTap({ uid: '04AABBCCDD', cardId: 12 })).rejects.toThrow(
'设备未在台账中'
);
});
});
describe('4. startScan 不伪造成功', () => {
it('4.1 天线 API 不可用时失败回退手动输入,不伪造成功、不发激活请求', () => {
const { createScanState, startScan } = loadUseScan();
const state = createScanState();
const uidBefore = state.uid;
const ok = startScan(state);
expect(ok).toBe(false);
expect(state.manualInput).toBe(true);
expect(state.scanning).toBe(false);
expect(state.record).toBeNull();
expect(state.uid).toBe(uidBefore);
expect(state.errorMessage.length).toBeGreaterThan(0);
expect(requestCallCount).toBe(0);
});
it('4.2 源码契约:APP 块尝试官方 getNFCAdapter 读 UID,成功仅在拿到合法 UID', () => {
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const startScanBlock = logicSource.slice(
logicSource.indexOf('export function startScan'),
logicSource.indexOf('export function resolveStatusLine')
);
expect(logicSource.includes('#ifdef APP')).toBe(true);
expect(logicSource.includes('getNFCAdapter')).toBe(true);
expect(logicSource.includes('startDiscovery') || logicSource.includes('onDiscovered')).toBe(
true
);
expect(startScanBlock.includes('parseUid')).toBe(true);
expect(startScanBlock.includes('state.uid')).toBe(true);
expect(startScanBlock.includes('return true')).toBe(true);
expect(startScanBlock.includes('manualInput = true')).toBe(true);
expect(startScanBlock.includes('return false')).toBe(true);
// 禁止在 startScan 内硬编码伪造芯片 UID
expect(/state\.uid\s*=\s*['"][0-9A-Fa-f:]+['"]/.test(startScanBlock)).toBe(false);
expect(startScanBlock.includes('Native NFC antenna is not wired')).toBe(false);
});
});
describe('5. writeNdefToTag 仅系统写入 API 成功才 toast 已写入', () => {
it('5.1 buildNdefUri 无合法 cardId 时返回空串', () => {
const { buildNdefUri } = loadUseScan();
expect(buildNdefUri(null)).toBe('');
expect(buildNdefUri(undefined)).toBe('');
expect(buildNdefUri('')).toBe('');
expect(buildNdefUri(0)).toBe('');
expect(buildNdefUri(-1)).toBe('');
expect(buildNdefUri(12)).toBe('https://omnicard.ai/c/12');
expect(buildNdefUri(' 8 ')).toBe('https://omnicard.ai/c/8');
});
it('5.2 写入 API 成功才 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
toastCalls = [];
writeMode = 'success';
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(true);
expect(writeNdefCalls.length).toBe(1);
expect(writeNdefCalls[0].uris).toEqual(['https://omnicard.ai/c/12']);
expect(
toastCalls.some(t => t.title === resolveScanText('writeSuccess'))
).toBe(true);
expect(WRITE_SUCCESS_TOAST_RE.test(resolveScanText('writeSuccess'))).toBe(
true
);
});
it('5.3 无可写内容不调写入 API,不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
toastCalls = [];
const ok = await writeNdefToTag(' ');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeUnavailable'))
).toBe(true);
});
it('5.4 天线/Adapter 不可用时不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'noAdapter';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.5 无 writeNdefMessage 能力时不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'noWriteApi';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.6 用户取消写入不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'cancel';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(1);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeCancelled'))
).toBe(true);
});
it('5.7 写入失败不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'fail';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(1);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.8 源码契约:writeSuccess 仅出现在 writeNdefMessage 的 success 回调内', () => {
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const writeIdx = logicSource.indexOf('ndef.writeNdefMessage(');
expect(writeIdx).toBeGreaterThan(-1);
const successIdx = logicSource.indexOf('success:', writeIdx);
expect(successIdx).toBeGreaterThan(writeIdx);
const successBody = logicSource.slice(successIdx, successIdx + 220);
expect(successBody.includes('writeSuccess')).toBe(true);
const failIdx = logicSource.indexOf('fail:', successIdx);
expect(failIdx).toBeGreaterThan(successIdx);
const failBody = logicSource.slice(failIdx, failIdx + 360);
expect(failBody.includes('writeSuccess')).toBe(false);
expect(WRITE_SUCCESS_TOAST_RE.test(failBody)).toBe(false);
expect(
failBody.includes('writeFailed') ||
failBody.includes('writeCancelled') ||
failBody.includes('toastWriteFailed') ||
failBody.includes('toastWriteCancelled')
).toBe(true);
});
});
});
describe('OmniCard AI 名片主 NFC 贴卡激活页契约 (scan.uvue)', () => {
it('页面存在且引用 bindNfcDevice 或 activateByTap', () => {
expect(fs.existsSync(SCAN_PAGE)).toBe(true);
expect(fs.existsSync(SCAN_UTS)).toBe(true);
const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8');
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const combined = `${pageSource}\n${logicSource}`;
expect(combined.includes('bindNfcDevice') || combined.includes('activateByTap')).toBe(
true
);
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
expect(pageSource.includes('<view')).toBe(true);
expect(pageSource.includes('<text')).toBe(true);
expect(pageSource.includes('<input')).toBe(true);
expect(pageSource.includes('<button')).toBe(true);
expect(pageSource.includes('getOmniText')).toBe(true);
expect(pageSource.includes('#0B5CFF')).toBe(true);
expect(pageSource.includes('#F7F8FA')).toBe(true);
expect(pageSource.includes('#101828')).toBe(true);
expect(pageSource.includes('#667085')).toBe(true);
expect(pageSource.includes('#E3E7ED')).toBe(true);
});
it('无 emoji、无 any,模板无硬编码中文主文案', () => {
const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8');
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const testSource = fs.readFileSync(__filename, 'utf8');
const template = pageSource.split('<script')[0];
expect(EMOJI_RE.test(pageSource)).toBe(false);
expect(EMOJI_RE.test(logicSource)).toBe(false);
expect(EMOJI_RE.test(testSource)).toBe(false);
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe(
false
);
});
it('主流程走 writeNdefToTag,失败路径无已写入文案,不把云端绑定冒充芯片写入', () => {
const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8');
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
expect(logicSource.includes('writeNdefToTag')).toBe(true);
expect(logicSource.includes('writeNdefMessage')).toBe(true);
expect(pageSource.includes('writeNdefToTag')).toBe(true);
expect(pageSource.includes('buildNdefUri')).toBe(true);
expect(pageSource.includes('已写入')).toBe(false);
expect(pageSource.includes('烧录至')).toBe(false);
expect(WRITE_SUCCESS_TOAST_RE.test(pageSource)).toBe(false);
const unavailableIdx = logicSource.indexOf("resolveScanText('writeUnavailable')");
expect(unavailableIdx).toBeGreaterThan(-1);
const unavailableSlice = logicSource.slice(
Math.max(0, unavailableIdx - 80),
unavailableIdx + 80
);
expect(WRITE_SUCCESS_TOAST_RE.test(unavailableSlice)).toBe(false);
});
});