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,265 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { partReducer } from '../../src/commands/part';
|
|
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(1_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 = nick.charAt(0).toUpperCase() + nick.slice(1);
|
|
38
|
+
s.registration = 'registered';
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeChan(name = '#foo'): ChannelState {
|
|
43
|
+
return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Adds a member to the channel roster. */
|
|
47
|
+
function addMember(chan: ChannelState, connId: string, nick: string, op = false): void {
|
|
48
|
+
chan.members.set(connId, { conn: connId, nick, op, voice: false });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const L = (text: string): RawLine => ({ text });
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// partReducer — success path
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('partReducer — success', () => {
|
|
58
|
+
it('broadcasts PART with reason to the channel and removes the parter from the roster', () => {
|
|
59
|
+
const chan = makeChan('#foo');
|
|
60
|
+
addMember(chan, 'c1', 'alice');
|
|
61
|
+
addMember(chan, 'c2', 'bob');
|
|
62
|
+
const conn = makeConn();
|
|
63
|
+
conn.joinedChannels.add('#foo');
|
|
64
|
+
const ctx = makeCtx(conn);
|
|
65
|
+
|
|
66
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo', 'leaving'], tags: {} }, ctx);
|
|
67
|
+
|
|
68
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
69
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo :leaving')]),
|
|
70
|
+
Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c1' }] }),
|
|
71
|
+
]);
|
|
72
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('broadcasts PART without a trailing reason when none is supplied', () => {
|
|
76
|
+
const chan = makeChan('#foo');
|
|
77
|
+
addMember(chan, 'c1', 'alice');
|
|
78
|
+
const conn = makeConn();
|
|
79
|
+
conn.joinedChannels.add('#foo');
|
|
80
|
+
const ctx = makeCtx(conn);
|
|
81
|
+
|
|
82
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
83
|
+
|
|
84
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
85
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('treats an empty reason param the same as no reason param', () => {
|
|
90
|
+
const chan = makeChan('#foo');
|
|
91
|
+
addMember(chan, 'c1', 'alice');
|
|
92
|
+
const conn = makeConn();
|
|
93
|
+
conn.joinedChannels.add('#foo');
|
|
94
|
+
const ctx = makeCtx(conn);
|
|
95
|
+
|
|
96
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo', ''], tags: {} }, ctx);
|
|
97
|
+
|
|
98
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
99
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('includes the parting connection in the broadcast (no except)', () => {
|
|
104
|
+
const chan = makeChan('#foo');
|
|
105
|
+
addMember(chan, 'c1', 'alice');
|
|
106
|
+
const conn = makeConn();
|
|
107
|
+
conn.joinedChannels.add('#foo');
|
|
108
|
+
const ctx = makeCtx(conn);
|
|
109
|
+
|
|
110
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
111
|
+
|
|
112
|
+
const broadcast = out.effects.find(
|
|
113
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
114
|
+
);
|
|
115
|
+
expect(broadcast?.except).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('removes the channel from the connection joinedChannels set (cross-authority mutation)', () => {
|
|
119
|
+
const chan = makeChan('#foo');
|
|
120
|
+
addMember(chan, 'c1', 'alice');
|
|
121
|
+
const conn = makeConn();
|
|
122
|
+
conn.joinedChannels.add('#foo');
|
|
123
|
+
conn.joinedChannels.add('#bar');
|
|
124
|
+
const ctx = makeCtx(conn);
|
|
125
|
+
|
|
126
|
+
partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
127
|
+
|
|
128
|
+
expect(conn.joinedChannels.has('#foo')).toBe(false);
|
|
129
|
+
expect(conn.joinedChannels.has('#bar')).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('updates the connection lastSeen to ctx.clock.now()', () => {
|
|
133
|
+
const chan = makeChan('#foo');
|
|
134
|
+
addMember(chan, 'c1', 'alice');
|
|
135
|
+
const conn = makeConn();
|
|
136
|
+
conn.joinedChannels.add('#foo');
|
|
137
|
+
const clock = new FakeClock(7_500);
|
|
138
|
+
const ctx = makeCtx(conn, clock);
|
|
139
|
+
|
|
140
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
141
|
+
|
|
142
|
+
expect(out.state).toBe(chan);
|
|
143
|
+
expect(conn.lastSeen).toBe(7_500);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns the same state reference (mutation permitted, no copy)', () => {
|
|
147
|
+
const chan = makeChan('#foo');
|
|
148
|
+
addMember(chan, 'c1', 'alice');
|
|
149
|
+
const conn = makeConn();
|
|
150
|
+
conn.joinedChannels.add('#foo');
|
|
151
|
+
const ctx = makeCtx(conn);
|
|
152
|
+
|
|
153
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
154
|
+
|
|
155
|
+
expect(out.state).toBe(chan);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('falls back to ? as the PART source when the connection has no nick (defensive)', () => {
|
|
159
|
+
const chan = makeChan('#foo');
|
|
160
|
+
// Roster entry with a stale nick but the connection itself has no nick.
|
|
161
|
+
addMember(chan, 'c1', '?');
|
|
162
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
163
|
+
const ctx = makeCtx(conn);
|
|
164
|
+
|
|
165
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
166
|
+
|
|
167
|
+
expect(out.effects).toContainEqual<EffectType>(Effect.broadcast('#foo', [L(':? PART #foo')]));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// partReducer — rejections
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
describe('partReducer — rejections', () => {
|
|
176
|
+
it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {
|
|
177
|
+
const chan = makeChan('#foo');
|
|
178
|
+
addMember(chan, 'c1', 'alice');
|
|
179
|
+
const conn = makeConn();
|
|
180
|
+
conn.joinedChannels.add('#foo');
|
|
181
|
+
const ctx = makeCtx(conn);
|
|
182
|
+
|
|
183
|
+
const out = partReducer(chan, { command: 'PART', params: [], tags: {} }, ctx);
|
|
184
|
+
|
|
185
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
186
|
+
Effect.send('c1', [L(':irc.example.com 461 alice PART :Not enough parameters')]),
|
|
187
|
+
]);
|
|
188
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {
|
|
192
|
+
const chan = makeChan('foo');
|
|
193
|
+
const conn = makeConn();
|
|
194
|
+
const ctx = makeCtx(conn);
|
|
195
|
+
|
|
196
|
+
const out = partReducer(chan, { command: 'PART', params: ['foo'], tags: {} }, ctx);
|
|
197
|
+
|
|
198
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
199
|
+
Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {
|
|
204
|
+
const chan = makeChan('#foo');
|
|
205
|
+
const conn = makeConn();
|
|
206
|
+
const ctx = makeCtx(conn);
|
|
207
|
+
|
|
208
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo,bar'], tags: {} }, ctx);
|
|
209
|
+
|
|
210
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
211
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
|
|
216
|
+
const chan = makeChan('#foo');
|
|
217
|
+
const conn = makeConn();
|
|
218
|
+
const ctx = makeCtx(conn);
|
|
219
|
+
|
|
220
|
+
const out = partReducer(chan, { command: 'PART', params: [''], tags: {} }, ctx);
|
|
221
|
+
|
|
222
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
223
|
+
Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name exceeding the length cap', () => {
|
|
228
|
+
const longName = `#${'a'.repeat(50)}`;
|
|
229
|
+
const chan = makeChan(longName);
|
|
230
|
+
const conn = makeConn();
|
|
231
|
+
const ctx = makeCtx(conn);
|
|
232
|
+
|
|
233
|
+
const out = partReducer(chan, { command: 'PART', params: [longName], tags: {} }, ctx);
|
|
234
|
+
|
|
235
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
236
|
+
Effect.send('c1', [L(`:irc.example.com 403 alice ${longName} :No such channel`)]),
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('emits 442 ERR_NOTONCHANNEL when the connection is not on the channel', () => {
|
|
241
|
+
const chan = makeChan('#foo');
|
|
242
|
+
addMember(chan, 'c2', 'bob');
|
|
243
|
+
const conn = makeConn();
|
|
244
|
+
const ctx = makeCtx(conn);
|
|
245
|
+
|
|
246
|
+
const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);
|
|
247
|
+
|
|
248
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
249
|
+
Effect.send('c1', [L(":irc.example.com 442 alice #foo :You're not on that channel")]),
|
|
250
|
+
]);
|
|
251
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('uses * in numeric replies when the connection has no nick (defensive)', () => {
|
|
255
|
+
const chan = makeChan('#foo');
|
|
256
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
257
|
+
const ctx = makeCtx(conn);
|
|
258
|
+
|
|
259
|
+
const out = partReducer(chan, { command: 'PART', params: [], tags: {} }, ctx);
|
|
260
|
+
|
|
261
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
262
|
+
Effect.send('c1', [L(':irc.example.com 461 * PART :Not enough parameters')]),
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pingReducer, pongReducer } from '../../src/commands/ping';
|
|
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 ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
|
+
|
|
9
|
+
const serverConfig: ServerConfig = {
|
|
10
|
+
serverName: 'irc.example.com',
|
|
11
|
+
networkName: 'ExampleNet',
|
|
12
|
+
maxChannelsPerUser: 30,
|
|
13
|
+
maxTargetsPerCommand: 10,
|
|
14
|
+
maxListEntries: 50,
|
|
15
|
+
nickLen: 30,
|
|
16
|
+
channelLen: 50,
|
|
17
|
+
topicLen: 390,
|
|
18
|
+
quitMessage: 'Client Quit',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
|
|
22
|
+
return buildCtx({
|
|
23
|
+
serverConfig,
|
|
24
|
+
clock,
|
|
25
|
+
ids: new SequentialIdFactory(),
|
|
26
|
+
motd: EmptyMotdProvider,
|
|
27
|
+
connection: state,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeState(): ConnectionState {
|
|
32
|
+
return createConnection({ id: 'c1', connectedSince: 0 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const L = (text: string): RawLine => ({ text });
|
|
36
|
+
|
|
37
|
+
describe('pingReducer', () => {
|
|
38
|
+
it('replies with PONG echoing the trailing token', () => {
|
|
39
|
+
const state = makeState();
|
|
40
|
+
const ctx = makeCtx(state);
|
|
41
|
+
const out = pingReducer(state, { command: 'PING', params: ['tok'], tags: {} }, ctx);
|
|
42
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('PONG :tok')])]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('still replies with a bare PONG when no token is provided', () => {
|
|
46
|
+
const state = makeState();
|
|
47
|
+
const ctx = makeCtx(state);
|
|
48
|
+
const out = pingReducer(state, { command: 'PING', params: [], tags: {} }, ctx);
|
|
49
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('PONG')])]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('echoes an empty trailing token as a dangling-colon PONG', () => {
|
|
53
|
+
const state = makeState();
|
|
54
|
+
const ctx = makeCtx(state);
|
|
55
|
+
const out = pingReducer(state, { command: 'PING', params: [''], tags: {} }, ctx);
|
|
56
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('PONG :')])]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('accepts a lower-case command token', () => {
|
|
60
|
+
const state = makeState();
|
|
61
|
+
const ctx = makeCtx(state);
|
|
62
|
+
const out = pingReducer(state, { command: 'ping', params: ['tok'], tags: {} }, ctx);
|
|
63
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('PONG :tok')])]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('accepts a mixed-case command token', () => {
|
|
67
|
+
const state = makeState();
|
|
68
|
+
const ctx = makeCtx(state);
|
|
69
|
+
const out = pingReducer(state, { command: 'PiNg', params: ['tok'], tags: {} }, ctx);
|
|
70
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('PONG :tok')])]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('updates connection lastSeen to ctx.clock.now()', () => {
|
|
74
|
+
const state = makeState();
|
|
75
|
+
expect(state.lastSeen).toBe(0);
|
|
76
|
+
const clock = new FakeClock(5_000);
|
|
77
|
+
const ctx = makeCtx(state, clock);
|
|
78
|
+
const out = pingReducer(state, { command: 'PING', params: ['tok'], tags: {} }, ctx);
|
|
79
|
+
expect(out.state.lastSeen).toBe(5_000);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not mutate any connection field other than lastSeen', () => {
|
|
83
|
+
const state = makeState();
|
|
84
|
+
state.nick = 'alice';
|
|
85
|
+
state.user = 'alice';
|
|
86
|
+
state.host = 'h';
|
|
87
|
+
state.realname = 'Alice';
|
|
88
|
+
state.caps.add('server-time');
|
|
89
|
+
state.joinedChannels.add('#foo');
|
|
90
|
+
state.userModes.invisible = true;
|
|
91
|
+
state.registration = 'registered';
|
|
92
|
+
|
|
93
|
+
const ctx = makeCtx(state);
|
|
94
|
+
const out = pingReducer(state, { command: 'PING', params: ['tok'], tags: {} }, ctx);
|
|
95
|
+
|
|
96
|
+
expect(out.state.id).toBe('c1');
|
|
97
|
+
expect(out.state.nick).toBe('alice');
|
|
98
|
+
expect(out.state.user).toBe('alice');
|
|
99
|
+
expect(out.state.host).toBe('h');
|
|
100
|
+
expect(out.state.realname).toBe('Alice');
|
|
101
|
+
expect(out.state.caps).toEqual(new Set(['server-time']));
|
|
102
|
+
expect(out.state.joinedChannels).toEqual(new Set(['#foo']));
|
|
103
|
+
expect(out.state.userModes.invisible).toBe(true);
|
|
104
|
+
expect(out.state.registration).toBe('registered');
|
|
105
|
+
expect(out.state.connectedSince).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns the same state reference (mutation permitted, no copy)', () => {
|
|
109
|
+
const state = makeState();
|
|
110
|
+
const ctx = makeCtx(state);
|
|
111
|
+
const out = pingReducer(state, { command: 'PING', params: ['tok'], tags: {} }, ctx);
|
|
112
|
+
expect(out.state).toBe(state);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('pongReducer', () => {
|
|
117
|
+
// PONG is the client's reply to a server-initiated PING. The reducer does
|
|
118
|
+
// not emit any reply; it only refreshes lastSeen. Idle / PING bookkeeping
|
|
119
|
+
// (deciding when to send the next server-initiated PING, or when to drop a
|
|
120
|
+
// silent connection) is the runtime's responsibility.
|
|
121
|
+
|
|
122
|
+
it('emits no effects', () => {
|
|
123
|
+
const state = makeState();
|
|
124
|
+
const ctx = makeCtx(state);
|
|
125
|
+
const out = pongReducer(state, { command: 'PONG', params: ['tok'], tags: {} }, ctx);
|
|
126
|
+
expect(out.effects).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('updates connection lastSeen to ctx.clock.now()', () => {
|
|
130
|
+
const state = makeState();
|
|
131
|
+
const clock = new FakeClock(7_000);
|
|
132
|
+
const ctx = makeCtx(state, clock);
|
|
133
|
+
const out = pongReducer(state, { command: 'PONG', params: ['tok'], tags: {} }, ctx);
|
|
134
|
+
expect(out.state.lastSeen).toBe(7_000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('accepts a PONG with no token', () => {
|
|
138
|
+
const state = makeState();
|
|
139
|
+
const ctx = makeCtx(state);
|
|
140
|
+
const out = pongReducer(state, { command: 'PONG', params: [], tags: {} }, ctx);
|
|
141
|
+
expect(out.effects).toEqual([]);
|
|
142
|
+
expect(out.state.lastSeen).toBe(1_000);
|
|
143
|
+
});
|
|
144
|
+
});
|