Files
SnowLuma/packages/onebot/tests/network-manager.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

304 lines
9.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
getLogLevel,
runWithRequestId,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
import {
IOneBotNetworkAdapter,
NetworkReloadType,
OneBotNetworkManager,
type AdapterStatus,
} from '../src/network';
import type { NetworkAdapterContext } from '../src/network';
import type { JsonObject, NetworkBase } from '../src/types';
import type { DispatchPayload } from '../src/event-filter';
interface FakeNetworkConfig extends NetworkBase {
// Allows tests to influence behavior via config:
failOnEvent?: boolean;
}
class FakeAdapter extends IOneBotNetworkAdapter<FakeNetworkConfig> {
events: JsonObject[] = [];
opens = 0;
closes = 0;
reloads = 0;
// Last dispatch payload object identity, to assert it was reused per emit.
lastPayloads: DispatchPayload[] = [];
waitForEvent: Promise<void> | undefined;
open(): void {
this.opens++;
if (this.config.enabled !== false) {
this.isEnabled = true;
}
}
close(): void {
this.closes++;
this.isEnabled = false;
}
// Keeps its own reload — this is a manager test double, not a reload test;
// base.reload became concrete so this is now a genuine override.
override reload(next: FakeNetworkConfig): NetworkReloadType {
this.reloads++;
this.config = structuredClone(next);
if (next.enabled === false && this.isEnabled) {
this.close();
return NetworkReloadType.Closed;
}
if (next.enabled !== false && !this.isEnabled) {
this.open();
return NetworkReloadType.Opened;
}
return NetworkReloadType.Normal;
}
protected bindingSignature(config: FakeNetworkConfig): string {
return JSON.stringify(config);
}
describeStatus(): AdapterStatus {
return {
name: this.name,
kind: 'httpClient',
status: this.isActive ? 'ok' : 'disabled',
detail: this.isActive ? 'active' : 'disabled',
};
}
async onEvent(event: JsonObject, payload: DispatchPayload): Promise<void> {
this.events.push(event);
this.lastPayloads.push(payload);
if (this.config.failOnEvent) {
throw new Error('boom');
}
await this.waitForEvent;
}
}
const NULL_CTX: NetworkAdapterContext = {
uin: '10001',
api: {
handle: async () => ({ status: 'failed', retcode: 0, data: null }),
processStreamRequest: async () => {},
} as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};
function makeAdapter(name: string, partial: Partial<FakeNetworkConfig> = {}): FakeAdapter {
const config: FakeNetworkConfig = {
name,
enabled: true,
messageFormat: 'array',
reportSelfMessage: false,
...partial,
};
return new FakeAdapter(name, config, NULL_CTX);
}
const SAMPLE_EVENT: JsonObject = {
time: 1,
self_id: 10001,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 1,
user_id: 22222,
message: [{ type: 'text', data: { text: 'hi' } }],
raw_message: 'hi',
font: 0,
sender: { user_id: 22222, nickname: 'peer', sex: 'unknown', age: 0 },
};
describe('OneBotNetworkManager', () => {
it('opens every adapter and emits to every active adapter', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
expect(a.opens).toBe(1);
expect(b.opens).toBe(1);
await mgr.emitEvent(SAMPLE_EVENT);
expect(a.events).toEqual([SAMPLE_EVENT]);
expect(b.events).toEqual([SAMPLE_EVENT]);
// Both adapters should receive the same payload reference (built once).
expect(a.lastPayloads[0]).toBe(b.lastPayloads[0]);
});
it('skips inactive adapters during emit', async () => {
const mgr = new OneBotNetworkManager();
const active = makeAdapter('active');
const idle = makeAdapter('idle', { enabled: false });
mgr.register(active);
mgr.register(idle);
await mgr.openAll();
await mgr.emitEvent(SAMPLE_EVENT);
expect(active.events).toHaveLength(1);
expect(idle.events).toHaveLength(0);
});
it('isolates adapter errors so one bad adapter does not block others', async () => {
const mgr = new OneBotNetworkManager();
const ok = makeAdapter('ok');
const bad = makeAdapter('bad', { failOnEvent: true });
mgr.register(ok);
mgr.register(bad);
await mgr.openAll();
await mgr.emitEvent(SAMPLE_EVENT);
// The good adapter still saw the event.
expect(ok.events).toHaveLength(1);
// The bad adapter logged the throw but didn't crash the manager.
expect(bad.events).toHaveLength(1);
});
it('traces active targets, isolated failures, settlement, and no-target conclusion', async () => {
const previousLevel = getLogLevel();
const entries: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
setLogLevel('trace');
try {
const mgr = new OneBotNetworkManager();
const ok = makeAdapter('ok-target');
const bad = makeAdapter('bad-target', { failOnEvent: true });
mgr.register(ok);
mgr.register(bad);
await mgr.openAll();
await runWithRequestId(7001, () => mgr.emitEvent(SAMPLE_EVENT));
const report = entries.filter(
(entry) =>
entry.scope === 'OneBot.Network' &&
entry.level === 'trace' &&
entry.req === 7001,
);
expect(
report.find((entry) => entry.message.startsWith('report_targets'))?.message,
).toContain('["ok-target","bad-target"]');
expect(
report.find((entry) => entry.message.startsWith('report_target_failed'))?.message,
).toContain('target=bad-target');
expect(
report.filter((entry) => entry.message === 'report_terminal outcome=settled'),
).toHaveLength(1);
entries.length = 0;
const empty = new OneBotNetworkManager();
await runWithRequestId(7002, () => empty.emitEvent(SAMPLE_EVENT));
expect(
entries.filter(
(entry) =>
entry.scope === 'OneBot.Network' &&
entry.req === 7002 &&
entry.message === 'report_terminal outcome=no_active_targets',
),
).toHaveLength(1);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
});
it('rejects duplicate registration instead of detaching the old close', async () => {
const mgr = new OneBotNetworkManager();
const first = makeAdapter('dup');
mgr.register(first);
await mgr.openAll();
const second = makeAdapter('dup');
expect(() => mgr.register(second)).toThrow(/already registered/);
expect(mgr.get('dup')).toBe(first);
expect(first.closes).toBe(0);
});
it('closeOne shuts down a single adapter and removes it', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
await mgr.closeOne('a');
expect(mgr.has('a')).toBe(false);
expect(a.closes).toBe(1);
expect(b.closes).toBe(0);
expect(mgr.hasActiveAdapters()).toBe(true);
});
it('shutdown clears every adapter', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
await mgr.shutdown();
expect(mgr.list()).toHaveLength(0);
expect(a.closes).toBe(1);
expect(b.closes).toBe(1);
});
it('hasActiveAdapters reflects current state', async () => {
const mgr = new OneBotNetworkManager();
expect(mgr.hasActiveAdapters()).toBe(false);
const a = makeAdapter('a');
mgr.register(a);
expect(mgr.hasActiveAdapters()).toBe(false);
await mgr.openAll();
expect(mgr.hasActiveAdapters()).toBe(true);
await mgr.shutdown();
expect(mgr.hasActiveAdapters()).toBe(false);
});
it('rejects new registration after shutdown', async () => {
const mgr = new OneBotNetworkManager();
await mgr.shutdown();
expect(() => mgr.register(makeAdapter('late'))).toThrow(/shutting down/);
await expect(mgr.shutdown()).resolves.toEqual({ closed: true, errors: [] });
expect(mgr.list()).toEqual([]);
});
it('stops new emits and drains an in-flight emit before adapter close', async () => {
let release!: () => void;
const eventGate = new Promise<void>((resolve) => {
release = resolve;
});
const mgr = new OneBotNetworkManager();
const adapter = makeAdapter('slow');
adapter.waitForEvent = eventGate;
mgr.register(adapter);
await mgr.openAll();
const emitting = mgr.emitEvent(SAMPLE_EVENT);
const closing = mgr.shutdown();
expect(mgr.shutdown()).toBe(closing);
await expect(mgr.emitEvent(SAMPLE_EVENT)).rejects.toThrow(/shutting down/);
await Promise.resolve();
await Promise.resolve();
expect(adapter.closes).toBe(0);
release();
await emitting;
await closing;
expect(mgr.shutdown()).toBe(closing);
expect(adapter.closes).toBe(1);
});
});