Files
BPOS-Service-Gateway/test/integration/sse.test.ts
T

124 lines
5.1 KiB
TypeScript
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.
import { describe, expect, it, beforeAll } from 'vitest';
import { SseHub } from '../../src/modules/sse/hub';
import type { Connection, SseEvent } from '../../src/modules/sse/hub';
import { buildApp } from '../../src/app/app';
import { makeToken, setupTestJwks } from '../support/token';
function conn(id: string, staffSub: string, topics: string[] = []): { c: Connection; got: SseEvent[] } {
const got: SseEvent[] = [];
return { c: { id, staffSub, isAdmin: false, topics: new Set(topics), send: (e) => got.push(e) }, got };
}
describe('SseHub 路由', () => {
it('staff 精确投递、system 广播、task 按订阅集、回放窗口', () => {
const hub = new SseHub(8);
const a = conn('a', 'sub-a', ['touch.task.t1']);
const b = conn('b', 'sub-b');
hub.register(a.c);
hub.register(b.c);
hub.deliver('touch.system', '{}');
hub.deliver('touch.staff.sub-a', '{}');
hub.deliver('touch.task.t1', '{}');
expect(a.got.map((e) => e.topic)).toEqual(['touch.system', 'touch.staff.sub-a', 'touch.task.t1']);
expect(b.got.map((e) => e.topic)).toEqual(['touch.system']);
// 断线重连回放:c 以 lastEventId=1 上线且订阅 t1 → 补 seq>1 的可见事件。
// D11:register 只登记、replay 单独一步——路由要把 hello 插在两者之间。
const c = conn('c', 'sub-c', ['touch.task.t1']);
hub.register(c.c);
hub.replay(c.c, 1);
expect(c.got.map((e) => e.topic)).toEqual(['touch.task.t1']);
});
it('subscribe 授权:access 拒 → 不入订阅集', async () => {
const hub = new SseHub();
const a = conn('a', 'sub-a');
hub.register(a.c);
const denyAccess = { allowed: async () => false };
const res = await hub.subscribe('a', 'stream.task.t9', denyAccess);
expect(res.ok).toBe(false);
expect(a.c.topics.has('stream.task.t9')).toBe(false);
});
it('D3:形状校验排在连接查找之前,失败带判别式 reason 供路由分流', async () => {
const hub = new SseHub();
const denyAccess = { allowed: async () => false };
// 连接不存在 + 形状也不对 → 必须报形状(否则 HTTP 面永远回不出 422)
expect(await hub.subscribe('nope', 'touch.staff.hack', denyAccess)).toMatchObject({
ok: false,
reason: 'bad-shape',
});
expect(await hub.subscribe('nope', 'touch.task.t1', denyAccess)).toMatchObject({
ok: false,
reason: 'no-conn',
});
const a = conn('a', 'sub-a');
hub.register(a.c);
expect(await hub.subscribe('a', 'stream.task.t9', denyAccess)).toMatchObject({
ok: false,
reason: 'denied',
});
});
it('D12:shutdown 逐条调 close 把手并摘除连接', () => {
const hub = new SseHub();
let closed = 0;
const a = conn('a', 'sub-a');
hub.register({ ...a.c, close: () => (closed += 1) });
hub.shutdown();
expect(closed).toBe(1);
hub.deliver('touch.system', '{}');
expect(a.got).toEqual([]); // 已摘除,不再收扇出
});
});
describe('SSE HTTP 面', () => {
beforeAll(async () => {
await setupTestJwks();
});
it('subscribe 主题形状校验 422;无 token 401', async () => {
const app = buildApp();
expect((await app.request('/api/stream/subscribe', { method: 'POST' })).status).toBe(401);
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const bad = await app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ connectionId: 'nope', add: ['touch.staff.hack'] }),
});
expect(bad.status).toBe(422);
});
it('D11/D12:hello 先于回放;hub.shutdown() 能把在途长连收尾', async () => {
const hub = new SseHub(8);
const app = buildApp(hub);
hub.deliver('touch.system', '{"n":1}'); // seq 1
hub.deliver('touch.staff.test-sub-1', '{"n":2}'); // seq 2
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const res = await app.request(`/api/stream?access_token=${token}`, {
headers: { 'last-event-id': '1' },
});
expect(res.status).toBe(200);
const body = res.body;
if (!body) throw new Error('SSE 响应缺流体');
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
while (!text.includes('event: touch.staff.test-sub-1')) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
expect(text).toContain('event: hello');
expect(text.indexOf('event: hello')).toBeLessThan(text.indexOf('event: touch.staff.test-sub-1'));
expect(text).not.toContain('event: touch.system'); // seq 1 ≤ lastEventId,不补
// D12:回调里挂住的 promise 必须能被 close 把手 settle——settle 了 streamSSE 才会
// close 流,客户端这边才读得到 done。旧写法 `await new Promise(() => {})` 永不 settle:
// 下面这次 read 会一直吊着(用例超时),线上则是 SIGTERM 关不掉服务。
hub.shutdown();
let ended = false;
for (let i = 0; i < 4 && !ended; i += 1) ended = (await reader.read()).done;
expect(ended).toBe(true);
});
});