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.
Files changed (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,452 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { listReducer } from '../../src/commands/list';
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 {
7
+ type ChanSnapshot,
8
+ type ChannelState,
9
+ createChannel,
10
+ toSnapshot,
11
+ } from '../../src/state/channel';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const serverConfig: ServerConfig = {
16
+ serverName: 'irc.example.com',
17
+ networkName: 'ExampleNet',
18
+ maxChannelsPerUser: 30,
19
+ maxTargetsPerCommand: 10,
20
+ maxListEntries: 50,
21
+ nickLen: 30,
22
+ channelLen: 50,
23
+ topicLen: 390,
24
+ quitMessage: 'Client Quit',
25
+ };
26
+
27
+ function makeCtx(
28
+ conn: ConnectionState,
29
+ overrides: Partial<ServerConfig> = {},
30
+ clock = new FakeClock(1_000),
31
+ ): Ctx {
32
+ return buildCtx({
33
+ serverConfig: { ...serverConfig, ...overrides },
34
+ clock,
35
+ ids: new SequentialIdFactory(),
36
+ motd: EmptyMotdProvider,
37
+ connection: conn,
38
+ });
39
+ }
40
+
41
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
42
+ const s = createConnection({ id, connectedSince: 0 });
43
+ s.nick = nick;
44
+ s.user = nick;
45
+ s.host = 'example.com';
46
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
47
+ s.registration = 'registered';
48
+ return s;
49
+ }
50
+
51
+ function makeChan(name = '#foo'): ChannelState {
52
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
53
+ }
54
+
55
+ function addMember(
56
+ chan: ChannelState,
57
+ connId: string,
58
+ nick: string,
59
+ op = false,
60
+ voice = false,
61
+ ): void {
62
+ chan.members.set(connId, { conn: connId, nick, op, voice });
63
+ }
64
+
65
+ function snapshot(chan: ChannelState): ChanSnapshot {
66
+ return toSnapshot(chan);
67
+ }
68
+
69
+ const L = (text: string): RawLine => ({ text });
70
+
71
+ // ============================================================================
72
+ // listReducer — LIST with no args
73
+ // ============================================================================
74
+
75
+ describe('listReducer — no args (enumerate visible channels)', () => {
76
+ it('emits 321, one 322 per public channel ordered by snapshot order, then 323', () => {
77
+ const foo = makeChan('#foo');
78
+ addMember(foo, 'c2', 'bob');
79
+ const bar = makeChan('#bar');
80
+ addMember(bar, 'c3', 'carol');
81
+ const channels = [snapshot(foo), snapshot(bar)];
82
+ const conn = makeConn();
83
+ const ctx = makeCtx(conn);
84
+
85
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
86
+
87
+ expect(out.effects).toEqual<EffectType[]>([
88
+ Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),
89
+ Effect.send('c1', [
90
+ L(':irc.example.com 322 alice #foo 1 :'),
91
+ L(':irc.example.com 322 alice #bar 1 :'),
92
+ ]),
93
+ Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),
94
+ ]);
95
+ });
96
+
97
+ it('includes the topic text in the 322 trailing when a topic is set', () => {
98
+ const foo = makeChan('#foo');
99
+ foo.topic = { text: 'Welcome to foo', setter: 'bob', setAt: 0 };
100
+ addMember(foo, 'c2', 'bob');
101
+ const channels = [snapshot(foo)];
102
+ const conn = makeConn();
103
+ const ctx = makeCtx(conn);
104
+
105
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
106
+
107
+ const list = out.effects[1];
108
+ if (list?.tag === 'Send') {
109
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :Welcome to foo');
110
+ } else {
111
+ throw new Error('expected Send effect for RPL_LIST');
112
+ }
113
+ });
114
+
115
+ it('hides secret (+s) channels the requester has not joined', () => {
116
+ const foo = makeChan('#foo');
117
+ addMember(foo, 'c2', 'bob');
118
+ const secret = makeChan('#secret');
119
+ secret.modes.secret = true;
120
+ addMember(secret, 'c3', 'carol');
121
+ const channels = [snapshot(foo), snapshot(secret)];
122
+ const conn = makeConn();
123
+ const ctx = makeCtx(conn);
124
+
125
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
126
+
127
+ const list = out.effects[1];
128
+ if (list?.tag === 'Send') {
129
+ expect(list.lines).toHaveLength(1);
130
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :');
131
+ } else {
132
+ throw new Error('expected Send effect for RPL_LIST');
133
+ }
134
+ });
135
+
136
+ it('reveals secret (+s) channels the requester HAS joined', () => {
137
+ const secret = makeChan('#secret');
138
+ secret.modes.secret = true;
139
+ addMember(secret, 'c1', 'alice');
140
+ addMember(secret, 'c2', 'bob');
141
+ const channels = [snapshot(secret)];
142
+ const conn = makeConn();
143
+ const ctx = makeCtx(conn);
144
+
145
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
146
+
147
+ const list = out.effects[1];
148
+ if (list?.tag === 'Send') {
149
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #secret 2 :');
150
+ } else {
151
+ throw new Error('expected Send effect for RPL_LIST');
152
+ }
153
+ });
154
+
155
+ it('emits 321 then 323 with no 322s when there are no visible channels', () => {
156
+ const channels: ChanSnapshot[] = [];
157
+ const conn = makeConn();
158
+ const ctx = makeCtx(conn);
159
+
160
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
161
+
162
+ expect(out.effects).toEqual<EffectType[]>([
163
+ Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),
164
+ Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),
165
+ ]);
166
+ });
167
+ });
168
+
169
+ // ============================================================================
170
+ // listReducer — LIST with explicit channel list
171
+ // ============================================================================
172
+
173
+ describe('listReducer — explicit channel list', () => {
174
+ it('emits a 322 only for the requested channels', () => {
175
+ const foo = makeChan('#foo');
176
+ addMember(foo, 'c2', 'bob');
177
+ const bar = makeChan('#bar');
178
+ addMember(bar, 'c3', 'carol');
179
+ const baz = makeChan('#baz');
180
+ addMember(baz, 'c4', 'dave');
181
+ const channels = [snapshot(foo), snapshot(bar), snapshot(baz)];
182
+ const conn = makeConn();
183
+ const ctx = makeCtx(conn);
184
+
185
+ const out = listReducer(channels, { command: 'LIST', params: ['#foo,#baz'], tags: {} }, ctx);
186
+
187
+ const list = out.effects[1];
188
+ if (list?.tag === 'Send') {
189
+ expect(list.lines).toEqual<RawLine[]>([
190
+ L(':irc.example.com 322 alice #foo 1 :'),
191
+ L(':irc.example.com 322 alice #baz 1 :'),
192
+ ]);
193
+ } else {
194
+ throw new Error('expected Send effect for RPL_LIST');
195
+ }
196
+ });
197
+
198
+ it('handles a single-channel argument', () => {
199
+ const foo = makeChan('#foo');
200
+ addMember(foo, 'c2', 'bob');
201
+ const channels = [snapshot(foo)];
202
+ const conn = makeConn();
203
+ const ctx = makeCtx(conn);
204
+
205
+ const out = listReducer(channels, { command: 'LIST', params: ['#foo'], tags: {} }, ctx);
206
+
207
+ const list = out.effects[1];
208
+ if (list?.tag === 'Send') {
209
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
210
+ } else {
211
+ throw new Error('expected Send effect for RPL_LIST');
212
+ }
213
+ });
214
+
215
+ it('silently skips requested channels that do not exist', () => {
216
+ const foo = makeChan('#foo');
217
+ addMember(foo, 'c2', 'bob');
218
+ const channels = [snapshot(foo)];
219
+ const conn = makeConn();
220
+ const ctx = makeCtx(conn);
221
+
222
+ const out = listReducer(channels, { command: 'LIST', params: ['#foo,#nope'], tags: {} }, ctx);
223
+
224
+ const list = out.effects[1];
225
+ if (list?.tag === 'Send') {
226
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
227
+ } else {
228
+ throw new Error('expected Send effect for RPL_LIST');
229
+ }
230
+ });
231
+
232
+ it('silently skips requested channels with invalid names', () => {
233
+ const foo = makeChan('#foo');
234
+ addMember(foo, 'c2', 'bob');
235
+ const channels = [snapshot(foo)];
236
+ const conn = makeConn();
237
+ const ctx = makeCtx(conn);
238
+
239
+ const out = listReducer(
240
+ channels,
241
+ { command: 'LIST', params: ['#foo,bogus,#foo,bar'], tags: {} },
242
+ ctx,
243
+ );
244
+
245
+ const list = out.effects[1];
246
+ if (list?.tag === 'Send') {
247
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
248
+ } else {
249
+ throw new Error('expected Send effect for RPL_LIST');
250
+ }
251
+ });
252
+
253
+ it('silently skips requested channels whose names exceed the configured length cap', () => {
254
+ const foo = makeChan('#foo');
255
+ addMember(foo, 'c2', 'bob');
256
+ const channels = [snapshot(foo)];
257
+ const conn = makeConn();
258
+ // channelLen is 50 by default; supply an over-long name alongside a valid one.
259
+ const longName = `#${'x'.repeat(60)}`;
260
+ const ctx = makeCtx(conn);
261
+
262
+ const out = listReducer(
263
+ channels,
264
+ { command: 'LIST', params: [`#foo,${longName}`], tags: {} },
265
+ ctx,
266
+ );
267
+
268
+ const list = out.effects[1];
269
+ if (list?.tag === 'Send') {
270
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
271
+ } else {
272
+ throw new Error('expected Send effect for RPL_LIST');
273
+ }
274
+ });
275
+
276
+ it('still filters secret channels when explicitly named by a non-member', () => {
277
+ const secret = makeChan('#secret');
278
+ secret.modes.secret = true;
279
+ addMember(secret, 'c2', 'bob');
280
+ const channels = [snapshot(secret)];
281
+ const conn = makeConn();
282
+ const ctx = makeCtx(conn);
283
+
284
+ const out = listReducer(channels, { command: 'LIST', params: ['#secret'], tags: {} }, ctx);
285
+
286
+ expect(out.effects).toEqual<EffectType[]>([
287
+ Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),
288
+ Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),
289
+ ]);
290
+ });
291
+
292
+ it('performs case-insensitive channel-name matching', () => {
293
+ const foo = makeChan('#Foo');
294
+ addMember(foo, 'c2', 'bob');
295
+ const channels = [snapshot(foo)];
296
+ const conn = makeConn();
297
+ const ctx = makeCtx(conn);
298
+
299
+ const out = listReducer(channels, { command: 'LIST', params: ['#foo'], tags: {} }, ctx);
300
+
301
+ const list = out.effects[1];
302
+ if (list?.tag === 'Send') {
303
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #Foo 1 :');
304
+ } else {
305
+ throw new Error('expected Send effect for RPL_LIST');
306
+ }
307
+ });
308
+
309
+ it('preserves the original channel name in the 322 line (preserved-case)', () => {
310
+ const foo = makeChan('#Foo');
311
+ addMember(foo, 'c2', 'bob');
312
+ const channels = [snapshot(foo)];
313
+ const conn = makeConn();
314
+ const ctx = makeCtx(conn);
315
+
316
+ const out = listReducer(channels, { command: 'LIST', params: ['#FOO'], tags: {} }, ctx);
317
+
318
+ const list = out.effects[1];
319
+ if (list?.tag === 'Send') {
320
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #Foo 1 :');
321
+ } else {
322
+ throw new Error('expected Send effect for RPL_LIST');
323
+ }
324
+ });
325
+ });
326
+
327
+ // ============================================================================
328
+ // listReducer — result cap (thundering-herd mitigation)
329
+ // ============================================================================
330
+
331
+ describe('listReducer — result cap', () => {
332
+ it('caps the number of 322 entries at serverConfig.maxListEntries (no args)', () => {
333
+ const chans: ChanSnapshot[] = [];
334
+ for (let i = 0; i < 10; i++) {
335
+ const c = makeChan(`#c${i}`);
336
+ addMember(c, `cx${i}`, `user${i}`);
337
+ chans.push(snapshot(c));
338
+ }
339
+ const conn = makeConn();
340
+ const ctx = makeCtx(conn, { maxListEntries: 3 });
341
+
342
+ const out = listReducer(chans, { command: 'LIST', params: [], tags: {} }, ctx);
343
+
344
+ const list = out.effects[1];
345
+ if (list?.tag === 'Send') {
346
+ expect(list.lines).toHaveLength(3);
347
+ // Iteration order preserved: first three channels.
348
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #c0 1 :');
349
+ expect(list.lines[2]?.text).toBe(':irc.example.com 322 alice #c2 1 :');
350
+ } else {
351
+ throw new Error('expected Send effect for RPL_LIST');
352
+ }
353
+ });
354
+
355
+ it('caps the number of 322 entries when the explicit list exceeds the cap', () => {
356
+ const chans: ChanSnapshot[] = [];
357
+ for (let i = 0; i < 5; i++) {
358
+ const c = makeChan(`#c${i}`);
359
+ addMember(c, `cx${i}`, `user${i}`);
360
+ chans.push(snapshot(c));
361
+ }
362
+ const conn = makeConn();
363
+ const ctx = makeCtx(conn, { maxListEntries: 2 });
364
+
365
+ const out = listReducer(
366
+ chans,
367
+ { command: 'LIST', params: ['#c0,#c1,#c2,#c3,#c4'], tags: {} },
368
+ ctx,
369
+ );
370
+
371
+ const list = out.effects[1];
372
+ if (list?.tag === 'Send') {
373
+ expect(list.lines).toHaveLength(2);
374
+ expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #c0 1 :');
375
+ expect(list.lines[1]?.text).toBe(':irc.example.com 322 alice #c1 1 :');
376
+ } else {
377
+ throw new Error('expected Send effect for RPL_LIST');
378
+ }
379
+ });
380
+ });
381
+
382
+ // ============================================================================
383
+ // listReducer — bookkeeping
384
+ // ============================================================================
385
+
386
+ describe('listReducer — bookkeeping', () => {
387
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
388
+ const conn = makeConn();
389
+ const clock = new FakeClock(7_700);
390
+ const ctx = makeCtx(conn, {}, clock);
391
+
392
+ listReducer([], { command: 'LIST', params: [], tags: {} }, ctx);
393
+
394
+ expect(conn.lastSeen).toBe(7_700);
395
+ });
396
+
397
+ it('returns the same state reference (read-only reducer)', () => {
398
+ const channels: ChanSnapshot[] = [];
399
+ const conn = makeConn();
400
+ const ctx = makeCtx(conn);
401
+
402
+ const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);
403
+
404
+ expect(out.state).toBe(channels);
405
+ });
406
+
407
+ it('uses * in numeric replies when the connection has no nick (defensive)', () => {
408
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
409
+ const ctx = makeCtx(conn);
410
+
411
+ const out = listReducer([], { command: 'LIST', params: [], tags: {} }, ctx);
412
+
413
+ expect(out.effects).toEqual<EffectType[]>([
414
+ Effect.send('c1', [L(':irc.example.com 321 * Channel :Users Name')]),
415
+ Effect.send('c1', [L(':irc.example.com 323 * :End of /LIST')]),
416
+ ]);
417
+ });
418
+
419
+ it('treats an empty trailing param as no args', () => {
420
+ const foo = makeChan('#foo');
421
+ addMember(foo, 'c2', 'bob');
422
+ const channels = [snapshot(foo)];
423
+ const conn = makeConn();
424
+ const ctx = makeCtx(conn);
425
+
426
+ const out = listReducer(channels, { command: 'LIST', params: [''], tags: {} }, ctx);
427
+
428
+ const list = out.effects[1];
429
+ if (list?.tag === 'Send') {
430
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
431
+ } else {
432
+ throw new Error('expected Send effect for RPL_LIST');
433
+ }
434
+ });
435
+
436
+ it('treats a whitespace-only trailing param as no args', () => {
437
+ const foo = makeChan('#foo');
438
+ addMember(foo, 'c2', 'bob');
439
+ const channels = [snapshot(foo)];
440
+ const conn = makeConn();
441
+ const ctx = makeCtx(conn);
442
+
443
+ const out = listReducer(channels, { command: 'LIST', params: [' '], tags: {} }, ctx);
444
+
445
+ const list = out.effects[1];
446
+ if (list?.tag === 'Send') {
447
+ expect(list.lines).toEqual<RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);
448
+ } else {
449
+ throw new Error('expected Send effect for RPL_LIST');
450
+ }
451
+ });
452
+ });