// 插件/客户端接入 SDK(`sdk/im-plugin-client`)的契约测试。 // // 盯三件事(都是"插件侧会踩、且踩了很难查"的): // ① 凭据头与插件身份头的**注入**(`x-dsh-im-instance-token` / `x-dsh-im-plugin-id`); // ② 失败一律折成 `{ok:false,reason,detail}` —— 插件侧只需判 `ok`,⛔ 不必维护状态码表; // ③ 未配置时**立刻**回 `not-registered`,⛔ 不发请求、⛔ 不抛异常。 // // 跑法:`npm test`。 import { test } from 'node:test' import assert from 'node:assert/strict' import { createServer } from 'node:http' import { SDK_VERSION, createImClient } from '../sdk/im-plugin-client/index.mjs' /** 起一个假平台:记下请求,按剧本回。 */ async function withServer(handler, fn) { const seen = [] const server = createServer((req, res) => { let body = '' req.on('data', (c) => (body += c)) req.on('end', () => { const record = { method: req.method, url: req.url, headers: req.headers, body } seen.push(record) const out = handler(record) res.writeHead(out.status ?? 200, { 'content-type': 'application/json' }) res.end(out.body === undefined ? '' : JSON.stringify(out.body)) }) }) await new Promise((r) => server.listen(0, '127.0.0.1', r)) const baseUrl = `http://127.0.0.1:${server.address().port}` try { return await fn(baseUrl, seen) } finally { await new Promise((r) => server.close(r)) } } test('im-plugin-client: 注入了实例凭据头与插件身份头', async () => { await withServer( () => ({ body: { ok: true, value: [{ note_id: 'n1' }] } }), async (baseUrl, seen) => { const im = createImClient({ baseUrl: baseUrl + '/', token: 'tok-1', pluginId: '@dsh-local/trpg-kit' }) const r = await im.data.table('my_notes').find({ eq: { body: 'x' }, limit: 5, afterCursor: 'n0', order: 'desc' }) assert.equal(r.ok, true) assert.deepEqual(r.value, [{ note_id: 'n1' }]) const req = seen[0] assert.equal(req.method, 'GET') assert.equal(req.headers['x-dsh-im-instance-token'], 'tok-1') assert.equal(req.headers['x-dsh-im-plugin-id'], '@dsh-local/trpg-kit') assert.equal(req.url, '/api/im/data/my_notes?eq.body=x&after=n0&order=desc&limit=5') }, ) }) test('im-plugin-client: 写与改与删走对方法与路径(表名不带 p_ 前缀)', async () => { await withServer( () => ({ body: { ok: true, value: {} } }), async (baseUrl, seen) => { const im = createImClient({ baseUrl, token: 't', pluginId: 'p' }) const t = im.data.table('character_sheet') await t.insert({ pc_name: 'a', room_id: 'r1' }) await t.update('a', { sheet: { hp: 3 } }) await t.remove('a') await t.count({ eq: { room_id: 'r1' } }) assert.deepEqual(seen.map((s) => `${s.method} ${s.url}`), [ 'POST /api/im/data/character_sheet', 'PATCH /api/im/data/character_sheet/a', 'DELETE /api/im/data/character_sheet/a', 'GET /api/im/data/character_sheet/count?eq.room_id=r1', ]) assert.deepEqual(JSON.parse(seen[0].body), { pc_name: 'a', room_id: 'r1' }) }, ) }) test('im-plugin-client: 契约失败原样透传(200 + ok:false)', async () => { await withServer( () => ({ body: { ok: false, reason: 'quota-exceeded', detail: '超过单插件行数上限 200000' } }), async (baseUrl) => { const im = createImClient({ baseUrl, token: 't', pluginId: 'p' }) const r = await im.data.table('my_notes').insert({ note_id: 'n' }) assert.equal(r.ok, false) assert.equal(r.reason, 'quota-exceeded') assert.match(r.detail, /行数上限/) }, ) }) test('im-plugin-client: 非 200(身份 / 就绪)也折成同一个 ok:false 形状', async () => { await withServer( (req) => (req.url.includes('status') ? { status: 409, body: { error: 'plugin-data-unavailable', reason: 'datastore-not-ready', detail: '尚未建库' } } : { status: 401, body: { error: 'instance-token-rejected', reason: 'unknown' } }), async (baseUrl) => { const im = createImClient({ baseUrl, token: 'bad', pluginId: 'p' }) const s = await im.data.status() assert.equal(s.ok, false) assert.equal(s.reason, 'disabled') assert.equal(s.httpStatus, 409) assert.match(s.detail, /尚未建库/) const r = await im.rooms.list() assert.equal(r.reason, 'not-registered', '401 ⇒ not-registered(凭据不认)') }, ) }) test('im-plugin-client: 未配置时立刻回 not-registered —— ⛔ 不发请求、⛔ 不抛', async () => { await withServer( () => { throw new Error('不应该发出请求') }, async (baseUrl, seen) => { const im = createImClient({ baseUrl: '', token: 'super-secret-1', pluginId: 'p' }) assert.equal(im.configured, false) const r = await im.data.table('my_notes').find() assert.equal(r.ok, false) assert.equal(r.reason, 'not-registered') assert.equal(seen.length, 0) assert.ok(!String(im).includes('super-secret-1'), '诊断串⛔ 不含 token 明文') assert.match(String(im), /token=已注入/, '只回"有没有配"') }, ) }) test('im-plugin-client: 版本号可读(插件报障要带它)', () => { assert.match(SDK_VERSION, /^\d+\.\d+\.\d+$/) })