feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// Pure UTS boundary tests. No network, database, real session or runtime installation.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
class JsonObject {
|
||||
constructor(value = {}) { Object.assign(this, value); }
|
||||
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||||
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||||
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||||
}
|
||||
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
|
||||
let session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
let calls = [];
|
||||
let reply;
|
||||
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||||
const api = {
|
||||
ApiRequestError: ApiError,
|
||||
getSession: () => session,
|
||||
requestJson: async (url, method, payload) => { calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await reply(url, method, payload)); }
|
||||
};
|
||||
const modules = {};
|
||||
const pages = [];
|
||||
const redirects = [];
|
||||
const styles = { pageStyle: () => '', contentStyle: () => '', statusStyle: () => '', bottomStyle: () => '' };
|
||||
let pageShow, pageHide, pageLoad;
|
||||
const pageUni = { showToast: () => {}, showModal: options => options.success?.({ confirm: true }), redirectTo: ({ url }) => redirects.push(url), navigateTo: ({ url }) => pages.push(url) };
|
||||
api.saveSession = (token, phone, userId, expiresAt) => { session = json({ token, phone, userId, expiresAt }); };
|
||||
api.API_BASE_URL = 'https://synthetic.invalid/api/v1';
|
||||
function load(relative) {
|
||||
if (modules[relative]) return modules[relative];
|
||||
const file = path.join(root, relative);
|
||||
if (!fs.existsSync(file)) return {};
|
||||
const output = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exports = {};
|
||||
modules[relative] = exports;
|
||||
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
|
||||
require: name => name.endsWith('/api.uts') || name === './api.uts' ? api : load(path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)))
|
||||
}, { filename: relative });
|
||||
return exports;
|
||||
}
|
||||
function componentPage(relative, bindings) {
|
||||
pageShow = null; pageHide = null; pageLoad = null;
|
||||
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
const output = ts.transpileModule(source + '\nexports.__test = {' + bindings + '};', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exports = {};
|
||||
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error, uni: pageUni,
|
||||
ref: value => ({ value }), watch: () => {}, onLoad: callback => { pageLoad = callback; }, onShow: callback => { pageShow = callback; }, onHide: callback => { pageHide = callback; }, onUnload: () => {}, onUnmounted: () => {},
|
||||
setInterval: () => 1, clearInterval: () => {},
|
||||
require: name => name === '@/services/api.uts' ? api : name === '@/services/layout.uts' ? { usePageLayout: () => styles } : name.endsWith('.uvue') ? {} : name === '@/services/launch.uts' ? { consumeInitialLaunch: () => {} } : load(name.replace(/^@\//, ''))
|
||||
}, { filename: relative });
|
||||
return exports.__test;
|
||||
}
|
||||
const loginPage = () => componentPage('packages/account/login/login.uvue', 'login, phone, password, agreed, errorMessage, loading, toggleAgreement: typeof toggleAgreement === "function" ? toggleAgreement : null, loadAgreements: typeof loadAgreements === "function" ? loadAgreements : null');
|
||||
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
||||
const route = load('services/agreements-route.uts');
|
||||
const service = load('services/agreements.uts');
|
||||
let checks = 0;
|
||||
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
|
||||
const hash = 'a'.repeat(64);
|
||||
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文\n第二段 <script>只是纯文本</script>', contentHash: hash, effectiveAt: '2026-10-01T00:00:00Z' };
|
||||
const consent = { consentId: '9', scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', contentHash: hash, grantedAt: '2026-10-12T00:00:00Z', revokedAt: null, canRevoke: false };
|
||||
(async () => {
|
||||
await test('unknown agreement code cannot produce a route', () => {
|
||||
assert.equal(typeof route.agreementRoute, 'function', 'agreement route validation is not implemented');
|
||||
for (const code of ['', 'unknown', 'terms?x=1', 'https://outside.invalid']) assert.equal(route.agreementRoute(code), '');
|
||||
});
|
||||
await test('versioned agreement links preserve the immutable version', () => {
|
||||
assert.equal(route.agreementRoute('terms', '2026.10-v1'), '/pages/agreement/agreement?code=terms&version=2026.10-v1');
|
||||
assert.equal(route.agreementRoute('privacy'), '/pages/agreement/agreement?code=privacy');
|
||||
for (const version of ['x&code=terms', '\n', 'x'.repeat(65), '../draft']) assert.equal(route.agreementRoute('terms', version), '');
|
||||
});
|
||||
await test('scope mapping separates account confirmations from optional grants', () => {
|
||||
assert.equal(route.agreementScope('terms'), 'account-terms');
|
||||
assert.equal(route.agreementScope('privacy'), 'account-privacy');
|
||||
assert.equal(route.agreementScope('document-use'), 'documents');
|
||||
assert.equal(route.agreementScope('data-authorization'), 'data');
|
||||
assert.equal(route.optionalConsentScope('account-privacy'), false);
|
||||
assert.equal(route.optionalConsentScope('documents'), true);
|
||||
});
|
||||
await test('BIGINT agreement ids stay as strings and reject overflow', () => {
|
||||
for (const value of ['1', '9223372036854775807']) assert.equal(route.agreementRecordIdValid(value), true);
|
||||
for (const value of ['0', '01', '-1', '9223372036854775808', '99999999999999999999']) assert.equal(route.agreementRecordIdValid(value), false);
|
||||
});
|
||||
await test('account guard rejects another account, new token and anonymous state', () => {
|
||||
assert.equal(route.sameConsentSession('a', '1', 'a', '1'), true);
|
||||
for (const args of [['a', '1', 'b', '1'], ['a', '1', 'a', '2'], ['', '', '', ''], ['a', '1', '', '']]) assert.equal(route.sameConsentSession(...args), false);
|
||||
});
|
||||
await test('public fetch emits only a validated code and requested version', async () => {
|
||||
calls = []; reply = () => terms;
|
||||
const result = await service.fetchAgreement('terms', '2026.10-v1');
|
||||
assert.equal(result.content, terms.content);
|
||||
assert.deepEqual(calls, [{ url: '/agreements/terms', method: 'GET', payload: { version: '2026.10-v1' } }]);
|
||||
await assert.rejects(service.fetchAgreement('unknown'));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('wrong version, scope, hash and invalid timestamp cannot become seen agreement', async () => {
|
||||
for (const patch of [{ version: 'old' }, { code: 'privacy' }, { scope: 'data' }, { contentHash: 'bad' }, { content: '' }, { effectiveAt: 'bad' }]) {
|
||||
reply = () => ({ ...terms, ...patch });
|
||||
await assert.rejects(service.fetchAgreement('terms', '2026.10-v1'));
|
||||
}
|
||||
});
|
||||
await test('not-published detection does not hide a network or server failure', () => {
|
||||
assert.equal(service.agreementUnavailable(new ApiError('未发布', 404, 'AGREEMENT_NOT_AVAILABLE')), true);
|
||||
for (const error of [new Error('network'), new ApiError('server', 503, 'AGREEMENT_NOT_AVAILABLE'), new ApiError('not found', 404, 'OTHER')]) assert.equal(service.agreementUnavailable(error), false);
|
||||
});
|
||||
await test('grant sends the exact seen version and stable request key', async () => {
|
||||
calls = []; reply = () => consent;
|
||||
const result = await service.grantConsent(terms, 'consent_request_123');
|
||||
assert.equal(result.consentId, '9');
|
||||
assert.deepEqual(calls, [{ url: '/consents', method: 'POST', payload: { scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', requestId: 'consent_request_123' } }]);
|
||||
});
|
||||
await test('anonymous or malformed consent cannot reach the network', async () => {
|
||||
calls = []; session = null;
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
await assert.rejects(service.grantConsent(terms, 'bad'));
|
||||
await assert.rejects(service.grantConsent({ ...terms, scope: 'data' }, 'consent_request_123'));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
await test('consent request keys require the server minimum of sixteen characters', async () => {
|
||||
calls = []; reply = () => consent;
|
||||
for (const value of ['abcdefgh', 'abcdefghijklmno', 'a'.repeat(65), 'request key spaces']) await assert.rejects(service.grantConsent(terms, value));
|
||||
assert.equal(calls.length, 0);
|
||||
await service.grantConsent(terms, 'abcdefghijklmnop');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('a response from the previous session cannot confirm consent', async () => {
|
||||
reply = () => { session = json({ token: 'synthetic-token-b', userId: '1' }); return consent; };
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
});
|
||||
await test('receipt must match seen hash, scope and version', async () => {
|
||||
for (const patch of [{ agreementVersion: 'old' }, { contentHash: 'b'.repeat(64) }, { scope: 'data' }, { agreementCode: 'privacy' }, { revokedAt: '2026-10-12T01:00:00Z' }]) {
|
||||
reply = () => ({ ...consent, ...patch });
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
}
|
||||
});
|
||||
await test('a revoked optional grant replay is a valid receipt of the original command', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
reply = () => ({ ...consent, scope: 'documents', agreementCode: 'document-use', revokedAt: '2026-10-12T01:00:00Z', canRevoke: false });
|
||||
const result = await service.grantConsent(agreement, 'original_request_123');
|
||||
assert.equal(result.revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(result.canRevoke, false);
|
||||
});
|
||||
await test('account confirmations cannot be revoked even with an incorrect permission flag', async () => {
|
||||
calls = [];
|
||||
await assert.rejects(service.revokeConsent({ ...consent, canRevoke: true }, 'revoke_request_123'));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
await test('optional revocation preserves the original receipt and only sends a request key', async () => {
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: '' };
|
||||
calls = []; reply = () => ({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' });
|
||||
assert.equal((await service.revokeConsent(item, 'revoke_request_123')).revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.deepEqual(calls, [{ url: '/consents/9/revoke', method: 'POST', payload: { requestId: 'revoke_request_123' } }]);
|
||||
});
|
||||
await test('list validates scope and pagination before querying', async () => {
|
||||
calls = []; reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false });
|
||||
const result = await service.fetchConsents('account-terms', 1);
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.deepEqual(calls[0], { url: '/consents', method: 'GET', payload: { scope: 'account-terms', page: '1', pageSize: '20' } });
|
||||
for (const args of [['unknown', 1], ['', 0], ['', 1.5], ['', 1, 51]]) await assert.rejects(service.fetchConsents(...args));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('wrong-page and wrong-scope list responses cannot replace user records', async () => {
|
||||
for (const patch of [{ page: 2 }, { pageSize: 10 }, { total: -1 }, { hasMore: null }, { items: [{ ...consent, scope: 'data', agreementCode: 'data-authorization' }] }]) {
|
||||
reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false, ...patch });
|
||||
await assert.rejects(service.fetchConsents('account-terms', 1));
|
||||
}
|
||||
});
|
||||
await test('failed post-login consent retains authentication and retries the same command without logging in again', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
let fail = true;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/terms') return terms;
|
||||
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
if (url === '/consents') {
|
||||
if (fail) { fail = false; throw new Error('synthetic lost receipt'); }
|
||||
return { ...consent, agreementCode: payload.agreementCode, scope: payload.scope, agreementVersion: payload.agreementVersion };
|
||||
}
|
||||
throw Error('Unexpected login request ' + url);
|
||||
};
|
||||
const page = loginPage();
|
||||
if (pageShow) await pageShow();
|
||||
if (page.loadAgreements) await page.loadAgreements();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
|
||||
if (page.toggleAgreement) page.toggleAgreement(); else page.agreed.value = true;
|
||||
await page.login();
|
||||
assert.equal(session.getString('token'), 'synthetic-login-token');
|
||||
assert.equal(redirects.length, 0, 'login must wait for consent receipts');
|
||||
assert.ok(page.errorMessage.value.length > 0, 'failed consent remains visible for manual retry');
|
||||
await page.login();
|
||||
const authCalls = calls.filter(call => call.url === '/auth/password-login');
|
||||
const consentCalls = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(authCalls.length, 1, 'manual retry must not authenticate again');
|
||||
assert.equal(consentCalls.length, 3, 'one failed grant, its retry and privacy confirmation');
|
||||
assert.equal(consentCalls[0].payload.requestId, consentCalls[1].payload.requestId, 'unknown result must use the original key');
|
||||
assert.notEqual(consentCalls[1].payload.requestId, consentCalls[2].payload.requestId);
|
||||
assert.deepEqual(redirects, ['/pages/home/home']);
|
||||
});
|
||||
await test('unpublished agreements allow existing password login without manufacturing consent records', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = url => {
|
||||
if (url.startsWith('/agreements/')) throw new ApiError('not published', 404, 'AGREEMENT_NOT_AVAILABLE');
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-existing-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
throw Error('No consent write is allowed before publication: ' + url);
|
||||
};
|
||||
const page = loginPage(); await pageShow();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url === '/auth/password-login').length, 1);
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 0);
|
||||
assert.deepEqual(redirects, ['/pages/home/home']);
|
||||
});
|
||||
await test('network failure loading agreements is never treated as not published', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = () => { throw new Error('synthetic network failure'); };
|
||||
const page = loginPage(); await pageShow();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.agreed.value = true;
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url.startsWith('/auth/')).length, 0);
|
||||
assert.equal(redirects.length, 0);
|
||||
assert.ok(page.errorMessage.value.length > 0);
|
||||
});
|
||||
await test('an account change while confirmation is pending never grants the previous choice to the new account', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/terms') return terms;
|
||||
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
if (url === '/consents') throw new Error('synthetic lost receipt');
|
||||
throw Error('Unexpected account-change request ' + url);
|
||||
};
|
||||
const page = loginPage(); await pageShow(); page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.toggleAgreement();
|
||||
await page.login();
|
||||
const writes = calls.filter(call => call.url === '/consents').length;
|
||||
session = json({ token: 'synthetic-other-token', userId: '2' });
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, writes);
|
||||
assert.equal(page.agreed.value, false, 'old account checkbox must be cleared');
|
||||
assert.equal(redirects.length, 0);
|
||||
});
|
||||
await test('late public agreement responses cannot replace a newer loaded version', async () => {
|
||||
session = null; calls = [];
|
||||
let release;
|
||||
reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'reload, agreement, available, loading, error');
|
||||
pageLoad({ code: 'terms' }); pageShow(); await flush();
|
||||
assert.equal(typeof release, 'function');
|
||||
reply = () => ({ ...terms, revisionId: '3', version: '2026.10-v2', content: '新合成正文' });
|
||||
await page.reload();
|
||||
release(terms); await flush();
|
||||
assert.equal(page.agreement.value.version, '2026.10-v2');
|
||||
assert.equal(page.agreement.value.content, '新合成正文');
|
||||
assert.equal(page.available.value, true);
|
||||
});
|
||||
await test('an agreement response arriving after leaving cannot fill the hidden page', async () => {
|
||||
session = null;
|
||||
let release; reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'agreement, available, error');
|
||||
pageLoad({ code: 'terms' }); pageShow(); await flush(); pageHide(); release(terms); await flush();
|
||||
assert.equal(page.available.value, false);
|
||||
assert.equal(page.agreement.value.content, '');
|
||||
assert.equal(page.error.value, '');
|
||||
});
|
||||
await test('optional revocation retries an unknown receipt with the original request key', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
|
||||
let failed = false;
|
||||
reply = (url, method) => {
|
||||
if (url === '/consents' && method === 'GET') return { items: [item], page: 1, pageSize: 20, total: 1, hasMore: false };
|
||||
if (url === '/consents/9/revoke') {
|
||||
if (!failed) { failed = true; throw Error('synthetic lost revoke receipt'); }
|
||||
return { ...item, revokedAt: '2026-10-12T01:00:00Z', canRevoke: false };
|
||||
}
|
||||
throw Error('Unexpected revocation request ' + url);
|
||||
};
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, pending, actionError, confirmRevoke, retryRevoke');
|
||||
pageShow(); await flush(); assert.equal(page.records.value.length, 1);
|
||||
page.confirmRevoke(page.records.value[0]); await flush();
|
||||
assert.ok(page.pending.value); assert.ok(page.actionError.value.length > 0);
|
||||
await page.retryRevoke();
|
||||
const writes = calls.filter(call => call.url === '/consents/9/revoke');
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
assert.equal(page.records.value[0].revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(page.pending.value, null);
|
||||
});
|
||||
await test('late consent records from a former account never enter the new account view', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); redirects.length = 0;
|
||||
let release; reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, error');
|
||||
pageShow(); await flush(); session = json({ token: 'synthetic-token-b', userId: '2' });
|
||||
release({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false }); await flush();
|
||||
assert.equal(page.records.value.length, 0); assert.equal(page.error.value, '');
|
||||
assert.deepEqual(redirects, ['/packages/account/profile/profile']);
|
||||
});
|
||||
await test('pagination never renders duplicate consent ids after records shift between requests', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const first = Array.from({ length: 20 }, (_, index) => ({ ...consent, consentId: String(index + 1) }));
|
||||
reply = (url, method, payload) => payload.page === '1'
|
||||
? { items: first, page: 1, pageSize: 20, total: 21, hasMore: true }
|
||||
: { items: [{ ...consent, consentId: '20' }, { ...consent, consentId: '21' }], page: 2, pageSize: 20, total: 21, hasMore: false };
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, loadMore');
|
||||
pageShow(); await flush(); page.loadMore(); await flush();
|
||||
assert.equal(page.records.value.length, 21);
|
||||
assert.equal(new Set(page.records.value.map(item => item.consentId)).size, 21);
|
||||
});
|
||||
await test('a list started before revocation cannot restore the revoked consent after its receipt', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
|
||||
let listCount = 0, release, releaseRevoke, revoked = false;
|
||||
reply = (url, method) => {
|
||||
if (url === '/consents' && method === 'GET') {
|
||||
listCount++;
|
||||
const currentItem = revoked ? { ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' } : { ...item };
|
||||
const result = { items: [currentItem], page: 1, pageSize: 20, total: 1, hasMore: false };
|
||||
if (listCount === 2) return new Promise(resolve => { release = () => resolve(result); });
|
||||
return result;
|
||||
}
|
||||
if (url === '/consents/9/revoke') return new Promise(resolve => { releaseRevoke = () => { revoked = true; resolve({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' }); }; });
|
||||
throw Error('Unexpected revoke/list race request ' + url);
|
||||
};
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, loading, reload, confirmRevoke');
|
||||
pageShow(); await flush(); page.confirmRevoke(page.records.value[0]); await flush(); page.reload(); await flush();
|
||||
assert.equal(page.loading.value, true);
|
||||
releaseRevoke(); await flush(); release(); await flush();
|
||||
const visible = page.records.value.find(row => row.consentId === '9');
|
||||
assert.ok(visible, 'receipt should retain the record while invalidating its in-flight refresh');
|
||||
assert.equal(visible.revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(visible.canRevoke, false);
|
||||
assert.equal(page.loading.value, false);
|
||||
});
|
||||
await test('returning to an optional page settles a revoked original grant and waits for a new explicit click', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
const original = { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
|
||||
let originalKey = '', revoked = false;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/document-use') return agreement;
|
||||
if (url === '/consents' && method === 'POST') {
|
||||
if (!originalKey) { originalKey = payload.requestId; throw Error('synthetic accepted but lost receipt'); }
|
||||
if (payload.requestId === originalKey) return { ...original, revokedAt: revoked ? '2026-10-12T01:00:00Z' : null, canRevoke: !revoked };
|
||||
return { ...original, consentId: '10' };
|
||||
}
|
||||
throw Error('Unexpected optional replay request ' + url);
|
||||
};
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted, actionMessage');
|
||||
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
|
||||
assert.ok(page.pendingId.value);
|
||||
pageHide(); revoked = true; pageShow(); await flush();
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically submit another grant');
|
||||
await page.confirmConsent();
|
||||
assert.equal(page.pendingId.value, '', 'revoked replay settles the pending command');
|
||||
assert.equal(page.granted.value, false, 'revoked original receipt must not show currently granted');
|
||||
assert.match(page.actionMessage.value, /此前同意已撤销/);
|
||||
await page.confirmConsent();
|
||||
const writes = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(writes.length, 3);
|
||||
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
assert.notEqual(writes[1].payload.requestId, writes[2].payload.requestId);
|
||||
assert.equal(page.granted.value, true);
|
||||
});
|
||||
await test('returning after revoking a successful optional grant restores an explicit fresh consent action', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
reply = (url, method) => {
|
||||
if (url === '/agreements/document-use') return agreement;
|
||||
if (url === '/consents' && method === 'POST') return { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
|
||||
throw Error('Unexpected successful-grant return request ' + url);
|
||||
};
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted');
|
||||
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
|
||||
assert.equal(page.granted.value, true);
|
||||
assert.equal(page.pendingId.value, '');
|
||||
pageHide(); pageShow(); await flush();
|
||||
assert.equal(page.granted.value, false, 'return must expose an explicit consent action instead of retaining old success state');
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically regrant');
|
||||
await page.confirmConsent();
|
||||
const writes = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(writes.length, 2);
|
||||
assert.notEqual(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
});
|
||||
console.log(checks + ' checks passed; no network calls.');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,48 @@
|
||||
// Execute the UTS service in memory; no network or real local files.
|
||||
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
|
||||
const ts=require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
class Json {constructor(v={}){Object.assign(this,v)}getString(k){return typeof this[k]==='string'?this[k]:null}getNumber(k){return typeof this[k]==='number'?this[k]:null}getBoolean(k){return typeof this[k]==='boolean'?this[k]:null}getArray(k){return Array.isArray(this[k])?this[k]:null}}
|
||||
let owner=new Json({token:'synthetic-a',userId:'1'}),calls=[],download,upload,reply,listReply=async()=>({documents:[]});
|
||||
const api={API_BASE_URL:'https://synthetic.invalid/api/v1',getSession:()=>owner,clearSession:()=>{owner=null},uploadJson:async(...args)=>{calls.push(args);return new Json(await upload(...args))},requestJson:async(...args)=>{calls.push(args);return new Json(await (args[0]==='/consents'&&args[1]==='GET'?listReply:reply)(...args))}};
|
||||
const uni={downloadFile:options=>{download=options},getFileSystemManager:()=>({unlink:options=>{calls.push(['unlink',options.filePath]);options.success?.({})}})};
|
||||
const modules={};const path=require('node:path');
|
||||
function load(name){if(modules[name])return modules[name];const exports={};modules[name]=exports;vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname,'..',name),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'||p==='./api.uts'?api:load(p.startsWith('@/')?p.slice(2):path.posix.normalize(path.posix.dirname(name)+'/'+p)),uni,UTSJSONObject:Json,Error,Number,Date,Promise,URL},{filename:name});return exports}
|
||||
let hooks={},picker,sheet,navigated,preview;
|
||||
function page(){hooks={};const exports={};const source=fs.readFileSync(path.join(__dirname,'../packages/finance/assessment/assessment.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
vm.runInNewContext(ts.transpileModule(source+'\nexports.page={documents,documentConsent,documentBusy,documentMessage,step,form,chooseDocument,uploadSelectedDocument,previewDocument,removeDocument,documentAction,toggleDocumentConsent,validateStep,showAgreement,next,restoreDocuments,documentPath};',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'?api:p==='@/services/layout.uts'?{usePageLayout:()=>({bottomStyle:()=>'',contentStyle:()=>'',pageStyle:()=>''})}:p.endsWith('.uvue')?{}:load(p.slice(2)),UTSJSONObject:Json,Error,Date,Math,Number,uni:Object.assign(uni,{showToast:()=>{},redirectTo:()=>{},navigateTo:o=>{navigated=o.url},chooseImage:o=>{picker=o},showActionSheet:o=>{sheet=o},previewImage:o=>{preview=o.urls},getCurrentPages:()=>[]}),getCurrentPages:()=>[],ref:value=>({value}),computed:f=>({get value(){return f()}}),onLoad:f=>hooks.load=f,onShow:f=>hooks.show=f,onHide:f=>hooks.hide=f,onUnload:f=>hooks.unload=f,onBackPress:()=>{},setInterval:()=>1,clearInterval:()=>{}},{filename:'assessment.uvue'});
|
||||
hooks.load({});hooks.show();return exports.page;
|
||||
}
|
||||
const flush=async()=>{for(let i=0;i<18;i++)await Promise.resolve()};
|
||||
const agreement={revisionId:'1',code:'document-use',version:'synthetic-v1',scope:'documents',title:'合成协议',content:'仅供测试',contentHash:'a'.repeat(64),effectiveAt:'2026-01-01T00:00:00Z'};
|
||||
function consentReply(url){if(url==='/agreements/document-use')return agreement;return {consentId:'9',scope:'documents',agreementCode:'document-use',agreementVersion:'synthetic-v1',contentHash:'a'.repeat(64),grantedAt:'2026-01-01T00:00:00Z',revokedAt:null,canRevoke:true}}
|
||||
let checks=0;async function test(name,f){await f();checks++;console.log('PASS '+name)}
|
||||
(async()=>{
|
||||
const s=load('services/documents.uts');
|
||||
await test('saved IDs recover after leaving flow, including duplicates and pending deletion',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);let p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});hooks.hide();hooks.unload();listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:true}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'10',category:'business-license',consentId:'9',deletePending:false})]});p=page();await flush();assert.equal(p.documents.value.length,3);assert.equal(p.documents.value[0].deletePending,true);reply=async()=>({documentId:'8',deleted:true});await p.removeDocument('身份证正面','8');assert.equal(p.documents.value.length,2);assert.equal(p.documents.value[0].documentId,'7');listReply=async()=>({documents:[]})});
|
||||
await test('recovery validates IDs, categories and forbids storage paths',async()=>{for(const item of [{documentId:'/tmp/x',category:'id-front',consentId:'9',deletePending:false},{documentId:'7',category:'unknown',consentId:'9',deletePending:false},{documentId:'7',category:'id-front',consentId:'9',deletePending:false,path:'/private/x'}]){listReply=async()=>({documents:[new Json(item)]});await assert.rejects(s.fetchOwnedDocuments())}listReply=async()=>({documents:[]})});
|
||||
await test('late recovery after owner change is refused',async()=>{let done;listReply=()=>new Promise(r=>done=r);const pending=s.fetchOwnedDocuments();owner=new Json({token:'synthetic-b',userId:'2'});done({documents:[]});await assert.rejects(pending);owner=new Json({token:'synthetic-a',userId:'1'});listReply=async()=>({documents:[]})});
|
||||
await test('recovery failure is retryable without losing unsent local photo',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'收入证明',path:'C:/local-pending.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>{throw new Error('synthetic recovery failure')};await p.restoreDocuments();assert.ok(p.documentMessage.value.includes('recovery failure'));assert.equal(p.documents.value[0].path,'C:/local-pending.png');listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.equal(p.documents.value.length,2);assert.equal(p.documents.value.find(x=>x.documentId==='').path,'C:/local-pending.png');listReply=async()=>({documents:[]})});
|
||||
await test('late recovery after leaving page never repopulates hidden form',async()=>{let done;listReply=()=>new Promise(r=>done=r);const p=page();hooks.hide();done({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await flush();assert.equal(p.documents.value.length,0);listReply=async()=>({documents:[]})});
|
||||
await test('recovered remote same-category photo does not trap unsent local selection',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'C:/unknown-receipt.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();p.documentAction('身份证正面');assert.equal(sheet.itemList[2],'移除未上传照片');sheet.success({tapIndex:2});assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'7');p.step.value=3;assert.equal(p.validateStep(),true);listReply=async()=>({documents:[]})});
|
||||
await test('uploaded photo stays visible in its original card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/selected.png']});await flush();assert.equal(p.documentPath('身份证正面'),'C:/selected.png')});
|
||||
await test('same category saved photos remain individually deletable through original card',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});const p=page();await flush();p.documentAction('身份证正面');assert.equal(sheet.itemList.length,2);assert.ok(sheet.itemList[0].includes('照片 1'));sheet.success({tapIndex:1});assert.equal(sheet.itemList[1],'删除照片');reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'8');listReply=async()=>({documents:[]})});
|
||||
await test('restored card thumbnail uses protected download and late thumbnail is cleaned',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.ok(download.url.endsWith('/documents/7/content'));download.success({statusCode:200,tempFilePath:'/tmp/recovered-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/recovered-thumb.png');await p.restoreDocuments();hooks.hide();download.success({statusCode:200,tempFilePath:'/tmp/late-thumb.png'});await flush();assert.equal(p.documents.value.length,0);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-thumb.png'));listReply=async()=>({documents:[]})});
|
||||
await test('late automatic thumbnail cannot delete the current manual preview',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();const automatic=download;const manual=p.previewDocument('身份证正面','7');download.success({statusCode:200,tempFilePath:'/tmp/manual-preview.png'});await manual;const count=calls.length;automatic.success({statusCode:200,tempFilePath:'/tmp/automatic-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/manual-preview.png');assert.ok(!calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/manual-preview.png'));assert.ok(calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/automatic-thumb.png'));listReply=async()=>({documents:[]})});
|
||||
await test('many recovered photos stay manageable within native action-sheet limits',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:Array.from({length:7},(_,i)=>new Json({documentId:String(i+1),category:'id-front',consentId:'9',deletePending:false}))});const p=page();await flush();p.documentAction('身份证正面');assert.ok(sheet.itemList.length<=6);assert.equal(sheet.itemList.at(-1),'更多照片');sheet.success({tapIndex:sheet.itemList.length-1});assert.ok(sheet.itemList[1].includes('照片 7'));sheet.success({tapIndex:1});reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,6);assert.ok(!p.documents.value.some(x=>x.documentId==='7'));listReply=async()=>({documents:[]})});
|
||||
await test('valid server receipt required; local path is not ID',async()=>{upload=async()=>({documentId:'C:/photo.jpg',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(s.uploadDocument('C:/local.jpg','id-front','9'))});
|
||||
await test('multipart receives category and consent ID',async()=>{upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});const r=await s.uploadDocument('C:/local.jpg','id-front','9');assert.equal(r,'7');assert.equal(calls.at(-1)[2].consentId,'9')});
|
||||
await test('unknown or public receipts refused',async()=>{upload=async()=>({documentId:'7',category:'income',mimeType:'image/png',byteSize:10,url:'/uploads/x'});await assert.rejects(s.uploadDocument('x','id-front','9'))});
|
||||
await test('protected download carries bearer without URL token',async()=>{const p=s.downloadDocument('7');assert.equal(download.header.Authorization,'Bearer synthetic-a');assert.equal(download.url,'https://synthetic.invalid/api/v1/documents/7/content');download.success({statusCode:200,tempFilePath:'/tmp/private.png'});assert.equal(await p,'/tmp/private.png')});
|
||||
await test('late download after account switch discarded and temp cleaned',async()=>{const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:200,tempFilePath:'/tmp/late.png'});await assert.rejects(p);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late.png'))});
|
||||
await test('old 401 cannot clear new session',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:401,tempFilePath:'/tmp/error'});await assert.rejects(p);assert.equal(owner.userId,'2')});
|
||||
await test('logout during upload cannot accept receipt',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});let done;upload=()=>new Promise(r=>done=r);const p=s.uploadDocument('x','id-front','9');owner=null;done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(p)});
|
||||
await test('delete only accepts same ID confirmed true',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async()=>({documentId:'8',deleted:true});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:false});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:true});await s.deleteDocument('7')});
|
||||
await test('not logged in means no download/upload/delete dispatch',async()=>{owner=null;const n=calls.length;await assert.rejects(s.uploadDocument('x','id-front','9'));await assert.rejects(s.downloadDocument('7'));await assert.rejects(s.deleteDocument('7'));assert.equal(calls.length,n)});
|
||||
await test('card requires server consent before picker; failed upload remains retryable',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);const p=page();await flush();picker=null;p.chooseDocument('身份证正面');assert.equal(picker,null);await p.toggleDocumentConsent();upload=async()=>{throw new Error('synthetic network loss')};p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});await flush();assert.equal(p.documents.value[0].documentId,'');assert.equal(p.documents.value[0].path,'C:/local.png');p.step.value=3;assert.equal(p.validateStep(),false);upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await p.uploadSelectedDocument('身份证正面');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.validateStep(),true)});
|
||||
await test('delete failure keeps original ID and forbids preview until explicit retry',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});reply=async()=>{throw new Error('synthetic delete failure')};await p.removeDocument('身份证正面','');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].deletePending,true);download=null;await p.previewDocument('身份证正面','');assert.equal(download,null);reply=async()=>({documentId:'7',deleted:true});await p.removeDocument('身份证正面','');assert.equal(p.documents.value.length,0)});
|
||||
await test('late upload after leaving page never restores card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();let done;upload=()=>new Promise(r=>done=r);p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});hooks.hide();done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await flush();assert.equal(p.documents.value.length,0)});
|
||||
await test('late download after leaving page never previews and cleans temp',async()=>{const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});const pending=p.previewDocument('身份证正面','');hooks.hide();preview=null;download.success({statusCode:200,tempFilePath:'/tmp/late-page.png'});await pending;assert.equal(preview,null);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-page.png'))});
|
||||
await test('viewing agreement preserves form and server IDs; temps are not put into flow',async()=>{const p=page();await flush();p.form.value.name='合成姓名';p.documents.value.push({name:'身份证正面',path:'/tmp/private.png',documentId:'7',consentId:'9',deletePending:false});p.showAgreement('资料使用协议');hooks.hide();hooks.show();assert.equal(p.form.value.name,'合成姓名');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].path,'');const saved=load('services/assessment-flow.uts').readAssessmentFlow('synthetic-a');assert.equal(saved.documents[0].path,'')});
|
||||
await test('uploaded photos stay out of assessment JSON; local paths and external grants refused',async()=>{const records=load('packages/finance/services/records.uts');const payload=new Json({...load('services/assessment.uts').emptyAssessmentForm(),type:'personal',documents:[new Json({name:'身份证正面',path:'C:/private.png',documentId:'7',consentId:'9',deletePending:false})],authorizations:[]});reply=async()=>({assessmentId:'2',revisionId:'3',submissionStatus:'accepted'});await records.createAssessment(payload);const body=calls.at(-1)[2];assert.equal(body.documents.length,0);assert.equal(body.consentIds.length,0);assert.ok(!JSON.stringify(body).includes('private.png'));payload.documents=[new Json({path:'C:/local.png'})];await assert.rejects(records.createAssessment(payload));payload.documents=[];payload.authorizations=['credit'];await assert.rejects(records.createAssessment(payload))});
|
||||
console.log('PASS: '+checks+' private document service scenarios');
|
||||
})().catch(e=>{console.error(e.message);process.exitCode=1});
|
||||
@@ -49,6 +49,7 @@ const vendor = {
|
||||
UTSJSONObject: JsonObject, UTS: { UTSType: UtsType },
|
||||
defineComponent: component => component,
|
||||
ref: value => ({ value }), watch: () => {}, onMounted: () => {}, onUnmounted: () => {},
|
||||
onShow: () => {}, onHide: () => {}, onUnload: () => {},
|
||||
onLoad: callback => { onLoad = callback; },
|
||||
index: {
|
||||
getWindowInfo: () => ({ windowWidth: 390, windowHeight: 844, statusBarHeight: 24 }),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Pure UTS data-service regression; no network, account or model access.
|
||||
const fs = require('node:fs'), vm = require('node:vm'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const ts = require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
class Json { constructor(v = {}) { for (const [k,x] of Object.entries(v)) this[k] = Array.isArray(x) ? x.map(wrap) : wrap(x); } getString(k) { return typeof this[k] === 'string' ? this[k] : null; } getNumber(k) {return typeof this[k] === 'number' ? this[k] : null;} getBoolean(k) {return typeof this[k] === 'boolean' ? this[k] : null;} }
|
||||
function wrap(x) { return x && typeof x === 'object' && !Array.isArray(x) ? new Json(x) : x; }
|
||||
let reply;
|
||||
const api = {requestJson:async()=>new Json(reply)};
|
||||
function load(file) {
|
||||
const exports = {};
|
||||
const source = fs.readFileSync(path.join(__dirname,'..',file),'utf8').replace(/const (fieldNames|labels): UTSJSONObject = (.*)/g,'const $1: UTSJSONObject = new UTSJSONObject($2)');
|
||||
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
|
||||
{exports,UTSJSONObject:Json,require:p=>p.endsWith('api.uts')?api:p.endsWith('config.uts')?{API_BASE_URL:'https://synthetic.invalid/api/v1'}:p.endsWith('records.uts')?{revisionParams:v=>({revisionId:v})}:load('services/money.uts'),Error,Number,Math},{filename:file});
|
||||
return exports;
|
||||
}
|
||||
function fixture() {
|
||||
return {assessmentId:'1',revisionId:'2',resultVersion:3,matching:{status:'completed',catalogSnapshotId:'synthetic-hash',ruleVersion:'product-matching-v2',orderingPolicyVersion:'product-score-descending-v1'},item:{productId:'4',productVersion:7,eligibility:'eligible',score:92,scorePolicyVersion:'product-soft-score-v1',rank:1,currentAvailability:'available',positiveReasons:[],blockingReasons:[],cautions:[],evidence:[],aiExplanation:{status:'not_requested'},productSnapshot:{productId:'4',productVersion:7,productName:'合成产品',bankName:'合成机构',productType:'信用贷',amountMinYuan:'500000.00',amountMaxYuan:'3000000.00',annualRateMinPercent:'3.60',annualRateMaxPercent:'8.50',termMinMonths:6,termMaxMonths:36,applicantTypes:['enterprise'],regions:[],validFrom:'2026-01-01',validUntil:'2030-01-01',source:{publisher:'合成机构',title:'仅供测试'},rules:[],bankLogo:'/uploads/synthetic.png',consultPhone:'400-123-4567',repaymentMethod:'按月付息',materials:[{name:'营业执照',required:true,note:'准备有效副本',applicantTypes:['enterprise']}]}}};
|
||||
}
|
||||
(async()=>{
|
||||
const service=load('packages/finance/services/matches.uts'); let checks=0;
|
||||
reply=fixture();let result=await service.fetchMatchDetailRecord('1','4','2');
|
||||
assert.equal(result.item.score,92);checks++;
|
||||
assert.equal(result.item.product.amount,'50–300 万元');checks++;
|
||||
assert.equal(result.item.product.logo,'https://synthetic.invalid/uploads/synthetic.png');checks++;
|
||||
assert.equal(result.item.product.materialList[0].objects,'企业');checks++;
|
||||
assert.equal(result.item.product.consultPhone,'400-123-4567');checks++;
|
||||
assert.equal(result.item.product.repayment,'按月付息');checks++;
|
||||
reply=fixture();reply.item.productSnapshot.expectation='额度按银行审批';reply.item.productSnapshot.quota='期限按合同';reply.item.productSnapshot.source={};reply.item.productSnapshot.regionMode='unverified';reply.item.productSnapshot.annualRateMinPercent=null;reply.item.productSnapshot.annualRateMaxPercent='8.50';
|
||||
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.amount,'额度按银行审批');assert.equal(result.item.product.term,'期限按合同');assert.equal(result.item.product.rate,'最高 8.5%');assert.equal(result.item.product.source,'待核实');assert.equal(result.item.product.regions,'待核实');checks++;
|
||||
for (const [from,until,want] of [[null,null,'未设置有效期'],[null,'2030-01-01','有效至 2030-01-01'],['2026-01-01',null,'2026-01-01 起生效'],['2026-01-01','2030-01-01','2026-01-01 至 2030-01-01']]) {
|
||||
reply=fixture();reply.item.productSnapshot.validFrom=from;reply.item.productSnapshot.validUntil=until;
|
||||
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.valid,want);checks++;
|
||||
}
|
||||
reply=fixture();reply.item.score=null;reply.item.scorePolicyVersion=null;reply.matching.ruleVersion='product-matching-v1';reply.matching.orderingPolicyVersion='product-id-ascending-v1';delete reply.item.productSnapshot.materials;delete reply.item.productSnapshot.bankLogo;
|
||||
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.score,null);assert.equal(result.item.product.materialList.length,0);assert.equal(result.item.product.logo,'');checks++;
|
||||
for(const [score,policy] of [[101,'product-soft-score-v1'],[-1,'product-soft-score-v1'],[92.2,'product-soft-score-v1'],[80,'unknown-policy']]) {reply=fixture();reply.item.score=score;reply.item.scorePolicyVersion=policy;await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;}
|
||||
reply=fixture();reply.item.productSnapshot.bankLogo='javascript:alert(1)';reply.item.productSnapshot.consultPhone='unsafe';result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.logo,'');assert.equal(result.item.product.consultPhone,'');checks++;
|
||||
reply=fixture();reply.revisionId='3';await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;
|
||||
reply=fixture();reply.item.productSnapshot.productId='5';await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;
|
||||
console.log('PASS: '+checks+' product presentation UTS checks');
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
Reference in New Issue
Block a user