2026-09-24 15:48:45 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Core.invoke 与 Provider 契约测试(任务 A2.2 / A2.3 / A2.4)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 覆盖实施设计 5 要求的六件事:Schema 校验、幂等重放、错误映射、超时与结果未知、
|
|
|
|
|
|
* 执行记录可查、契约测试套件本身能拦住坏 Provider。
|
|
|
|
|
|
*/
|
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
|
import { test } from 'node:test';
|
|
|
|
|
|
|
|
|
|
|
|
import { runProviderContract } from '@aps/provider-sdk/contract-suite';
|
|
|
|
|
|
import { ProviderError } from '@aps/provider-sdk';
|
|
|
|
|
|
import { createFakeProvider } from '@aps/provider-sdk/testing';
|
|
|
|
|
|
|
|
|
|
|
|
const subject = { userId: 'u-1', tenantId: 't-1' };
|
|
|
|
|
|
|
|
|
|
|
|
async function startPlatform(devProvider = true) {
|
|
|
|
|
|
if (devProvider) process.env.APS_DEV_PROVIDER = '1';
|
|
|
|
|
|
else delete process.env.APS_DEV_PROVIDER;
|
|
|
|
|
|
const { startServer } = await import('../server/dist/index.js');
|
2026-09-28 20:30:40 +08:00
|
|
|
|
const server = await startServer({ host: '127.0.0.1', port: 0, version: 'test', databasePath: ':memory:', engine: 'fake' });
|
2026-09-24 15:48:45 +08:00
|
|
|
|
return server;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test('未注册能力返回 invalid_argument,且不产生成功记录', async () => {
|
|
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
|
|
|
|
|
capabilityId: 'fake.__nope__',
|
|
|
|
|
|
params: {},
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(receipt.status, 'failed');
|
|
|
|
|
|
assert.equal(receipt.errorKind, 'invalid_argument');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('正常调用返回 succeeded,执行记录可查且带参数摘要', async () => {
|
|
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
|
|
|
|
|
capabilityId: 'fake.echo',
|
|
|
|
|
|
params: { text: '你好' },
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(receipt.status, 'succeeded');
|
|
|
|
|
|
assert.deepEqual(receipt.result, { echo: '你好' });
|
|
|
|
|
|
|
|
|
|
|
|
const record = server.service.core.executions.get(receipt.executionId);
|
|
|
|
|
|
assert.ok(record, '执行记录应可查');
|
|
|
|
|
|
assert.equal(record.capabilityId, 'fake.echo');
|
|
|
|
|
|
assert.equal(record.providerId, 'fake');
|
|
|
|
|
|
assert.match(record.paramsDigest, /^sha256:/, '只存参数摘要,不存明文');
|
|
|
|
|
|
assert.equal(record.status, 'succeeded');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 10:59:34 +08:00
|
|
|
|
test('同一幂等键重试不重复生效:写操作先开确认卡,确认后只执行一次', async () => {
|
2026-09-24 15:48:45 +08:00
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const request = {
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
2026-09-29 10:59:34 +08:00
|
|
|
|
projectId: 'p-default',
|
2026-09-24 15:48:45 +08:00
|
|
|
|
capabilityId: 'fake.write',
|
|
|
|
|
|
params: { value: 1 },
|
|
|
|
|
|
idempotencyKey: 'idem-core-0001',
|
|
|
|
|
|
};
|
2026-09-29 10:59:34 +08:00
|
|
|
|
|
|
|
|
|
|
// A4:写操作不会直接执行,先落一张待确认动作(协议第 7 节)
|
2026-09-24 15:48:45 +08:00
|
|
|
|
const first = await server.service.invoke(request);
|
2026-09-29 10:59:34 +08:00
|
|
|
|
assert.equal(first.status, 'pending-confirmation');
|
|
|
|
|
|
assert.ok(first.approvalId, '要给出确认卡标识');
|
2026-09-24 15:48:45 +08:00
|
|
|
|
const second = await server.service.invoke(request);
|
2026-09-29 10:59:34 +08:00
|
|
|
|
assert.equal(second.approvalId, first.approvalId, '同一幂等键重试返回同一张确认卡');
|
|
|
|
|
|
assert.equal(provider.stats.calls['fake.write'], undefined, '没有确认就不该碰业务系统');
|
|
|
|
|
|
|
|
|
|
|
|
const approved = await server.service.core.approve(subject, first.approvalId, {
|
|
|
|
|
|
decision: 'approve',
|
|
|
|
|
|
revision: 7,
|
|
|
|
|
|
idempotencyKey: 'idem-approve-0001',
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(approved.kind, 'ok');
|
|
|
|
|
|
assert.equal(approved.receipt.status, 'succeeded');
|
|
|
|
|
|
assert.equal(provider.stats.calls['fake.write'], 1, '确认后执行一次');
|
|
|
|
|
|
|
|
|
|
|
|
const again = await server.service.core.approve(subject, first.approvalId, {
|
|
|
|
|
|
decision: 'approve',
|
|
|
|
|
|
revision: 7,
|
|
|
|
|
|
idempotencyKey: 'idem-approve-0002',
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(again.receipt.executionId, approved.receipt.executionId, '重复确认返回同一执行');
|
|
|
|
|
|
assert.equal(provider.stats.calls['fake.write'], 1, '同一写请求重试不重复生效');
|
2026-09-24 15:48:45 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('超时归一为 unknown_result(结果未知,不等于失败确认)', async () => {
|
|
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
|
|
|
|
|
capabilityId: 'fake.slow',
|
|
|
|
|
|
params: { ms: 500 },
|
|
|
|
|
|
timeoutMs: 50,
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(receipt.status, 'failed');
|
|
|
|
|
|
assert.equal(receipt.errorKind, 'unknown_result');
|
|
|
|
|
|
const record = server.service.core.executions.get(receipt.executionId);
|
|
|
|
|
|
assert.equal(record?.status, 'failed');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('Provider 声明的错误分类原样透传', async () => {
|
|
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
|
|
|
|
|
capabilityId: 'fake.declare-error',
|
|
|
|
|
|
params: { kind: 'forbidden' },
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(receipt.status, 'failed');
|
|
|
|
|
|
assert.equal(receipt.errorKind, 'forbidden');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('裸异常归一为 unknown_result,不进脏分类', async () => {
|
|
|
|
|
|
const provider = createFakeProvider();
|
|
|
|
|
|
const server = await startPlatform(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await server.service.registerProvider(provider);
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-test',
|
|
|
|
|
|
capabilityId: 'fake.crash',
|
|
|
|
|
|
params: {},
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(receipt.status, 'failed');
|
|
|
|
|
|
assert.equal(receipt.errorKind, 'unknown_result');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('契约套件:假 Provider 全绿', async () => {
|
|
|
|
|
|
const report = await runProviderContract(createFakeProvider());
|
|
|
|
|
|
assert.equal(report.ok, true, JSON.stringify(report.checks.filter((check) => !check.ok), null, 2));
|
|
|
|
|
|
assert.equal(report.providerId, 'fake');
|
|
|
|
|
|
assert.ok(report.checks.length >= 7, `检查项应有若干条,实际 ${report.checks.length}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('契约套件:坏 Provider 被拦下(负例)', async () => {
|
|
|
|
|
|
const broken = {
|
|
|
|
|
|
async describe() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: 'broken',
|
|
|
|
|
|
displayName: '坏 Provider',
|
|
|
|
|
|
version: '0.0.0',
|
|
|
|
|
|
platformVersion: 1,
|
|
|
|
|
|
capabilities: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'Broken_Capability',
|
|
|
|
|
|
description: '这个描述故意写得非常长'.repeat(5),
|
|
|
|
|
|
effect: 'execute',
|
|
|
|
|
|
mode: 'batch',
|
|
|
|
|
|
inputSchema: null,
|
|
|
|
|
|
outputSchema: null,
|
|
|
|
|
|
idempotency: 'none',
|
|
|
|
|
|
errors: ['nonsense'],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
async invoke() {
|
|
|
|
|
|
throw new Error('不该被调用');
|
|
|
|
|
|
},
|
|
|
|
|
|
async health() {
|
|
|
|
|
|
return { status: 'ok', checkedAt: Date.now() };
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
const report = await runProviderContract(broken);
|
|
|
|
|
|
assert.equal(report.ok, false, '坏 Provider 不应通过契约测试');
|
|
|
|
|
|
const failed = report.checks.filter((check) => !check.ok);
|
|
|
|
|
|
assert.ok(failed.length >= 2, `应有多项失败,实际:${failed.map((item) => item.name).join('、')}`);
|
|
|
|
|
|
const capabilityDetail = failed.find((item) => item.name.includes('声明合法'))?.detail ?? '';
|
|
|
|
|
|
for (const expected of ['id 命名不合规', 'effect 非法', 'mode 非法', '缺 inputSchema', 'errors 含非法分类']) {
|
|
|
|
|
|
assert.ok(capabilityDetail.includes(expected), `应报告「${expected}」,实际:${capabilityDetail}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.ok(failed.some((item) => item.name === '未知能力必须报错'), '裸异常不算合规的未知能力报错');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('HTTP:注册 Provider 后能力清单非空,执行记录端点可查回执', async () => {
|
|
|
|
|
|
const server = await startPlatform(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const capabilities = await (await fetch(`${server.url}/v1/capabilities`)).json();
|
|
|
|
|
|
assert.ok(capabilities.capabilities.length >= 5, '假 Provider 应暴露 5 个能力');
|
|
|
|
|
|
const first = capabilities.capabilities[0];
|
|
|
|
|
|
assert.equal(typeof first.providerId, 'string');
|
|
|
|
|
|
assert.ok(first.providerId.length > 0, '能力条目应带 providerId');
|
|
|
|
|
|
|
|
|
|
|
|
const receipt = await server.service.invoke({
|
|
|
|
|
|
subject,
|
|
|
|
|
|
sessionId: 's-http',
|
|
|
|
|
|
capabilityId: 'fake.echo',
|
|
|
|
|
|
params: { text: '通过 HTTP 查回执' },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 11:01:50 +08:00
|
|
|
|
const response = await fetch(`${server.url}/v1/executions/${receipt.executionId}`, { headers: { 'x-aps-user': subject.userId, 'x-aps-tenant': subject.tenantId } });
|
2026-09-24 15:48:45 +08:00
|
|
|
|
assert.equal(response.status, 200, '执行记录端点应返回 200');
|
|
|
|
|
|
const body = await response.json();
|
|
|
|
|
|
assert.equal(body.executionId, receipt.executionId);
|
|
|
|
|
|
assert.equal(body.status, 'succeeded');
|
|
|
|
|
|
|
2026-09-29 11:01:50 +08:00
|
|
|
|
const missing = await fetch(`${server.url}/v1/executions/exec-none`, { headers: { 'x-aps-user': subject.userId } });
|
2026-09-24 15:48:45 +08:00
|
|
|
|
assert.equal(missing.status, 404, '未知执行记录应 404');
|
|
|
|
|
|
const missingBody = await missing.json();
|
|
|
|
|
|
assert.equal(missingBody.error.code, 'not_found');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.APS_DEV_PROVIDER;
|
|
|
|
|
|
await server.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|