serverless-ircd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { whoisReducer } from '../../src/commands/whois';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
|
+
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
7
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
8
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
9
|
+
|
|
10
|
+
const serverConfig: ServerConfig = {
|
|
11
|
+
serverName: 'irc.example.com',
|
|
12
|
+
networkName: 'ExampleNet',
|
|
13
|
+
maxChannelsPerUser: 30,
|
|
14
|
+
maxTargetsPerCommand: 10,
|
|
15
|
+
maxListEntries: 50,
|
|
16
|
+
nickLen: 30,
|
|
17
|
+
channelLen: 50,
|
|
18
|
+
topicLen: 390,
|
|
19
|
+
quitMessage: 'Client Quit',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function makeCtx(conn: ConnectionState, clock = new FakeClock(10_000)): Ctx {
|
|
23
|
+
return buildCtx({
|
|
24
|
+
serverConfig,
|
|
25
|
+
clock,
|
|
26
|
+
ids: new SequentialIdFactory(),
|
|
27
|
+
motd: EmptyMotdProvider,
|
|
28
|
+
connection: conn,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
33
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
34
|
+
s.nick = nick;
|
|
35
|
+
s.user = nick;
|
|
36
|
+
s.host = 'example.com';
|
|
37
|
+
s.realname = 'Alice';
|
|
38
|
+
s.registration = 'registered';
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTarget(
|
|
43
|
+
id: string,
|
|
44
|
+
nick: string,
|
|
45
|
+
opts: {
|
|
46
|
+
user?: string;
|
|
47
|
+
host?: string;
|
|
48
|
+
realname?: string;
|
|
49
|
+
away?: string;
|
|
50
|
+
oper?: boolean;
|
|
51
|
+
account?: string;
|
|
52
|
+
connectedSince?: number;
|
|
53
|
+
lastSeen?: number;
|
|
54
|
+
} = {},
|
|
55
|
+
): ConnectionState {
|
|
56
|
+
const since = opts.connectedSince ?? 1_000;
|
|
57
|
+
const s = createConnection({
|
|
58
|
+
id,
|
|
59
|
+
connectedSince: since,
|
|
60
|
+
...(opts.lastSeen !== undefined ? { lastSeen: opts.lastSeen } : {}),
|
|
61
|
+
});
|
|
62
|
+
s.nick = nick;
|
|
63
|
+
s.user = opts.user ?? nick;
|
|
64
|
+
s.host = opts.host ?? 'target.example.net';
|
|
65
|
+
s.realname = opts.realname ?? nick;
|
|
66
|
+
s.registration = 'registered';
|
|
67
|
+
if (opts.away !== undefined) s.away = opts.away;
|
|
68
|
+
if (opts.oper !== undefined) s.userModes.oper = opts.oper;
|
|
69
|
+
if (opts.account !== undefined) s.account = opts.account;
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeChan(name: string, opts: { secret?: boolean; private?: boolean } = {}): ChannelState {
|
|
74
|
+
const c = createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
|
|
75
|
+
if (opts.secret) c.modes.secret = true;
|
|
76
|
+
if (opts.private) c.modes.private = true;
|
|
77
|
+
return c;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addMember(
|
|
81
|
+
chan: ChannelState,
|
|
82
|
+
connId: string,
|
|
83
|
+
nick: string,
|
|
84
|
+
op = false,
|
|
85
|
+
voice = false,
|
|
86
|
+
): void {
|
|
87
|
+
chan.members.set(connId, { conn: connId, nick, op, voice });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const L = (text: string): RawLine => ({ text });
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// whoisReducer — happy path
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
describe('whoisReducer — happy path', () => {
|
|
97
|
+
it('emits 311/312/317/318 for a registered user with no channels', () => {
|
|
98
|
+
const requester = makeConn();
|
|
99
|
+
const ctx = makeCtx(requester, new FakeClock(10_000));
|
|
100
|
+
const target = makeTarget('c2', 'bob', { connectedSince: 4_000, lastSeen: 8_000 });
|
|
101
|
+
|
|
102
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
103
|
+
|
|
104
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
105
|
+
Effect.send('c1', [
|
|
106
|
+
L(':irc.example.com 311 alice bob bob target.example.net * :bob'),
|
|
107
|
+
L(':irc.example.com 312 alice bob irc.example.com :ExampleNet'),
|
|
108
|
+
L(':irc.example.com 317 alice bob 2 4 :seconds idle, signon time'),
|
|
109
|
+
]),
|
|
110
|
+
Effect.send('c1', [L(':irc.example.com 318 alice bob :End of /WHOIS list')]),
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('emits 319 RPL_WHOISCHANNELS between 317 and 318 when the target is in channels', () => {
|
|
115
|
+
const requester = makeConn();
|
|
116
|
+
const ctx = makeCtx(requester);
|
|
117
|
+
const target = makeTarget('c2', 'bob');
|
|
118
|
+
|
|
119
|
+
const pub = makeChan('#foo');
|
|
120
|
+
addMember(pub, 'c2', 'bob');
|
|
121
|
+
const pub2 = makeChan('#bar');
|
|
122
|
+
addMember(pub2, 'c2', 'bob', true);
|
|
123
|
+
|
|
124
|
+
const out = whoisReducer(target, [pub, pub2], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
125
|
+
|
|
126
|
+
const send319 = out.effects[1];
|
|
127
|
+
expect(send319?.tag).toBe('Send');
|
|
128
|
+
if (send319?.tag === 'Send') {
|
|
129
|
+
// Channels sorted alphabetically with prefix sigils.
|
|
130
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :@#bar #foo');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('emits 330 RPL_WHOISACCOUNT when the target has an account', () => {
|
|
135
|
+
const requester = makeConn();
|
|
136
|
+
const ctx = makeCtx(requester);
|
|
137
|
+
const target = makeTarget('c2', 'bob', { account: 'aliceacct' });
|
|
138
|
+
|
|
139
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
140
|
+
|
|
141
|
+
const send = out.effects[0];
|
|
142
|
+
expect(send?.tag).toBe('Send');
|
|
143
|
+
if (send?.tag === 'Send') {
|
|
144
|
+
// 330 appears in the first batch alongside 311/312/317.
|
|
145
|
+
expect(send.lines.some((l) => l.text.includes(' 330 '))).toBe(true);
|
|
146
|
+
expect(send.lines.some((l) => l.text.includes(' aliceacct '))).toBe(true);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('emits 313 RPL_WHOISOPERATOR when the target is an IRC operator', () => {
|
|
151
|
+
const requester = makeConn();
|
|
152
|
+
const ctx = makeCtx(requester);
|
|
153
|
+
const target = makeTarget('c2', 'bob', { oper: true });
|
|
154
|
+
|
|
155
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
156
|
+
|
|
157
|
+
const send = out.effects[0];
|
|
158
|
+
if (send?.tag === 'Send') {
|
|
159
|
+
expect(send.lines.some((l) => l.text.includes(' 313 '))).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// whoisReducer — idle/signon math
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
describe('whoisReducer — idle and signon', () => {
|
|
169
|
+
it('reports idle seconds as floor((now - lastSeen) / 1000)', () => {
|
|
170
|
+
const requester = makeConn();
|
|
171
|
+
// clock.now = 10_000ms, target.lastSeen = 8_000ms → 2s idle.
|
|
172
|
+
const ctx = makeCtx(requester, new FakeClock(10_000));
|
|
173
|
+
const target = makeTarget('c2', 'bob', { connectedSince: 4_000, lastSeen: 8_000 });
|
|
174
|
+
|
|
175
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
176
|
+
|
|
177
|
+
const send = out.effects[0];
|
|
178
|
+
if (send?.tag === 'Send') {
|
|
179
|
+
const idle = send.lines.find((l) => l.text.includes(' 317 '));
|
|
180
|
+
expect(idle?.text).toContain(' 317 alice bob 2 4 ');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reports signon as floor(connectedSince / 1000)', () => {
|
|
185
|
+
const requester = makeConn();
|
|
186
|
+
const ctx = makeCtx(requester);
|
|
187
|
+
const target = makeTarget('c2', 'bob', { connectedSince: 65_000 });
|
|
188
|
+
|
|
189
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
190
|
+
|
|
191
|
+
const send = out.effects[0];
|
|
192
|
+
if (send?.tag === 'Send') {
|
|
193
|
+
const idle = send.lines.find((l) => l.text.includes(' 317 '));
|
|
194
|
+
expect(idle?.text).toContain(' 65 ');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// whoisReducer — channel visibility
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
describe('whoisReducer — channel visibility', () => {
|
|
204
|
+
it('hides secret (+s) channels the requester is not on', () => {
|
|
205
|
+
const requester = makeConn();
|
|
206
|
+
const ctx = makeCtx(requester);
|
|
207
|
+
const target = makeTarget('c2', 'bob');
|
|
208
|
+
|
|
209
|
+
const pub = makeChan('#foo');
|
|
210
|
+
addMember(pub, 'c2', 'bob');
|
|
211
|
+
const sec = makeChan('#secret', { secret: true });
|
|
212
|
+
addMember(sec, 'c2', 'bob');
|
|
213
|
+
|
|
214
|
+
const out = whoisReducer(target, [pub, sec], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
215
|
+
|
|
216
|
+
const send319 = out.effects[1];
|
|
217
|
+
if (send319?.tag === 'Send') {
|
|
218
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#foo');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('shows secret (+s) channels the requester is also on', () => {
|
|
223
|
+
const requester = makeConn();
|
|
224
|
+
const ctx = makeCtx(requester);
|
|
225
|
+
const target = makeTarget('c2', 'bob');
|
|
226
|
+
|
|
227
|
+
const sec = makeChan('#secret', { secret: true });
|
|
228
|
+
addMember(sec, 'c2', 'bob');
|
|
229
|
+
addMember(sec, 'c1', 'alice');
|
|
230
|
+
|
|
231
|
+
const out = whoisReducer(target, [sec], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
232
|
+
|
|
233
|
+
const send319 = out.effects[1];
|
|
234
|
+
if (send319?.tag === 'Send') {
|
|
235
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#secret');
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('hides private (+p) channels the requester is not on', () => {
|
|
240
|
+
const requester = makeConn();
|
|
241
|
+
const ctx = makeCtx(requester);
|
|
242
|
+
const target = makeTarget('c2', 'bob');
|
|
243
|
+
|
|
244
|
+
const priv = makeChan('#priv', { private: true });
|
|
245
|
+
addMember(priv, 'c2', 'bob');
|
|
246
|
+
|
|
247
|
+
const out = whoisReducer(target, [priv], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
248
|
+
|
|
249
|
+
// No 319 emitted.
|
|
250
|
+
const send319 = out.effects[1];
|
|
251
|
+
expect(send319?.tag).toBe('Send');
|
|
252
|
+
if (send319?.tag === 'Send') {
|
|
253
|
+
expect(send319.lines[0]?.text).toContain(' 318 ');
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('prefixes op channels with @ and voiced channels with +', () => {
|
|
258
|
+
const requester = makeConn();
|
|
259
|
+
const ctx = makeCtx(requester);
|
|
260
|
+
const target = makeTarget('c2', 'bob');
|
|
261
|
+
|
|
262
|
+
const opChan = makeChan('#op');
|
|
263
|
+
addMember(opChan, 'c2', 'bob', true);
|
|
264
|
+
const voiceChan = makeChan('#voice');
|
|
265
|
+
addMember(voiceChan, 'c2', 'bob', false, true);
|
|
266
|
+
|
|
267
|
+
const out = whoisReducer(
|
|
268
|
+
target,
|
|
269
|
+
[opChan, voiceChan],
|
|
270
|
+
{ command: 'WHOIS', params: ['bob'] },
|
|
271
|
+
ctx,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const send319 = out.effects[1];
|
|
275
|
+
if (send319?.tag === 'Send') {
|
|
276
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :@#op +#voice');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('emits no 319 when no channels are visible', () => {
|
|
281
|
+
const requester = makeConn();
|
|
282
|
+
const ctx = makeCtx(requester);
|
|
283
|
+
const target = makeTarget('c2', 'bob');
|
|
284
|
+
|
|
285
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
286
|
+
|
|
287
|
+
// 318 should be the second effect (no 319 in between).
|
|
288
|
+
expect(out.effects.length).toBe(2);
|
|
289
|
+
expect(out.effects[1]?.tag).toBe('Send');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('sorts channels alphabetically regardless of input order', () => {
|
|
293
|
+
const requester = makeConn();
|
|
294
|
+
const ctx = makeCtx(requester);
|
|
295
|
+
const target = makeTarget('c2', 'bob');
|
|
296
|
+
|
|
297
|
+
const zed = makeChan('#zed');
|
|
298
|
+
addMember(zed, 'c2', 'bob');
|
|
299
|
+
const alpha = makeChan('#alpha');
|
|
300
|
+
addMember(alpha, 'c2', 'bob');
|
|
301
|
+
|
|
302
|
+
const out = whoisReducer(target, [zed, alpha], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
303
|
+
|
|
304
|
+
const send319 = out.effects[1];
|
|
305
|
+
if (send319?.tag === 'Send') {
|
|
306
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#alpha #zed');
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('skips channels where the target is not actually a member', () => {
|
|
311
|
+
const requester = makeConn();
|
|
312
|
+
const ctx = makeCtx(requester);
|
|
313
|
+
const target = makeTarget('c2', 'bob');
|
|
314
|
+
|
|
315
|
+
const realChan = makeChan('#real');
|
|
316
|
+
addMember(realChan, 'c2', 'bob');
|
|
317
|
+
const ghostChan = makeChan('#ghost');
|
|
318
|
+
// Target is NOT a member of #ghost.
|
|
319
|
+
|
|
320
|
+
const out = whoisReducer(
|
|
321
|
+
target,
|
|
322
|
+
[realChan, ghostChan],
|
|
323
|
+
{ command: 'WHOIS', params: ['bob'] },
|
|
324
|
+
ctx,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const send319 = out.effects[1];
|
|
328
|
+
if (send319?.tag === 'Send') {
|
|
329
|
+
expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#real');
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// whoisReducer — errors and edge cases
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
338
|
+
describe('whoisReducer — errors and edge cases', () => {
|
|
339
|
+
it('emits 401 then 318 for an unknown nick', () => {
|
|
340
|
+
const requester = makeConn();
|
|
341
|
+
const ctx = makeCtx(requester);
|
|
342
|
+
|
|
343
|
+
const out = whoisReducer(undefined, [], { command: 'WHOIS', params: ['ghost'] }, ctx);
|
|
344
|
+
|
|
345
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
346
|
+
Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),
|
|
347
|
+
Effect.send('c1', [L(':irc.example.com 318 alice ghost :End of /WHOIS list')]),
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('uses * in replies when the requester has no nick', () => {
|
|
352
|
+
const requester = createConnection({ id: 'c1', connectedSince: 0 });
|
|
353
|
+
const ctx = makeCtx(requester);
|
|
354
|
+
const target = makeTarget('c2', 'bob');
|
|
355
|
+
|
|
356
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
357
|
+
|
|
358
|
+
const end = out.effects.at(-1);
|
|
359
|
+
if (end?.tag === 'Send') {
|
|
360
|
+
expect(end.lines[0]?.text).toBe(':irc.example.com 318 * bob :End of /WHOIS list');
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('updates the requester lastSeen to ctx.clock.now()', () => {
|
|
365
|
+
const requester = makeConn();
|
|
366
|
+
const clock = new FakeClock(7_700);
|
|
367
|
+
const ctx = makeCtx(requester, clock);
|
|
368
|
+
const target = makeTarget('c2', 'bob');
|
|
369
|
+
|
|
370
|
+
whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
371
|
+
|
|
372
|
+
expect(requester.lastSeen).toBe(7_700);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('emits 401-less 318 when target is undefined and params are empty', () => {
|
|
376
|
+
const requester = makeConn();
|
|
377
|
+
const ctx = makeCtx(requester);
|
|
378
|
+
|
|
379
|
+
const out = whoisReducer(undefined, [], { command: 'WHOIS', params: [] }, ctx);
|
|
380
|
+
|
|
381
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
382
|
+
Effect.send('c1', [L(':irc.example.com 318 alice :End of /WHOIS list')]),
|
|
383
|
+
]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('uses ? fallbacks when target has missing user/host/realname fields', () => {
|
|
387
|
+
const requester = makeConn();
|
|
388
|
+
const ctx = makeCtx(requester);
|
|
389
|
+
// Construct a target with only nick set (no user/host/realname).
|
|
390
|
+
const target = createConnection({ id: 'c2', connectedSince: 1_000 });
|
|
391
|
+
target.nick = 'bob';
|
|
392
|
+
target.registration = 'registered';
|
|
393
|
+
|
|
394
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
395
|
+
|
|
396
|
+
const send = out.effects[0];
|
|
397
|
+
if (send?.tag === 'Send') {
|
|
398
|
+
const line311 = send.lines.find((l) => l.text.includes(' 311 '));
|
|
399
|
+
// nick is bob, user/host fall back to ?, realname falls back to nick.
|
|
400
|
+
expect(line311?.text).toBe(':irc.example.com 311 alice bob ? ? * :bob');
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('uses * fallback for target nick when target has no nick set', () => {
|
|
405
|
+
const requester = makeConn();
|
|
406
|
+
const ctx = makeCtx(requester);
|
|
407
|
+
// Target ConnectionState with no nick at all (highly defensive path).
|
|
408
|
+
const target = createConnection({ id: 'c2', connectedSince: 1_000 });
|
|
409
|
+
target.user = 'bob';
|
|
410
|
+
target.host = 'h';
|
|
411
|
+
target.realname = 'Bob';
|
|
412
|
+
target.registration = 'registered';
|
|
413
|
+
|
|
414
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
415
|
+
|
|
416
|
+
const end = out.effects.at(-1);
|
|
417
|
+
if (end?.tag === 'Send') {
|
|
418
|
+
// The 318 line middle param falls back to * when target.nick is undefined.
|
|
419
|
+
expect(end.lines[0]?.text).toBe(':irc.example.com 318 alice * :End of /WHOIS list');
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, type EffectTag, type Effect as EffectType, type RawLine } from '../src/effects';
|
|
3
|
+
import type { EffectDispatchResult } from '../src/effects';
|
|
4
|
+
import type { ChannelDelta } from '../src/state/channel';
|
|
5
|
+
|
|
6
|
+
const line = (text: string): RawLine => ({ text });
|
|
7
|
+
|
|
8
|
+
describe('Effect constructors', () => {
|
|
9
|
+
it('Send carries target conn and lines', () => {
|
|
10
|
+
const e = Effect.send('c1', [line('PONG :tok')]);
|
|
11
|
+
expect(e).toEqual<EffectType>({ tag: 'Send', to: 'c1', lines: [{ text: 'PONG :tok' }] });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('Broadcast carries channel, lines, and optional except', () => {
|
|
15
|
+
expect(Effect.broadcast('#foo', [line('hi')])).toEqual<EffectType>({
|
|
16
|
+
tag: 'Broadcast',
|
|
17
|
+
chan: '#foo',
|
|
18
|
+
lines: [{ text: 'hi' }],
|
|
19
|
+
});
|
|
20
|
+
expect(Effect.broadcast('#foo', [line('hi')], 'c1')).toEqual<EffectType>({
|
|
21
|
+
tag: 'Broadcast',
|
|
22
|
+
chan: '#foo',
|
|
23
|
+
lines: [{ text: 'hi' }],
|
|
24
|
+
except: 'c1',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('Disconnect carries conn and optional reason', () => {
|
|
29
|
+
expect(Effect.disconnect('c1')).toEqual<EffectType>({ tag: 'Disconnect', conn: 'c1' });
|
|
30
|
+
expect(Effect.disconnect('c1', 'excess flood')).toEqual<EffectType>({
|
|
31
|
+
tag: 'Disconnect',
|
|
32
|
+
conn: 'c1',
|
|
33
|
+
reason: 'excess flood',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ReserveNick carries nick and conn', () => {
|
|
38
|
+
expect(Effect.reserveNick('alice', 'c1')).toEqual<EffectType>({
|
|
39
|
+
tag: 'ReserveNick',
|
|
40
|
+
nick: 'alice',
|
|
41
|
+
conn: 'c1',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('ChangeNick carries conn, oldNick, newNick', () => {
|
|
46
|
+
expect(Effect.changeNick('c1', 'alice', 'bob')).toEqual<EffectType>({
|
|
47
|
+
tag: 'ChangeNick',
|
|
48
|
+
conn: 'c1',
|
|
49
|
+
oldNick: 'alice',
|
|
50
|
+
newNick: 'bob',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('ReleaseNick carries nick', () => {
|
|
55
|
+
expect(Effect.releaseNick('alice')).toEqual<EffectType>({
|
|
56
|
+
tag: 'ReleaseNick',
|
|
57
|
+
nick: 'alice',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('ApplyChannelDelta carries chan and delta', () => {
|
|
62
|
+
const delta: ChannelDelta = { memberships: [{ type: 'remove', conn: 'c1' }] };
|
|
63
|
+
expect(Effect.applyChannelDelta('#foo', delta)).toEqual<EffectType>({
|
|
64
|
+
tag: 'ApplyChannelDelta',
|
|
65
|
+
chan: '#foo',
|
|
66
|
+
delta,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('LookupNick carries nick', () => {
|
|
71
|
+
expect(Effect.lookupNick('alice')).toEqual<EffectType>({
|
|
72
|
+
tag: 'LookupNick',
|
|
73
|
+
nick: 'alice',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('GetConnectionInfo carries conn', () => {
|
|
78
|
+
expect(Effect.getConnectionInfo('c1')).toEqual<EffectType>({
|
|
79
|
+
tag: 'GetConnectionInfo',
|
|
80
|
+
conn: 'c1',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('GetChannelSnapshot carries chan', () => {
|
|
85
|
+
expect(Effect.getChannelSnapshot('#foo')).toEqual<EffectType>({
|
|
86
|
+
tag: 'GetChannelSnapshot',
|
|
87
|
+
chan: '#foo',
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('SendToNick carries nick, sender, lines, and optional notFoundLines', () => {
|
|
92
|
+
expect(Effect.sendToNick('alice', 'c1', [line('hi')])).toEqual<EffectType>({
|
|
93
|
+
tag: 'SendToNick',
|
|
94
|
+
nick: 'alice',
|
|
95
|
+
sender: 'c1',
|
|
96
|
+
lines: [{ text: 'hi' }],
|
|
97
|
+
});
|
|
98
|
+
expect(Effect.sendToNick('alice', 'c1', [line('hi')], [line('401')])).toEqual<EffectType>({
|
|
99
|
+
tag: 'SendToNick',
|
|
100
|
+
nick: 'alice',
|
|
101
|
+
sender: 'c1',
|
|
102
|
+
lines: [{ text: 'hi' }],
|
|
103
|
+
notFoundLines: [{ text: '401' }],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Effect tag set', () => {
|
|
109
|
+
// Ensures the Effect union covers every side-effect referenced in PLAN §2.1.
|
|
110
|
+
const expectedTags: EffectTag[] = [
|
|
111
|
+
'Send',
|
|
112
|
+
'Broadcast',
|
|
113
|
+
'Disconnect',
|
|
114
|
+
'ReserveNick',
|
|
115
|
+
'ChangeNick',
|
|
116
|
+
'ReleaseNick',
|
|
117
|
+
'ApplyChannelDelta',
|
|
118
|
+
'LookupNick',
|
|
119
|
+
'GetConnectionInfo',
|
|
120
|
+
'GetChannelSnapshot',
|
|
121
|
+
'SendToNick',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
|
|
125
|
+
const constructors: Record<EffectTag, EffectType> = {
|
|
126
|
+
Send: Effect.send('c', []),
|
|
127
|
+
Broadcast: Effect.broadcast('#c', []),
|
|
128
|
+
Disconnect: Effect.disconnect('c'),
|
|
129
|
+
ReserveNick: Effect.reserveNick('n', 'c'),
|
|
130
|
+
ChangeNick: Effect.changeNick('c', 'a', 'b'),
|
|
131
|
+
ReleaseNick: Effect.releaseNick('n'),
|
|
132
|
+
ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
|
|
133
|
+
LookupNick: Effect.lookupNick('n'),
|
|
134
|
+
GetConnectionInfo: Effect.getConnectionInfo('c'),
|
|
135
|
+
GetChannelSnapshot: Effect.getChannelSnapshot('#c'),
|
|
136
|
+
SendToNick: Effect.sendToNick('n', 'c', []),
|
|
137
|
+
};
|
|
138
|
+
expect(constructors[tag].tag).toBe(tag);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('EffectDispatchResult', () => {
|
|
143
|
+
it('is exported as a type alias for per-effect lookup results', () => {
|
|
144
|
+
const r: EffectDispatchResult = { ok: true };
|
|
145
|
+
expect(r.ok).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
});
|