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,687 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleJoinZero, isValidChannelName, joinReducer } from '../../src/commands/join';
|
|
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
|
+
const L = (text: string): RawLine => ({ text });
|
|
47
|
+
|
|
48
|
+
/** Adds a non-op member to the channel for setups that need a pre-populated roster. */
|
|
49
|
+
function addMember(chan: ChannelState, connId: string, nick: string, op = false): void {
|
|
50
|
+
chan.members.set(connId, { conn: connId, nick, op, voice: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// isValidChannelName
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('isValidChannelName', () => {
|
|
58
|
+
it('accepts a # channel', () => {
|
|
59
|
+
expect(isValidChannelName('#foo', 50)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('accepts a & channel', () => {
|
|
63
|
+
expect(isValidChannelName('&foo', 50)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('accepts a single-char name after the prefix', () => {
|
|
67
|
+
expect(isValidChannelName('#a', 50)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects an empty string', () => {
|
|
71
|
+
expect(isValidChannelName('', 50)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects a name without a channel prefix', () => {
|
|
75
|
+
expect(isValidChannelName('foo', 50)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects a name starting with +', () => {
|
|
79
|
+
expect(isValidChannelName('+foo', 50)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects a name containing a space', () => {
|
|
83
|
+
expect(isValidChannelName('#foo bar', 50)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a name containing a comma', () => {
|
|
87
|
+
expect(isValidChannelName('#foo,bar', 50)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a name containing a colon', () => {
|
|
91
|
+
expect(isValidChannelName('#foo:bar', 50)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a name exceeding the length cap', () => {
|
|
95
|
+
expect(isValidChannelName(`#${'a'.repeat(50)}`, 50)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('accepts a name at exactly the length cap', () => {
|
|
99
|
+
expect(isValidChannelName(`#${'a'.repeat(49)}`, 50)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// joinReducer — success path
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
describe('joinReducer — success', () => {
|
|
108
|
+
it('broadcasts JOIN to the channel including the joiner and sends 353/366 to the joiner', () => {
|
|
109
|
+
const chan = makeChan('#foo');
|
|
110
|
+
const conn = makeConn();
|
|
111
|
+
const ctx = makeCtx(conn);
|
|
112
|
+
|
|
113
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
114
|
+
|
|
115
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
116
|
+
Effect.applyChannelDelta('#foo', {
|
|
117
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
|
|
118
|
+
}),
|
|
119
|
+
Effect.broadcast(
|
|
120
|
+
'#foo',
|
|
121
|
+
[L(':alice!alice@example.com JOIN #foo')],
|
|
122
|
+
undefined,
|
|
123
|
+
'extended-join',
|
|
124
|
+
[L(':alice!alice@example.com JOIN #foo _ :Alice')],
|
|
125
|
+
),
|
|
126
|
+
Effect.send('c1', [
|
|
127
|
+
L(':irc.example.com 353 alice = #foo :@alice'),
|
|
128
|
+
L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
|
|
129
|
+
]),
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('adds the joiner to the channel roster as a regular member when others are present', () => {
|
|
134
|
+
const chan = makeChan('#foo');
|
|
135
|
+
addMember(chan, 'c2', 'bob');
|
|
136
|
+
const conn = makeConn();
|
|
137
|
+
const ctx = makeCtx(conn);
|
|
138
|
+
|
|
139
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
140
|
+
|
|
141
|
+
const entry = out.state.members.get('c1');
|
|
142
|
+
expect(entry).toBeDefined();
|
|
143
|
+
if (entry !== undefined) {
|
|
144
|
+
expect(entry.nick).toBe('alice');
|
|
145
|
+
expect(entry.op).toBe(false);
|
|
146
|
+
expect(entry.voice).toBe(false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('grants op to the first user to join a channel', () => {
|
|
151
|
+
const chan = makeChan('#foo');
|
|
152
|
+
const conn = makeConn();
|
|
153
|
+
const ctx = makeCtx(conn);
|
|
154
|
+
|
|
155
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
156
|
+
|
|
157
|
+
const entry = out.state.members.get('c1');
|
|
158
|
+
expect(entry?.op).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('adds the channel to the connection joinedChannels set (cross-authority mutation)', () => {
|
|
162
|
+
const chan = makeChan('#foo');
|
|
163
|
+
const conn = makeConn();
|
|
164
|
+
const ctx = makeCtx(conn);
|
|
165
|
+
|
|
166
|
+
joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
167
|
+
|
|
168
|
+
expect(conn.joinedChannels.has('#foo')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('updates the connection lastSeen to ctx.clock.now()', () => {
|
|
172
|
+
const chan = makeChan('#foo');
|
|
173
|
+
const conn = makeConn();
|
|
174
|
+
const clock = new FakeClock(9_000);
|
|
175
|
+
const ctx = makeCtx(conn, clock);
|
|
176
|
+
|
|
177
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
178
|
+
|
|
179
|
+
expect(out.state).toBe(chan);
|
|
180
|
+
expect(conn.lastSeen).toBe(9_000);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('lists existing members with their prefixes in the 353 NAMES reply', () => {
|
|
184
|
+
const chan = makeChan('#foo');
|
|
185
|
+
addMember(chan, 'c2', 'bob', true);
|
|
186
|
+
addMember(chan, 'c3', 'carol');
|
|
187
|
+
const conn = makeConn();
|
|
188
|
+
const ctx = makeCtx(conn);
|
|
189
|
+
|
|
190
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
191
|
+
|
|
192
|
+
// bob is op (@), carol is a regular member. alice is added by this join and
|
|
193
|
+
// is NOT op (she is not first). Names list is one space-separated trailing
|
|
194
|
+
// param; we assert the substring membership rather than exact ordering since
|
|
195
|
+
// roster iteration order matches insertion.
|
|
196
|
+
const sendEffect = out.effects.find(
|
|
197
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
198
|
+
);
|
|
199
|
+
expect(sendEffect).toBeDefined();
|
|
200
|
+
const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));
|
|
201
|
+
expect(namesLine).toBeDefined();
|
|
202
|
+
expect(namesLine?.text).toContain('@bob');
|
|
203
|
+
expect(namesLine?.text).toContain('carol');
|
|
204
|
+
expect(namesLine?.text).toContain('alice');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('uses * as the secret-channel sigil in 353 when +s is set', () => {
|
|
208
|
+
const chan = makeChan('#foo');
|
|
209
|
+
chan.modes.secret = true;
|
|
210
|
+
const conn = makeConn();
|
|
211
|
+
const ctx = makeCtx(conn);
|
|
212
|
+
|
|
213
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
214
|
+
|
|
215
|
+
const sendEffect = out.effects.find(
|
|
216
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
217
|
+
);
|
|
218
|
+
const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));
|
|
219
|
+
expect(namesLine?.text).toContain('353 alice * #foo');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('uses @ as the private-channel sigil in 353 when +p is set', () => {
|
|
223
|
+
const chan = makeChan('#foo');
|
|
224
|
+
chan.modes.private = true;
|
|
225
|
+
const conn = makeConn();
|
|
226
|
+
const ctx = makeCtx(conn);
|
|
227
|
+
|
|
228
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
229
|
+
|
|
230
|
+
const sendEffect = out.effects.find(
|
|
231
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
232
|
+
);
|
|
233
|
+
const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));
|
|
234
|
+
expect(namesLine?.text).toContain('353 alice @ #foo');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('accepts the correct key for a +k channel', () => {
|
|
238
|
+
const chan = makeChan('#foo');
|
|
239
|
+
chan.modes.key = 'secret';
|
|
240
|
+
const conn = makeConn();
|
|
241
|
+
const ctx = makeCtx(conn);
|
|
242
|
+
|
|
243
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo', 'secret'], tags: {} }, ctx);
|
|
244
|
+
|
|
245
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('proceeds with the join when the ban list is non-empty but no mask matches', () => {
|
|
249
|
+
const chan = makeChan('#foo');
|
|
250
|
+
chan.banMasks.add('*!*@baddomain.example');
|
|
251
|
+
chan.banMasks.add('evil!*@*');
|
|
252
|
+
const conn = makeConn();
|
|
253
|
+
const ctx = makeCtx(conn);
|
|
254
|
+
|
|
255
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
256
|
+
|
|
257
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
258
|
+
const sendEffect = out.effects.find(
|
|
259
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
260
|
+
);
|
|
261
|
+
expect(sendEffect).toBeDefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('allows an invited user to bypass +i', () => {
|
|
265
|
+
const chan = makeChan('#foo');
|
|
266
|
+
chan.modes.inviteOnly = true;
|
|
267
|
+
// Pending invites are keyed by lowercased nick.
|
|
268
|
+
chan.pendingInvites.add('alice');
|
|
269
|
+
const conn = makeConn();
|
|
270
|
+
const ctx = makeCtx(conn);
|
|
271
|
+
|
|
272
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
273
|
+
|
|
274
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
275
|
+
// invite is consumed
|
|
276
|
+
expect(out.state.pendingInvites.has('alice')).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('includes the joiner in the broadcast (no except)', () => {
|
|
280
|
+
const chan = makeChan('#foo');
|
|
281
|
+
const conn = makeConn();
|
|
282
|
+
const ctx = makeCtx(conn);
|
|
283
|
+
|
|
284
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
285
|
+
|
|
286
|
+
const broadcast = out.effects.find(
|
|
287
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
288
|
+
);
|
|
289
|
+
expect(broadcast?.except).toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns the same state reference (mutation permitted, no copy)', () => {
|
|
293
|
+
const chan = makeChan('#foo');
|
|
294
|
+
const conn = makeConn();
|
|
295
|
+
const ctx = makeCtx(conn);
|
|
296
|
+
|
|
297
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
298
|
+
|
|
299
|
+
expect(out.state).toBe(chan);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('builds the JOIN source from the connection hostmask without @host when host is absent', () => {
|
|
303
|
+
const chan = makeChan('#foo');
|
|
304
|
+
const conn = makeConn();
|
|
305
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
306
|
+
delete conn.host;
|
|
307
|
+
const ctx = makeCtx(conn);
|
|
308
|
+
|
|
309
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
310
|
+
|
|
311
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
312
|
+
Effect.broadcast('#foo', [L(':alice!alice JOIN #foo')], undefined, 'extended-join', [
|
|
313
|
+
L(':alice!alice JOIN #foo _ :Alice'),
|
|
314
|
+
]),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('falls back to ? and * placeholders when the connection has no nick', () => {
|
|
319
|
+
const chan = makeChan('#foo');
|
|
320
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
321
|
+
const ctx = makeCtx(conn);
|
|
322
|
+
|
|
323
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
324
|
+
|
|
325
|
+
// Roster entry uses '?' since no nick is available.
|
|
326
|
+
expect(out.state.members.get('c1')?.nick).toBe('?');
|
|
327
|
+
// JOIN broadcast source is '?'.
|
|
328
|
+
expect(out.effects).toContainEqual<EffectType>(Effect.broadcast('#foo', [L(':? JOIN #foo')]));
|
|
329
|
+
// 353/366 use '*' as the numeric target. The joiner is first, so it is
|
|
330
|
+
// also an op and appears as `@?` in the names list.
|
|
331
|
+
const sendEffect = out.effects.find(
|
|
332
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
333
|
+
);
|
|
334
|
+
expect(sendEffect?.lines[0]?.text).toBe(':irc.example.com 353 * = #foo :@?');
|
|
335
|
+
expect(sendEffect?.lines[1]?.text).toBe(':irc.example.com 366 * #foo :End of /NAMES list.');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// joinReducer — rejections
|
|
341
|
+
// ============================================================================
|
|
342
|
+
|
|
343
|
+
describe('joinReducer — rejections', () => {
|
|
344
|
+
it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {
|
|
345
|
+
const chan = makeChan('#foo');
|
|
346
|
+
const conn = makeConn();
|
|
347
|
+
const ctx = makeCtx(conn);
|
|
348
|
+
|
|
349
|
+
const out = joinReducer(chan, { command: 'JOIN', params: [], tags: {} }, ctx);
|
|
350
|
+
|
|
351
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
352
|
+
Effect.send('c1', [L(':irc.example.com 461 alice JOIN :Not enough parameters')]),
|
|
353
|
+
]);
|
|
354
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {
|
|
358
|
+
const chan = makeChan('foo');
|
|
359
|
+
const conn = makeConn();
|
|
360
|
+
const ctx = makeCtx(conn);
|
|
361
|
+
|
|
362
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['foo'], tags: {} }, ctx);
|
|
363
|
+
|
|
364
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
365
|
+
Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
|
|
366
|
+
]);
|
|
367
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {
|
|
371
|
+
const chan = makeChan('#foo');
|
|
372
|
+
const conn = makeConn();
|
|
373
|
+
const ctx = makeCtx(conn);
|
|
374
|
+
|
|
375
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo,bar'], tags: {} }, ctx);
|
|
376
|
+
|
|
377
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
378
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit', () => {
|
|
383
|
+
const chan = makeChan('#newchan');
|
|
384
|
+
const conn = makeConn();
|
|
385
|
+
for (let i = 0; i < serverConfig.maxChannelsPerUser; i++) {
|
|
386
|
+
conn.joinedChannels.add(`#c${i}`);
|
|
387
|
+
}
|
|
388
|
+
const ctx = makeCtx(conn);
|
|
389
|
+
|
|
390
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#newchan'], tags: {} }, ctx);
|
|
391
|
+
|
|
392
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
393
|
+
Effect.send('c1', [
|
|
394
|
+
L(':irc.example.com 405 alice #newchan :You have joined too many channels'),
|
|
395
|
+
]),
|
|
396
|
+
]);
|
|
397
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('does not count a channel the user is already on against the max-channels limit', () => {
|
|
401
|
+
const chan = makeChan('#foo');
|
|
402
|
+
addMember(chan, 'c1', 'alice');
|
|
403
|
+
const conn = makeConn();
|
|
404
|
+
conn.joinedChannels.add('#foo');
|
|
405
|
+
for (let i = 0; i < serverConfig.maxChannelsPerUser; i++) {
|
|
406
|
+
conn.joinedChannels.add(`#c${i}`);
|
|
407
|
+
}
|
|
408
|
+
const ctx = makeCtx(conn);
|
|
409
|
+
|
|
410
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
411
|
+
|
|
412
|
+
// No error: the user is already on #foo, so this is a silent no-op.
|
|
413
|
+
expect(out.effects).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('emits 474 ERR_BANNEDFROMCHAN when a ban mask matches the joiner hostmask', () => {
|
|
417
|
+
const chan = makeChan('#foo');
|
|
418
|
+
chan.banMasks.add('*!*@example.com');
|
|
419
|
+
const conn = makeConn();
|
|
420
|
+
const ctx = makeCtx(conn);
|
|
421
|
+
|
|
422
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
423
|
+
|
|
424
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
425
|
+
Effect.send('c1', [L(':irc.example.com 474 alice #foo :Cannot join channel (+b)')]),
|
|
426
|
+
]);
|
|
427
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('honors the ? wildcard in ban masks when checking the joiner hostmask', () => {
|
|
431
|
+
const chan = makeChan('#foo');
|
|
432
|
+
// `?` matches exactly one char; five `?` match the 5-char user segment
|
|
433
|
+
// `alice` between `!` and `@` in alice!alice@example.com.
|
|
434
|
+
chan.banMasks.add('alice!?????@example.com');
|
|
435
|
+
const conn = makeConn();
|
|
436
|
+
const ctx = makeCtx(conn);
|
|
437
|
+
|
|
438
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
439
|
+
|
|
440
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
441
|
+
Effect.send('c1', [L(':irc.example.com 474 alice #foo :Cannot join channel (+b)')]),
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('emits 473 ERR_INVITEONLYCHAN when +i is set and the connection has no pending invite', () => {
|
|
446
|
+
const chan = makeChan('#foo');
|
|
447
|
+
chan.modes.inviteOnly = true;
|
|
448
|
+
const conn = makeConn();
|
|
449
|
+
const ctx = makeCtx(conn);
|
|
450
|
+
|
|
451
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
452
|
+
|
|
453
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
454
|
+
Effect.send('c1', [L(':irc.example.com 473 alice #foo :Cannot join channel (+i)')]),
|
|
455
|
+
]);
|
|
456
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('emits 471 ERR_CHANNELISFULL when +l is set and the roster is at the limit', () => {
|
|
460
|
+
const chan = makeChan('#foo');
|
|
461
|
+
chan.modes.limit = 1;
|
|
462
|
+
addMember(chan, 'c2', 'bob');
|
|
463
|
+
const conn = makeConn();
|
|
464
|
+
const ctx = makeCtx(conn);
|
|
465
|
+
|
|
466
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
467
|
+
|
|
468
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
469
|
+
Effect.send('c1', [L(':irc.example.com 471 alice #foo :Cannot join channel (+l)')]),
|
|
470
|
+
]);
|
|
471
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('emits 475 ERR_BADCHANNELKEY when +k is set and no key was supplied', () => {
|
|
475
|
+
const chan = makeChan('#foo');
|
|
476
|
+
chan.modes.key = 'secret';
|
|
477
|
+
const conn = makeConn();
|
|
478
|
+
const ctx = makeCtx(conn);
|
|
479
|
+
|
|
480
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
481
|
+
|
|
482
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
483
|
+
Effect.send('c1', [L(':irc.example.com 475 alice #foo :Cannot join channel (+k)')]),
|
|
484
|
+
]);
|
|
485
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('emits 475 ERR_BADCHANNELKEY when +k is set and the supplied key is wrong', () => {
|
|
489
|
+
const chan = makeChan('#foo');
|
|
490
|
+
chan.modes.key = 'secret';
|
|
491
|
+
const conn = makeConn();
|
|
492
|
+
const ctx = makeCtx(conn);
|
|
493
|
+
|
|
494
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo', 'wrong'], tags: {} }, ctx);
|
|
495
|
+
|
|
496
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
497
|
+
Effect.send('c1', [L(':irc.example.com 475 alice #foo :Cannot join channel (+k)')]),
|
|
498
|
+
]);
|
|
499
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('uses * in numeric replies when the connection has no nick (defensive)', () => {
|
|
503
|
+
const chan = makeChan('badname');
|
|
504
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
505
|
+
const ctx = makeCtx(conn);
|
|
506
|
+
|
|
507
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['badname'], tags: {} }, ctx);
|
|
508
|
+
|
|
509
|
+
// 403 path fires numericErr, which substitutes `*` for the absent nick.
|
|
510
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
511
|
+
Effect.send('c1', [L(':irc.example.com 403 * badname :No such channel')]),
|
|
512
|
+
]);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ============================================================================
|
|
517
|
+
// joinReducer — already on channel
|
|
518
|
+
// ============================================================================
|
|
519
|
+
|
|
520
|
+
describe('joinReducer — already on channel', () => {
|
|
521
|
+
it('is a silent no-op when the joiner is already on the channel', () => {
|
|
522
|
+
const chan = makeChan('#foo');
|
|
523
|
+
addMember(chan, 'c1', 'alice');
|
|
524
|
+
const conn = makeConn();
|
|
525
|
+
conn.joinedChannels.add('#foo');
|
|
526
|
+
const ctx = makeCtx(conn);
|
|
527
|
+
|
|
528
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
529
|
+
|
|
530
|
+
expect(out.effects).toEqual([]);
|
|
531
|
+
expect(out.state.members.size).toBe(1);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ============================================================================
|
|
536
|
+
// handleJoinZero
|
|
537
|
+
// ============================================================================
|
|
538
|
+
|
|
539
|
+
describe('handleJoinZero', () => {
|
|
540
|
+
it('emits a PART broadcast and roster-removal delta for every joined channel', () => {
|
|
541
|
+
const conn = makeConn();
|
|
542
|
+
conn.joinedChannels.add('#foo');
|
|
543
|
+
conn.joinedChannels.add('#bar');
|
|
544
|
+
const ctx = makeCtx(conn);
|
|
545
|
+
|
|
546
|
+
const effects = handleJoinZero(ctx);
|
|
547
|
+
|
|
548
|
+
expect(effects).toEqual<EffectType[]>(
|
|
549
|
+
expect.arrayContaining([
|
|
550
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),
|
|
551
|
+
Effect.broadcast('#bar', [L(':alice!alice@example.com PART #bar')]),
|
|
552
|
+
]),
|
|
553
|
+
);
|
|
554
|
+
// One roster-removal delta per channel.
|
|
555
|
+
const deltas = effects.filter(
|
|
556
|
+
(e): e is Extract<EffectType, { tag: 'ApplyChannelDelta' }> => e.tag === 'ApplyChannelDelta',
|
|
557
|
+
);
|
|
558
|
+
expect(deltas).toHaveLength(2);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('clears the connection joinedChannels set', () => {
|
|
562
|
+
const conn = makeConn();
|
|
563
|
+
conn.joinedChannels.add('#foo');
|
|
564
|
+
const ctx = makeCtx(conn);
|
|
565
|
+
|
|
566
|
+
handleJoinZero(ctx);
|
|
567
|
+
|
|
568
|
+
expect(conn.joinedChannels.size).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('emits no effects when the connection has joined no channels', () => {
|
|
572
|
+
const conn = makeConn();
|
|
573
|
+
const ctx = makeCtx(conn);
|
|
574
|
+
|
|
575
|
+
const effects = handleJoinZero(ctx);
|
|
576
|
+
|
|
577
|
+
expect(effects).toEqual([]);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// ============================================================================
|
|
582
|
+
// joinReducer — IRCv3 extended-join
|
|
583
|
+
// (https://ircv3.net/specs/extensions/extended-join-3.1.html)
|
|
584
|
+
//
|
|
585
|
+
// Peers that negotiated `extended-join` see the account + realname in the
|
|
586
|
+
// JOIN line; legacy peers see the bare channel. The reducer cannot see each
|
|
587
|
+
// recipient's caps, so it emits one cap-split Broadcast: `lines` (legacy) for
|
|
588
|
+
// non-cap members and `capLines` (extended) for cap-enabled members. The
|
|
589
|
+
// dispatch layer resolves per-recipient.
|
|
590
|
+
// ============================================================================
|
|
591
|
+
|
|
592
|
+
describe('joinReducer — extended-join', () => {
|
|
593
|
+
it('emits a cap-split broadcast with the extended JOIN for cap-enabled peers', () => {
|
|
594
|
+
const chan = makeChan('#foo');
|
|
595
|
+
const conn = makeConn();
|
|
596
|
+
const ctx = makeCtx(conn);
|
|
597
|
+
|
|
598
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
599
|
+
|
|
600
|
+
// Extended format: :nick!user@host JOIN #chan account :realname.
|
|
601
|
+
// Account is `_` (not logged in); realname from USER.
|
|
602
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
603
|
+
Effect.broadcast(
|
|
604
|
+
'#foo',
|
|
605
|
+
[L(':alice!alice@example.com JOIN #foo')],
|
|
606
|
+
undefined,
|
|
607
|
+
'extended-join',
|
|
608
|
+
[L(':alice!alice@example.com JOIN #foo _ :Alice')],
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('uses the authenticated account name in the extended JOIN when set', () => {
|
|
614
|
+
const chan = makeChan('#foo');
|
|
615
|
+
const conn = makeConn();
|
|
616
|
+
conn.account = 'alice-account';
|
|
617
|
+
const ctx = makeCtx(conn);
|
|
618
|
+
|
|
619
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
620
|
+
|
|
621
|
+
const extended = out.effects.find(
|
|
622
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> =>
|
|
623
|
+
e.tag === 'Broadcast' && e.cap === 'extended-join',
|
|
624
|
+
);
|
|
625
|
+
expect(extended?.capLines).toEqual([
|
|
626
|
+
L(':alice!alice@example.com JOIN #foo alice-account :Alice'),
|
|
627
|
+
]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('uses _ as the account when the joiner is not authenticated', () => {
|
|
631
|
+
const chan = makeChan('#foo');
|
|
632
|
+
const conn = makeConn();
|
|
633
|
+
const ctx = makeCtx(conn);
|
|
634
|
+
|
|
635
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
636
|
+
|
|
637
|
+
const extended = out.effects.find(
|
|
638
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> =>
|
|
639
|
+
e.tag === 'Broadcast' && e.cap === 'extended-join',
|
|
640
|
+
);
|
|
641
|
+
expect(extended?.capLines?.[0]?.text).toBe(':alice!alice@example.com JOIN #foo _ :Alice');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('carries the legacy JOIN line for non-cap peers', () => {
|
|
645
|
+
const chan = makeChan('#foo');
|
|
646
|
+
const conn = makeConn();
|
|
647
|
+
const ctx = makeCtx(conn);
|
|
648
|
+
|
|
649
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
650
|
+
|
|
651
|
+
const broadcast = out.effects.find(
|
|
652
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
653
|
+
);
|
|
654
|
+
expect(broadcast?.lines).toEqual([L(':alice!alice@example.com JOIN #foo')]);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('includes the joiner in the broadcast (no except)', () => {
|
|
658
|
+
const chan = makeChan('#foo');
|
|
659
|
+
const conn = makeConn();
|
|
660
|
+
conn.caps.add('extended-join');
|
|
661
|
+
const ctx = makeCtx(conn);
|
|
662
|
+
|
|
663
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
664
|
+
|
|
665
|
+
const broadcast = out.effects.find(
|
|
666
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
667
|
+
);
|
|
668
|
+
expect(broadcast?.except).toBeUndefined();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('falls back to a legacy-only broadcast when realname is absent (defensive)', () => {
|
|
672
|
+
const chan = makeChan('#foo');
|
|
673
|
+
// Bare connection: no nick, user, host, or realname.
|
|
674
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
675
|
+
const ctx = makeCtx(conn);
|
|
676
|
+
|
|
677
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
678
|
+
|
|
679
|
+
// No cap-split: plain legacy broadcast with no cap/capLines fields.
|
|
680
|
+
expect(out.effects).toContainEqual<EffectType>(Effect.broadcast('#foo', [L(':? JOIN #foo')]));
|
|
681
|
+
const broadcast = out.effects.find(
|
|
682
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
683
|
+
);
|
|
684
|
+
expect(broadcast?.cap).toBeUndefined();
|
|
685
|
+
expect(broadcast?.capLines).toBeUndefined();
|
|
686
|
+
});
|
|
687
|
+
});
|