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,1048 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { channelModeReducer, userModeReducer } from '../../src/commands/mode';
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
+ function addMember(
47
+ chan: ChannelState,
48
+ connId: string,
49
+ nick: string,
50
+ op = false,
51
+ voice = false,
52
+ ): void {
53
+ chan.members.set(connId, { conn: connId, nick, op, voice });
54
+ }
55
+
56
+ const L = (text: string): RawLine => ({ text });
57
+
58
+ // ============================================================================
59
+ // channelModeReducer — boolean modes
60
+ // ============================================================================
61
+
62
+ describe('channelModeReducer — boolean modes', () => {
63
+ it('sets +t when caller is op and broadcasts MODE +t to the channel', () => {
64
+ const chan = makeChan('#foo');
65
+ addMember(chan, 'c1', 'alice', true);
66
+ const conn = makeConn();
67
+ const ctx = makeCtx(conn);
68
+
69
+ const out = channelModeReducer(
70
+ chan,
71
+ { command: 'MODE', params: ['#foo', '+t'], tags: {} },
72
+ ctx,
73
+ );
74
+
75
+ expect(chan.modes.topicLock).toBe(true);
76
+ expect(out.effects).toEqual<EffectType[]>([
77
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),
78
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),
79
+ ]);
80
+ });
81
+
82
+ it('clears -t when caller is op', () => {
83
+ const chan = makeChan('#foo');
84
+ chan.modes.topicLock = true;
85
+ addMember(chan, 'c1', 'alice', true);
86
+ const conn = makeConn();
87
+ const ctx = makeCtx(conn);
88
+
89
+ const out = channelModeReducer(
90
+ chan,
91
+ { command: 'MODE', params: ['#foo', '-t'], tags: {} },
92
+ ctx,
93
+ );
94
+
95
+ expect(chan.modes.topicLock).toBe(false);
96
+ expect(out.effects).toContainEqual<EffectType>(
97
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -t')]),
98
+ );
99
+ });
100
+
101
+ const booleanModes: Array<[string, keyof ChannelState['modes']]> = [
102
+ ['i', 'inviteOnly'],
103
+ ['t', 'topicLock'],
104
+ ['n', 'noExternal'],
105
+ ['m', 'moderated'],
106
+ ['s', 'secret'],
107
+ ['p', 'private'],
108
+ ];
109
+ for (const [letter, field] of booleanModes) {
110
+ it(`sets +${letter} (${field}) and clears -${letter}`, () => {
111
+ const chan = makeChan('#foo');
112
+ addMember(chan, 'c1', 'alice', true);
113
+ const conn = makeConn();
114
+ const ctx = makeCtx(conn);
115
+
116
+ channelModeReducer(chan, { command: 'MODE', params: ['#foo', `+${letter}`], tags: {} }, ctx);
117
+ expect(chan.modes[field]).toBe(true);
118
+
119
+ channelModeReducer(chan, { command: 'MODE', params: ['#foo', `-${letter}`], tags: {} }, ctx);
120
+ expect(chan.modes[field]).toBe(false);
121
+ });
122
+ }
123
+
124
+ it('combines multiple boolean mode changes into one broadcast', () => {
125
+ const chan = makeChan('#foo');
126
+ addMember(chan, 'c1', 'alice', true);
127
+ const conn = makeConn();
128
+ const ctx = makeCtx(conn);
129
+
130
+ const out = channelModeReducer(
131
+ chan,
132
+ { command: 'MODE', params: ['#foo', '+tn'], tags: {} },
133
+ ctx,
134
+ );
135
+
136
+ expect(chan.modes.topicLock).toBe(true);
137
+ expect(chan.modes.noExternal).toBe(true);
138
+ expect(out.effects).toEqual<EffectType[]>([
139
+ Effect.applyChannelDelta('#foo', {
140
+ modeChanges: [
141
+ { mode: 'topicLock', set: true },
142
+ { mode: 'noExternal', set: true },
143
+ ],
144
+ }),
145
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +tn')]),
146
+ ]);
147
+ });
148
+
149
+ it('toggles mixed sign changes preserving sign boundaries', () => {
150
+ const chan = makeChan('#foo');
151
+ chan.modes.topicLock = true;
152
+ addMember(chan, 'c1', 'alice', true);
153
+ const conn = makeConn();
154
+ const ctx = makeCtx(conn);
155
+
156
+ const out = channelModeReducer(
157
+ chan,
158
+ { command: 'MODE', params: ['#foo', '-t+n'], tags: {} },
159
+ ctx,
160
+ );
161
+
162
+ expect(chan.modes.topicLock).toBe(false);
163
+ expect(chan.modes.noExternal).toBe(true);
164
+ expect(out.effects).toEqual<EffectType[]>([
165
+ Effect.applyChannelDelta('#foo', {
166
+ modeChanges: [
167
+ { mode: 'topicLock', set: false },
168
+ { mode: 'noExternal', set: true },
169
+ ],
170
+ }),
171
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -t+n')]),
172
+ ]);
173
+ });
174
+ });
175
+
176
+ // ============================================================================
177
+ // channelModeReducer — prefix modes (o, v)
178
+ // ============================================================================
179
+
180
+ describe('channelModeReducer — prefix modes', () => {
181
+ it('+o <nick> grants op to the named member and broadcasts MODE +o nick', () => {
182
+ const chan = makeChan('#foo');
183
+ addMember(chan, 'c1', 'alice', true);
184
+ addMember(chan, 'c2', 'bob');
185
+ const conn = makeConn();
186
+ const ctx = makeCtx(conn);
187
+
188
+ const out = channelModeReducer(
189
+ chan,
190
+ { command: 'MODE', params: ['#foo', '+o', 'bob'], tags: {} },
191
+ ctx,
192
+ );
193
+
194
+ expect(chan.members.get('c2')?.op).toBe(true);
195
+ expect(out.effects).toEqual<EffectType[]>([
196
+ Effect.applyChannelDelta('#foo', {
197
+ memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: true, voice: false }],
198
+ }),
199
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +o bob')]),
200
+ ]);
201
+ });
202
+
203
+ it('-o <nick> removes op from the named member', () => {
204
+ const chan = makeChan('#foo');
205
+ addMember(chan, 'c1', 'alice', true);
206
+ addMember(chan, 'c2', 'bob', true);
207
+ const conn = makeConn();
208
+ const ctx = makeCtx(conn);
209
+
210
+ const out = channelModeReducer(
211
+ chan,
212
+ { command: 'MODE', params: ['#foo', '-o', 'bob'], tags: {} },
213
+ ctx,
214
+ );
215
+
216
+ expect(chan.members.get('c2')?.op).toBe(false);
217
+ expect(out.effects).toEqual<EffectType[]>([
218
+ Effect.applyChannelDelta('#foo', {
219
+ memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: false }],
220
+ }),
221
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -o bob')]),
222
+ ]);
223
+ });
224
+
225
+ it('+v <nick> grants voice', () => {
226
+ const chan = makeChan('#foo');
227
+ addMember(chan, 'c1', 'alice', true);
228
+ addMember(chan, 'c2', 'bob');
229
+ const conn = makeConn();
230
+ const ctx = makeCtx(conn);
231
+
232
+ const out = channelModeReducer(
233
+ chan,
234
+ { command: 'MODE', params: ['#foo', '+v', 'bob'], tags: {} },
235
+ ctx,
236
+ );
237
+
238
+ expect(chan.members.get('c2')?.voice).toBe(true);
239
+ expect(out.effects).toEqual<EffectType[]>([
240
+ Effect.applyChannelDelta('#foo', {
241
+ memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: true }],
242
+ }),
243
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +v bob')]),
244
+ ]);
245
+ });
246
+
247
+ it('-v <nick> removes voice', () => {
248
+ const chan = makeChan('#foo');
249
+ addMember(chan, 'c1', 'alice', true);
250
+ addMember(chan, 'c2', 'bob', false, true);
251
+ const conn = makeConn();
252
+ const ctx = makeCtx(conn);
253
+
254
+ const out = channelModeReducer(
255
+ chan,
256
+ { command: 'MODE', params: ['#foo', '-v', 'bob'], tags: {} },
257
+ ctx,
258
+ );
259
+
260
+ expect(chan.members.get('c2')?.voice).toBe(false);
261
+ expect(out.effects).toEqual<EffectType[]>([
262
+ Effect.applyChannelDelta('#foo', {
263
+ memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: false }],
264
+ }),
265
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -v bob')]),
266
+ ]);
267
+ });
268
+
269
+ it('matches nicks case-insensitively for +o', () => {
270
+ const chan = makeChan('#foo');
271
+ addMember(chan, 'c1', 'alice', true);
272
+ addMember(chan, 'c2', 'Bob');
273
+ const conn = makeConn();
274
+ const ctx = makeCtx(conn);
275
+
276
+ channelModeReducer(chan, { command: 'MODE', params: ['#foo', '+o', 'BOB'], tags: {} }, ctx);
277
+
278
+ expect(chan.members.get('c2')?.op).toBe(true);
279
+ });
280
+
281
+ it('emits 441 ERR_USERNOTINCHANNEL when +o target is not on the channel', () => {
282
+ const chan = makeChan('#foo');
283
+ addMember(chan, 'c1', 'alice', true);
284
+ const conn = makeConn();
285
+ const ctx = makeCtx(conn);
286
+
287
+ const out = channelModeReducer(
288
+ chan,
289
+ { command: 'MODE', params: ['#foo', '+o', 'carol'], tags: {} },
290
+ ctx,
291
+ );
292
+
293
+ expect(out.effects).toEqual<EffectType[]>([
294
+ Effect.send('c1', [L(':irc.example.com 441 alice carol #foo :They are not on that channel')]),
295
+ ]);
296
+ expect(chan.members.size).toBe(1);
297
+ });
298
+
299
+ it('emits 461 ERR_NEEDMOREPARAMS when +o has no nick argument', () => {
300
+ const chan = makeChan('#foo');
301
+ addMember(chan, 'c1', 'alice', true);
302
+ const conn = makeConn();
303
+ const ctx = makeCtx(conn);
304
+
305
+ const out = channelModeReducer(
306
+ chan,
307
+ { command: 'MODE', params: ['#foo', '+o'], tags: {} },
308
+ ctx,
309
+ );
310
+
311
+ expect(out.effects).toEqual<EffectType[]>([
312
+ Effect.send('c1', [L(':irc.example.com 461 alice o :Not enough parameters')]),
313
+ ]);
314
+ });
315
+ });
316
+
317
+ // ============================================================================
318
+ // channelModeReducer — key (k)
319
+ // ============================================================================
320
+
321
+ describe('channelModeReducer — key (k)', () => {
322
+ it('+k <key> sets the channel key and broadcasts MODE +k key', () => {
323
+ const chan = makeChan('#foo');
324
+ addMember(chan, 'c1', 'alice', true);
325
+ const conn = makeConn();
326
+ const ctx = makeCtx(conn);
327
+
328
+ const out = channelModeReducer(
329
+ chan,
330
+ { command: 'MODE', params: ['#foo', '+k', 'secret'], tags: {} },
331
+ ctx,
332
+ );
333
+
334
+ expect(chan.modes.key).toBe('secret');
335
+ expect(out.effects).toEqual<EffectType[]>([
336
+ Effect.applyChannelDelta('#foo', {
337
+ modeChanges: [{ mode: 'key', set: true, arg: 'secret' }],
338
+ }),
339
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +k secret')]),
340
+ ]);
341
+ });
342
+
343
+ it('-k clears the channel key without requiring an argument', () => {
344
+ const chan = makeChan('#foo');
345
+ chan.modes.key = 'secret';
346
+ addMember(chan, 'c1', 'alice', true);
347
+ const conn = makeConn();
348
+ const ctx = makeCtx(conn);
349
+
350
+ const out = channelModeReducer(
351
+ chan,
352
+ { command: 'MODE', params: ['#foo', '-k'], tags: {} },
353
+ ctx,
354
+ );
355
+
356
+ expect(chan.modes.key).toBeUndefined();
357
+ expect(out.effects).toEqual<EffectType[]>([
358
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'key', set: false }] }),
359
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -k')]),
360
+ ]);
361
+ });
362
+
363
+ it('emits 461 ERR_NEEDMOREPARAMS when +k has no argument', () => {
364
+ const chan = makeChan('#foo');
365
+ addMember(chan, 'c1', 'alice', true);
366
+ const conn = makeConn();
367
+ const ctx = makeCtx(conn);
368
+
369
+ const out = channelModeReducer(
370
+ chan,
371
+ { command: 'MODE', params: ['#foo', '+k'], tags: {} },
372
+ ctx,
373
+ );
374
+
375
+ expect(out.effects).toEqual<EffectType[]>([
376
+ Effect.send('c1', [L(':irc.example.com 461 alice k :Not enough parameters')]),
377
+ ]);
378
+ expect(chan.modes.key).toBeUndefined();
379
+ });
380
+ });
381
+
382
+ // ============================================================================
383
+ // channelModeReducer — limit (l)
384
+ // ============================================================================
385
+
386
+ describe('channelModeReducer — limit (l)', () => {
387
+ it('+l <N> sets the channel limit and broadcasts MODE +l N', () => {
388
+ const chan = makeChan('#foo');
389
+ addMember(chan, 'c1', 'alice', true);
390
+ const conn = makeConn();
391
+ const ctx = makeCtx(conn);
392
+
393
+ const out = channelModeReducer(
394
+ chan,
395
+ { command: 'MODE', params: ['#foo', '+l', '30'], tags: {} },
396
+ ctx,
397
+ );
398
+
399
+ expect(chan.modes.limit).toBe(30);
400
+ expect(out.effects).toEqual<EffectType[]>([
401
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'limit', set: true, arg: 30 }] }),
402
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +l 30')]),
403
+ ]);
404
+ });
405
+
406
+ it('-l clears the channel limit without requiring an argument', () => {
407
+ const chan = makeChan('#foo');
408
+ chan.modes.limit = 30;
409
+ addMember(chan, 'c1', 'alice', true);
410
+ const conn = makeConn();
411
+ const ctx = makeCtx(conn);
412
+
413
+ const out = channelModeReducer(
414
+ chan,
415
+ { command: 'MODE', params: ['#foo', '-l'], tags: {} },
416
+ ctx,
417
+ );
418
+
419
+ expect(chan.modes.limit).toBeUndefined();
420
+ expect(out.effects).toEqual<EffectType[]>([
421
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'limit', set: false }] }),
422
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -l')]),
423
+ ]);
424
+ });
425
+
426
+ it('emits 461 ERR_NEEDMOREPARAMS when +l has no argument', () => {
427
+ const chan = makeChan('#foo');
428
+ addMember(chan, 'c1', 'alice', true);
429
+ const conn = makeConn();
430
+ const ctx = makeCtx(conn);
431
+
432
+ const out = channelModeReducer(
433
+ chan,
434
+ { command: 'MODE', params: ['#foo', '+l'], tags: {} },
435
+ ctx,
436
+ );
437
+
438
+ expect(out.effects).toEqual<EffectType[]>([
439
+ Effect.send('c1', [L(':irc.example.com 461 alice l :Not enough parameters')]),
440
+ ]);
441
+ });
442
+
443
+ it('emits 461 ERR_NEEDMOREPARAMS when +l argument is non-numeric', () => {
444
+ const chan = makeChan('#foo');
445
+ addMember(chan, 'c1', 'alice', true);
446
+ const conn = makeConn();
447
+ const ctx = makeCtx(conn);
448
+
449
+ const out = channelModeReducer(
450
+ chan,
451
+ { command: 'MODE', params: ['#foo', '+l', 'big'], tags: {} },
452
+ ctx,
453
+ );
454
+
455
+ expect(out.effects).toEqual<EffectType[]>([
456
+ Effect.send('c1', [L(':irc.example.com 461 alice l :Not enough parameters')]),
457
+ ]);
458
+ expect(chan.modes.limit).toBeUndefined();
459
+ });
460
+ });
461
+
462
+ // ============================================================================
463
+ // channelModeReducer — ban masks (b)
464
+ // ============================================================================
465
+
466
+ describe('channelModeReducer — ban masks', () => {
467
+ it('+b <mask> adds to the ban list and broadcasts MODE +b mask', () => {
468
+ const chan = makeChan('#foo');
469
+ addMember(chan, 'c1', 'alice', true);
470
+ const conn = makeConn();
471
+ const ctx = makeCtx(conn);
472
+
473
+ const out = channelModeReducer(
474
+ chan,
475
+ { command: 'MODE', params: ['#foo', '+b', 'bob!*@*'], tags: {} },
476
+ ctx,
477
+ );
478
+
479
+ expect([...chan.banMasks]).toEqual(['bob!*@*']);
480
+ expect(out.effects).toEqual<EffectType[]>([
481
+ Effect.applyChannelDelta('#foo', { banMaskChanges: [{ type: 'add', mask: 'bob!*@*' }] }),
482
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +b bob!*@*')]),
483
+ ]);
484
+ });
485
+
486
+ it('-b <mask> removes from the ban list', () => {
487
+ const chan = makeChan('#foo');
488
+ chan.banMasks.add('bob!*@*');
489
+ addMember(chan, 'c1', 'alice', true);
490
+ const conn = makeConn();
491
+ const ctx = makeCtx(conn);
492
+
493
+ const out = channelModeReducer(
494
+ chan,
495
+ { command: 'MODE', params: ['#foo', '-b', 'bob!*@*'], tags: {} },
496
+ ctx,
497
+ );
498
+
499
+ expect([...chan.banMasks]).toEqual([]);
500
+ expect(out.effects).toEqual<EffectType[]>([
501
+ Effect.applyChannelDelta('#foo', { banMaskChanges: [{ type: 'remove', mask: 'bob!*@*' }] }),
502
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -b bob!*@*')]),
503
+ ]);
504
+ });
505
+
506
+ it('emits 367 RPL_BANLIST for each ban mask then 368 ENDOFBANLIST when querying with +b alone', () => {
507
+ const chan = makeChan('#foo');
508
+ chan.banMasks.add('bob!*@*');
509
+ chan.banMasks.add('carol!*@*');
510
+ addMember(chan, 'c1', 'alice', true);
511
+ const conn = makeConn();
512
+ const ctx = makeCtx(conn);
513
+
514
+ const out = channelModeReducer(
515
+ chan,
516
+ { command: 'MODE', params: ['#foo', '+b'], tags: {} },
517
+ ctx,
518
+ );
519
+
520
+ expect(out.effects).toEqual<EffectType[]>([
521
+ Effect.send('c1', [L(':irc.example.com 367 alice #foo bob!*@*')]),
522
+ Effect.send('c1', [L(':irc.example.com 367 alice #foo carol!*@*')]),
523
+ Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),
524
+ ]);
525
+ });
526
+
527
+ it('uses * in 367/368 replies when the connection has no nick (defensive)', () => {
528
+ const chan = makeChan('#foo');
529
+ chan.banMasks.add('bob!*@*');
530
+ addMember(chan, 'c1', 'alice', true);
531
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
532
+ const ctx = makeCtx(conn);
533
+
534
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo', 'b'], tags: {} }, ctx);
535
+
536
+ expect(out.effects).toEqual<EffectType[]>([
537
+ Effect.send('c1', [L(':irc.example.com 367 * #foo bob!*@*')]),
538
+ Effect.send('c1', [L(':irc.example.com 368 * #foo :End of Channel Ban List')]),
539
+ ]);
540
+ });
541
+
542
+ it('emits only 368 ENDOFBANLIST when there are no bans', () => {
543
+ const chan = makeChan('#foo');
544
+ addMember(chan, 'c1', 'alice', true);
545
+ const conn = makeConn();
546
+ const ctx = makeCtx(conn);
547
+
548
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo', 'b'], tags: {} }, ctx);
549
+
550
+ expect(out.effects).toEqual<EffectType[]>([
551
+ Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),
552
+ ]);
553
+ });
554
+
555
+ it('queries the ban list inline when +b appears in a combined modestring without an arg', () => {
556
+ const chan = makeChan('#foo');
557
+ chan.banMasks.add('bob!*@*');
558
+ addMember(chan, 'c1', 'alice', true);
559
+ const conn = makeConn();
560
+ const ctx = makeCtx(conn);
561
+
562
+ const out = channelModeReducer(
563
+ chan,
564
+ { command: 'MODE', params: ['#foo', '+tb'], tags: {} },
565
+ ctx,
566
+ );
567
+
568
+ expect(chan.modes.topicLock).toBe(true);
569
+ expect(out.effects).toEqual<EffectType[]>([
570
+ Effect.send('c1', [L(':irc.example.com 367 alice #foo bob!*@*')]),
571
+ Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),
572
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),
573
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),
574
+ ]);
575
+ });
576
+
577
+ it('emits 461 ERR_NEEDMOREPARAMS when +b has no argument and the list is queried empty (still emits 368)', () => {
578
+ // Per modern ircd behavior, +b alone queries the list. So no 461 here.
579
+ const chan = makeChan('#foo');
580
+ addMember(chan, 'c1', 'alice', true);
581
+ const conn = makeConn();
582
+ const ctx = makeCtx(conn);
583
+
584
+ const out = channelModeReducer(
585
+ chan,
586
+ { command: 'MODE', params: ['#foo', '+b'], tags: {} },
587
+ ctx,
588
+ );
589
+
590
+ expect(out.effects).toEqual<EffectType[]>([
591
+ Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),
592
+ ]);
593
+ });
594
+ });
595
+
596
+ // ============================================================================
597
+ // channelModeReducer — read form
598
+ // ============================================================================
599
+
600
+ describe('channelModeReducer — read', () => {
601
+ it('emits 324 RPL_CHANNELMODEIS with the current boolean modes', () => {
602
+ const chan = makeChan('#foo');
603
+ chan.modes.topicLock = true;
604
+ chan.modes.noExternal = true;
605
+ addMember(chan, 'c1', 'alice');
606
+ const conn = makeConn();
607
+ const ctx = makeCtx(conn);
608
+
609
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
610
+
611
+ expect(out.effects).toEqual<EffectType[]>([
612
+ Effect.send('c1', [L(':irc.example.com 324 alice #foo +tn')]),
613
+ ]);
614
+ });
615
+
616
+ it('includes k and l parameters in 324 when set', () => {
617
+ const chan = makeChan('#foo');
618
+ chan.modes.topicLock = true;
619
+ chan.modes.key = 'secret';
620
+ chan.modes.limit = 50;
621
+ addMember(chan, 'c1', 'alice');
622
+ const conn = makeConn();
623
+ const ctx = makeCtx(conn);
624
+
625
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
626
+
627
+ expect(out.effects).toEqual<EffectType[]>([
628
+ Effect.send('c1', [L(':irc.example.com 324 alice #foo +tkl secret 50')]),
629
+ ]);
630
+ });
631
+
632
+ it('emits 324 with bare + when no modes are set', () => {
633
+ const chan = makeChan('#foo');
634
+ addMember(chan, 'c1', 'alice');
635
+ const conn = makeConn();
636
+ const ctx = makeCtx(conn);
637
+
638
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
639
+
640
+ expect(out.effects).toEqual<EffectType[]>([
641
+ Effect.send('c1', [L(':irc.example.com 324 alice #foo +')]),
642
+ ]);
643
+ });
644
+
645
+ it('uses * in 324 reply when the connection has no nick (defensive)', () => {
646
+ const chan = makeChan('#foo');
647
+ chan.modes.topicLock = true;
648
+ addMember(chan, 'c1', 'alice');
649
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
650
+ const ctx = makeCtx(conn);
651
+
652
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
653
+
654
+ expect(out.effects).toEqual<EffectType[]>([
655
+ Effect.send('c1', [L(':irc.example.com 324 * #foo +t')]),
656
+ ]);
657
+ });
658
+
659
+ it('allows non-member to read modes of a public channel', () => {
660
+ const chan = makeChan('#foo');
661
+ chan.modes.topicLock = true;
662
+ addMember(chan, 'c2', 'bob', true);
663
+ const conn = makeConn();
664
+ const ctx = makeCtx(conn);
665
+
666
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
667
+
668
+ expect(out.effects).toEqual<EffectType[]>([
669
+ Effect.send('c1', [L(':irc.example.com 324 alice #foo +t')]),
670
+ ]);
671
+ });
672
+
673
+ it('emits 442 ERR_NOTONCHANNEL when a non-member queries a +s channel', () => {
674
+ const chan = makeChan('#foo');
675
+ chan.modes.secret = true;
676
+ addMember(chan, 'c2', 'bob', true);
677
+ const conn = makeConn();
678
+ const ctx = makeCtx(conn);
679
+
680
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);
681
+
682
+ expect(out.effects).toEqual<EffectType[]>([
683
+ Effect.send('c1', [L(":irc.example.com 442 alice #foo :You're not on that channel")]),
684
+ ]);
685
+ });
686
+
687
+ it('emits 442 ERR_NOTONCHANNEL when a non-member attempts to change modes on a +s channel', () => {
688
+ const chan = makeChan('#foo');
689
+ chan.modes.secret = true;
690
+ addMember(chan, 'c2', 'bob', true);
691
+ const conn = makeConn();
692
+ const ctx = makeCtx(conn);
693
+
694
+ const out = channelModeReducer(
695
+ chan,
696
+ { command: 'MODE', params: ['#foo', '+t'], tags: {} },
697
+ ctx,
698
+ );
699
+
700
+ expect(out.effects).toEqual<EffectType[]>([
701
+ Effect.send('c1', [L(":irc.example.com 442 alice #foo :You're not on that channel")]),
702
+ ]);
703
+ expect(chan.modes.topicLock).toBe(false);
704
+ });
705
+ });
706
+
707
+ // ============================================================================
708
+ // channelModeReducer — authorization & errors
709
+ // ============================================================================
710
+
711
+ describe('channelModeReducer — authorization & errors', () => {
712
+ it('emits 482 ERR_CHANOPRIVSNEEDED when a non-op attempts a mode change', () => {
713
+ const chan = makeChan('#foo');
714
+ addMember(chan, 'c1', 'alice', false);
715
+ const conn = makeConn();
716
+ const ctx = makeCtx(conn);
717
+
718
+ const out = channelModeReducer(
719
+ chan,
720
+ { command: 'MODE', params: ['#foo', '+t'], tags: {} },
721
+ ctx,
722
+ );
723
+
724
+ expect(out.effects).toEqual<EffectType[]>([
725
+ Effect.send('c1', [L(":irc.example.com 482 alice #foo :You're not channel operator")]),
726
+ ]);
727
+ expect(chan.modes.topicLock).toBe(false);
728
+ });
729
+
730
+ it('emits 482 when a non-op attempts +o on someone', () => {
731
+ const chan = makeChan('#foo');
732
+ addMember(chan, 'c1', 'alice', false);
733
+ addMember(chan, 'c2', 'bob', false);
734
+ const conn = makeConn();
735
+ const ctx = makeCtx(conn);
736
+
737
+ const out = channelModeReducer(
738
+ chan,
739
+ { command: 'MODE', params: ['#foo', '+o', 'bob'], tags: {} },
740
+ ctx,
741
+ );
742
+
743
+ expect(out.effects).toEqual<EffectType[]>([
744
+ Effect.send('c1', [L(":irc.example.com 482 alice #foo :You're not channel operator")]),
745
+ ]);
746
+ expect(chan.members.get('c2')?.op).toBe(false);
747
+ });
748
+
749
+ it('emits 442 ERR_NOTONCHANNEL when a non-member attempts a mode change', () => {
750
+ const chan = makeChan('#foo');
751
+ addMember(chan, 'c2', 'bob', true);
752
+ const conn = makeConn();
753
+ const ctx = makeCtx(conn);
754
+
755
+ const out = channelModeReducer(
756
+ chan,
757
+ { command: 'MODE', params: ['#foo', '+t'], tags: {} },
758
+ ctx,
759
+ );
760
+
761
+ expect(out.effects).toEqual<EffectType[]>([
762
+ Effect.send('c1', [L(":irc.example.com 442 alice #foo :You're not on that channel")]),
763
+ ]);
764
+ });
765
+
766
+ it('emits 472 ERR_UNKNOWNMODE for an unknown mode char and continues processing the rest', () => {
767
+ const chan = makeChan('#foo');
768
+ addMember(chan, 'c1', 'alice', true);
769
+ const conn = makeConn();
770
+ const ctx = makeCtx(conn);
771
+
772
+ const out = channelModeReducer(
773
+ chan,
774
+ { command: 'MODE', params: ['#foo', '+xz'], tags: {} },
775
+ ctx,
776
+ );
777
+
778
+ expect(out.effects).toEqual<EffectType[]>([
779
+ Effect.send('c1', [L(':irc.example.com 472 alice x :is unknown mode char to me')]),
780
+ Effect.send('c1', [L(':irc.example.com 472 alice z :is unknown mode char to me')]),
781
+ ]);
782
+ expect(chan.modes.topicLock).toBe(false);
783
+ });
784
+
785
+ it('applies valid mode chars and reports unknown ones in the same modestring', () => {
786
+ const chan = makeChan('#foo');
787
+ addMember(chan, 'c1', 'alice', true);
788
+ const conn = makeConn();
789
+ const ctx = makeCtx(conn);
790
+
791
+ const out = channelModeReducer(
792
+ chan,
793
+ { command: 'MODE', params: ['#foo', '+tz'], tags: {} },
794
+ ctx,
795
+ );
796
+
797
+ expect(chan.modes.topicLock).toBe(true);
798
+ expect(out.effects).toEqual<EffectType[]>([
799
+ Effect.send('c1', [L(':irc.example.com 472 alice z :is unknown mode char to me')]),
800
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),
801
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),
802
+ ]);
803
+ });
804
+
805
+ it('emits 461 ERR_NEEDMOREPARAMS when no channel argument is supplied', () => {
806
+ const chan = makeChan('#foo');
807
+ addMember(chan, 'c1', 'alice', true);
808
+ const conn = makeConn();
809
+ const ctx = makeCtx(conn);
810
+
811
+ const out = channelModeReducer(chan, { command: 'MODE', params: [], tags: {} }, ctx);
812
+
813
+ expect(out.effects).toEqual<EffectType[]>([
814
+ Effect.send('c1', [L(':irc.example.com 461 alice MODE :Not enough parameters')]),
815
+ ]);
816
+ });
817
+
818
+ it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {
819
+ const chan = makeChan('foo');
820
+ addMember(chan, 'c1', 'alice', true);
821
+ const conn = makeConn();
822
+ const ctx = makeCtx(conn);
823
+
824
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['foo', '+t'], tags: {} }, ctx);
825
+
826
+ expect(out.effects).toEqual<EffectType[]>([
827
+ Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
828
+ ]);
829
+ });
830
+
831
+ it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
832
+ const chan = makeChan('#foo');
833
+ addMember(chan, 'c1', 'alice', true);
834
+ const conn = makeConn();
835
+ const ctx = makeCtx(conn);
836
+
837
+ const out = channelModeReducer(chan, { command: 'MODE', params: ['', '+t'], tags: {} }, ctx);
838
+
839
+ expect(out.effects).toEqual<EffectType[]>([
840
+ Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),
841
+ ]);
842
+ });
843
+
844
+ it('uses * in error replies when the connection has no nick (defensive)', () => {
845
+ const chan = makeChan('#foo');
846
+ addMember(chan, 'c1', 'alice', true);
847
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
848
+ const ctx = makeCtx(conn);
849
+
850
+ const out = channelModeReducer(chan, { command: 'MODE', params: [], tags: {} }, ctx);
851
+
852
+ expect(out.effects).toEqual<EffectType[]>([
853
+ Effect.send('c1', [L(':irc.example.com 461 * MODE :Not enough parameters')]),
854
+ ]);
855
+ });
856
+
857
+ it('broadcasts MODE with the bare hostmask (? fallback) when nick is undefined', () => {
858
+ const chan = makeChan('#foo');
859
+ addMember(chan, 'c1', 'alice', true);
860
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
861
+ const ctx = makeCtx(conn);
862
+
863
+ const out = channelModeReducer(
864
+ chan,
865
+ { command: 'MODE', params: ['#foo', '+t'], tags: {} },
866
+ ctx,
867
+ );
868
+
869
+ expect(out.effects).toEqual<EffectType[]>([
870
+ Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),
871
+ Effect.broadcast('#foo', [L(':? MODE #foo +t')]),
872
+ ]);
873
+ });
874
+
875
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
876
+ const chan = makeChan('#foo');
877
+ addMember(chan, 'c1', 'alice', true);
878
+ const conn = makeConn();
879
+ const clock = new FakeClock(9_000);
880
+ const ctx = makeCtx(conn, clock);
881
+
882
+ channelModeReducer(chan, { command: 'MODE', params: ['#foo', '+t'], tags: {} }, ctx);
883
+
884
+ expect(conn.lastSeen).toBe(9_000);
885
+ });
886
+ });
887
+
888
+ // ============================================================================
889
+ // userModeReducer
890
+ // ============================================================================
891
+
892
+ describe('userModeReducer — read', () => {
893
+ it('emits 221 RPL_UMODEIS with the current user modes', () => {
894
+ const conn = makeConn();
895
+ conn.userModes.invisible = true;
896
+ conn.userModes.wallops = true;
897
+ const ctx = makeCtx(conn);
898
+
899
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
900
+
901
+ expect(out.effects).toEqual<EffectType[]>([
902
+ Effect.send('c1', [L(':irc.example.com 221 alice +iw')]),
903
+ ]);
904
+ });
905
+
906
+ it('emits 221 with bare + when no user modes are set', () => {
907
+ const conn = makeConn();
908
+ const ctx = makeCtx(conn);
909
+
910
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
911
+
912
+ expect(out.effects).toEqual<EffectType[]>([
913
+ Effect.send('c1', [L(':irc.example.com 221 alice +')]),
914
+ ]);
915
+ });
916
+ });
917
+
918
+ describe('userModeReducer — write', () => {
919
+ it('sets +i (invisible) and broadcasts no echo (local-only state change)', () => {
920
+ const conn = makeConn();
921
+ const ctx = makeCtx(conn);
922
+
923
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+i'], tags: {} }, ctx);
924
+
925
+ expect(conn.userModes.invisible).toBe(true);
926
+ expect(out.effects).toEqual<EffectType[]>([
927
+ Effect.send('c1', [L(':alice!alice@example.com MODE alice +i')]),
928
+ ]);
929
+ });
930
+
931
+ it('clears -i', () => {
932
+ const conn = makeConn();
933
+ conn.userModes.invisible = true;
934
+ const ctx = makeCtx(conn);
935
+
936
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-i'], tags: {} }, ctx);
937
+
938
+ expect(conn.userModes.invisible).toBe(false);
939
+ expect(out.effects).toEqual<EffectType[]>([
940
+ Effect.send('c1', [L(':alice!alice@example.com MODE alice -i')]),
941
+ ]);
942
+ });
943
+
944
+ it('toggles +w / -w (wallops)', () => {
945
+ const conn = makeConn();
946
+ const ctx = makeCtx(conn);
947
+
948
+ userModeReducer(conn, { command: 'MODE', params: ['alice', '+w'], tags: {} }, ctx);
949
+ expect(conn.userModes.wallops).toBe(true);
950
+
951
+ userModeReducer(conn, { command: 'MODE', params: ['alice', '-w'], tags: {} }, ctx);
952
+ expect(conn.userModes.wallops).toBe(false);
953
+ });
954
+
955
+ it('toggles +s / -s (server notices)', () => {
956
+ const conn = makeConn();
957
+ const ctx = makeCtx(conn);
958
+
959
+ userModeReducer(conn, { command: 'MODE', params: ['alice', '+s'], tags: {} }, ctx);
960
+ expect(conn.userModes.serverNotices).toBe(true);
961
+
962
+ userModeReducer(conn, { command: 'MODE', params: ['alice', '-s'], tags: {} }, ctx);
963
+ expect(conn.userModes.serverNotices).toBe(false);
964
+ });
965
+
966
+ it('rejects +o via MODE with 481 ERR_NOPRIVILEGES (oper is granted via OPER only)', () => {
967
+ const conn = makeConn();
968
+ const ctx = makeCtx(conn);
969
+
970
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+o'], tags: {} }, ctx);
971
+
972
+ expect(conn.userModes.oper).toBe(false);
973
+ expect(out.effects).toEqual<EffectType[]>([
974
+ Effect.send('c1', [
975
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
976
+ ]),
977
+ ]);
978
+ });
979
+
980
+ it('allows an existing oper to drop +o via -o', () => {
981
+ const conn = makeConn();
982
+ conn.userModes.oper = true;
983
+ const ctx = makeCtx(conn);
984
+
985
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-o'], tags: {} }, ctx);
986
+
987
+ expect(conn.userModes.oper).toBe(false);
988
+ expect(out.effects).toEqual<EffectType[]>([
989
+ Effect.send('c1', [L(':alice!alice@example.com MODE alice -o')]),
990
+ ]);
991
+ });
992
+
993
+ it('combines multiple user mode changes into one echo', () => {
994
+ const conn = makeConn();
995
+ const ctx = makeCtx(conn);
996
+
997
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+iw'], tags: {} }, ctx);
998
+
999
+ expect(conn.userModes.invisible).toBe(true);
1000
+ expect(conn.userModes.wallops).toBe(true);
1001
+ expect(out.effects).toEqual<EffectType[]>([
1002
+ Effect.send('c1', [L(':alice!alice@example.com MODE alice +iw')]),
1003
+ ]);
1004
+ });
1005
+
1006
+ it('emits 501 ERR_UMODEUNKNOWNFLAG for an unknown user mode char', () => {
1007
+ const conn = makeConn();
1008
+ const ctx = makeCtx(conn);
1009
+
1010
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+z'], tags: {} }, ctx);
1011
+
1012
+ expect(out.effects).toEqual<EffectType[]>([
1013
+ Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),
1014
+ ]);
1015
+ });
1016
+
1017
+ it('emits 461 ERR_NEEDMOREPARAMS when no nick argument is supplied', () => {
1018
+ const conn = makeConn();
1019
+ const ctx = makeCtx(conn);
1020
+
1021
+ const out = userModeReducer(conn, { command: 'MODE', params: [], tags: {} }, ctx);
1022
+
1023
+ expect(out.effects).toEqual<EffectType[]>([
1024
+ Effect.send('c1', [L(':irc.example.com 461 alice MODE :Not enough parameters')]),
1025
+ ]);
1026
+ });
1027
+
1028
+ it('emits 502 ERR_USERSDONTMATCH when the target nick is not the connection', () => {
1029
+ const conn = makeConn();
1030
+ const ctx = makeCtx(conn);
1031
+
1032
+ const out = userModeReducer(conn, { command: 'MODE', params: ['bob', '+i'], tags: {} }, ctx);
1033
+
1034
+ expect(out.effects).toEqual<EffectType[]>([
1035
+ Effect.send('c1', [L(':irc.example.com 502 alice :Cannot change mode for other users')]),
1036
+ ]);
1037
+ });
1038
+
1039
+ it('updates lastSeen to ctx.clock.now()', () => {
1040
+ const conn = makeConn();
1041
+ const clock = new FakeClock(11_000);
1042
+ const ctx = makeCtx(conn, clock);
1043
+
1044
+ userModeReducer(conn, { command: 'MODE', params: ['alice', '+i'], tags: {} }, ctx);
1045
+
1046
+ expect(conn.lastSeen).toBe(11_000);
1047
+ });
1048
+ });