serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,654 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MEMOSERV_NICK, deliverUnreadMemos, memoservReducer } from '../../src/commands/memoserv';
3
+ import type { Effect as EffectType } from '../../src/effects';
4
+ import type { ServicesStore } from '../../src/ports';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryServicesStore,
9
+ SequentialIdFactory,
10
+ } from '../../src/ports';
11
+ import type { IrcMessage } from '../../src/protocol/messages';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const NOW = 1_700_000_000_000;
16
+
17
+ const serverConfig: ServerConfig = {
18
+ serverName: 'irc.example.com',
19
+ networkName: 'ExampleNet',
20
+ maxChannelsPerUser: 30,
21
+ maxTargetsPerCommand: 10,
22
+ maxListEntries: 50,
23
+ nickLen: 30,
24
+ channelLen: 50,
25
+ topicLen: 390,
26
+ quitMessage: 'Client Quit',
27
+ };
28
+
29
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
30
+ const s = createConnection({ id, connectedSince: 0 });
31
+ s.nick = nick;
32
+ s.user = nick;
33
+ s.host = 'example.com';
34
+ s.realname = nick;
35
+ s.registration = 'registered';
36
+ return s;
37
+ }
38
+
39
+ function makeCtx(
40
+ state: ConnectionState,
41
+ services?: ServicesStore,
42
+ clock = new FakeClock(NOW),
43
+ ): Ctx {
44
+ return buildCtx({
45
+ serverConfig,
46
+ clock,
47
+ ids: new SequentialIdFactory(),
48
+ motd: EmptyMotdProvider,
49
+ connection: state,
50
+ ...(services !== undefined ? { services } : {}),
51
+ });
52
+ }
53
+
54
+ function privmsg(body: string): IrcMessage {
55
+ return { command: 'PRIVMSG', params: [MEMOSERV_NICK, body], tags: {} };
56
+ }
57
+
58
+ /** Extracts the text of every Send line emitted by the reducer. */
59
+ function sentTexts(effects: EffectType[]): string[] {
60
+ return effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
61
+ }
62
+
63
+ describe('memoservReducer — services availability', () => {
64
+ it('reports that services are unavailable when no store is bound', () => {
65
+ const conn = makeConn();
66
+ const ctx = makeCtx(conn);
67
+ const out = memoservReducer(conn, privmsg('SEND bob Hi'), ctx);
68
+ expect(sentTexts(out.effects)).toContain(
69
+ ':MemoServ!MemoServ@services NOTICE alice :Services are not available on this server.',
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('memoservReducer — SEND', () => {
75
+ it('requires the sender to be identified', () => {
76
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
77
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
78
+ const conn = makeConn();
79
+ const ctx = makeCtx(conn, services);
80
+
81
+ const out = memoservReducer(conn, privmsg('SEND bob Hi'), ctx);
82
+
83
+ expect(services.listMemos('bob')).toHaveLength(0);
84
+ expect(sentTexts(out.effects)).toContain(
85
+ ':MemoServ!MemoServ@services NOTICE alice :You must identify before sending a memo.',
86
+ );
87
+ });
88
+
89
+ it('rejects SEND when the recipient nick is not registered', () => {
90
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
91
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
92
+ const conn = makeConn();
93
+ conn.account = 'alice';
94
+ conn.userModes.registered = true;
95
+ const ctx = makeCtx(conn, services);
96
+
97
+ const out = memoservReducer(conn, privmsg('SEND ghost Hi'), ctx);
98
+
99
+ expect(services.listMemos('ghost')).toHaveLength(0);
100
+ expect(sentTexts(out.effects)).toContain(
101
+ ':MemoServ!MemoServ@services NOTICE alice :Nick ghost is not registered.',
102
+ );
103
+ });
104
+
105
+ it('records a memo against the recipient account and confirms via NOTICE', () => {
106
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
107
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
108
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
109
+ const conn = makeConn();
110
+ conn.account = 'alice';
111
+ conn.userModes.registered = true;
112
+ const ctx = makeCtx(conn, services);
113
+
114
+ const out = memoservReducer(conn, privmsg('SEND bob Hi there'), ctx);
115
+
116
+ const memos = services.listMemos('bob');
117
+ expect(memos).toHaveLength(1);
118
+ expect(memos[0]).toMatchObject({
119
+ from: 'alice',
120
+ to: 'bob',
121
+ body: 'Hi there',
122
+ read: false,
123
+ });
124
+ expect(sentTexts(out.effects)).toContain(
125
+ ':MemoServ!MemoServ@services NOTICE alice :Memo sent to bob.',
126
+ );
127
+ });
128
+
129
+ it('uses the identified account as the sender, not the current nick', () => {
130
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
131
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
132
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
133
+ const conn = makeConn();
134
+ conn.nick = 'Alice'; // different case from the account
135
+ conn.account = 'alice';
136
+ conn.userModes.registered = true;
137
+ const ctx = makeCtx(conn, services);
138
+
139
+ memoservReducer(conn, privmsg('SEND bob Hi'), ctx);
140
+
141
+ expect(services.listMemos('bob')[0]?.from).toBe('alice');
142
+ });
143
+
144
+ it('rejects SEND with missing recipient', () => {
145
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
146
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
147
+ const conn = makeConn();
148
+ conn.account = 'alice';
149
+ conn.userModes.registered = true;
150
+ const ctx = makeCtx(conn, services);
151
+
152
+ const out = memoservReducer(conn, privmsg('SEND'), ctx);
153
+
154
+ expect(services.listMemos('alice')).toHaveLength(0);
155
+ expect(sentTexts(out.effects)).toContain(
156
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: SEND <nick> <message>',
157
+ );
158
+ });
159
+
160
+ it('rejects SEND with a recipient but no body', () => {
161
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
162
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
163
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
164
+ const conn = makeConn();
165
+ conn.account = 'alice';
166
+ conn.userModes.registered = true;
167
+ const ctx = makeCtx(conn, services);
168
+
169
+ const out = memoservReducer(conn, privmsg('SEND bob'), ctx);
170
+
171
+ expect(services.listMemos('bob')).toHaveLength(0);
172
+ expect(sentTexts(out.effects)).toContain(
173
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: SEND <nick> <message>',
174
+ );
175
+ });
176
+
177
+ it('records a memo body that contains spaces verbatim', () => {
178
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
179
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
180
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
181
+ const conn = makeConn();
182
+ conn.account = 'alice';
183
+ conn.userModes.registered = true;
184
+ const ctx = makeCtx(conn, services);
185
+
186
+ memoservReducer(conn, privmsg('SEND bob one two three'), ctx);
187
+
188
+ expect(services.listMemos('bob')[0]?.body).toBe('one two three');
189
+ });
190
+
191
+ it('rejects SEND to self with a readable notice', () => {
192
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
193
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
194
+ const conn = makeConn();
195
+ conn.account = 'alice';
196
+ conn.userModes.registered = true;
197
+ const ctx = makeCtx(conn, services);
198
+
199
+ const out = memoservReducer(conn, privmsg('SEND alice Hi me'), ctx);
200
+
201
+ expect(services.listMemos('alice')).toHaveLength(0);
202
+ expect(sentTexts(out.effects)).toContain(
203
+ ':MemoServ!MemoServ@services NOTICE alice :You cannot send a memo to yourself.',
204
+ );
205
+ });
206
+
207
+ it('looks up the recipient case-insensitively', () => {
208
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
209
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
210
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
211
+ const conn = makeConn();
212
+ conn.account = 'alice';
213
+ conn.userModes.registered = true;
214
+ const ctx = makeCtx(conn, services);
215
+
216
+ memoservReducer(conn, privmsg('SEND BoB Hi'), ctx);
217
+
218
+ expect(services.listMemos('bob')).toHaveLength(1);
219
+ });
220
+ });
221
+
222
+ describe('memoservReducer — LIST', () => {
223
+ it('requires the caller to be identified', () => {
224
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
225
+ const conn = makeConn();
226
+ const ctx = makeCtx(conn, services);
227
+
228
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
229
+
230
+ expect(sentTexts(out.effects)).toContain(
231
+ ':MemoServ!MemoServ@services NOTICE alice :You must identify before listing memos.',
232
+ );
233
+ });
234
+
235
+ it('reports an empty list when the account has no memos', () => {
236
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
237
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
238
+ const conn = makeConn();
239
+ conn.account = 'alice';
240
+ conn.userModes.registered = true;
241
+ const ctx = makeCtx(conn, services);
242
+
243
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
244
+
245
+ expect(sentTexts(out.effects)).toContain(
246
+ ':MemoServ!MemoServ@services NOTICE alice :You have no memos.',
247
+ );
248
+ });
249
+
250
+ it('lists unread memos with their id, sender and body', () => {
251
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
252
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
253
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'first', sentAt: NOW });
254
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'second', sentAt: NOW });
255
+ const conn = makeConn();
256
+ conn.account = 'alice';
257
+ conn.userModes.registered = true;
258
+ const ctx = makeCtx(conn, services);
259
+
260
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
261
+
262
+ const texts = sentTexts(out.effects);
263
+ expect(texts).toContain(':MemoServ!MemoServ@services NOTICE alice :Memos:');
264
+ expect(texts).toContain(':MemoServ!MemoServ@services NOTICE alice :#1 bob: first');
265
+ expect(texts).toContain(':MemoServ!MemoServ@services NOTICE alice :#2 carol: second');
266
+ });
267
+
268
+ it('excludes already-read memos from the listing', () => {
269
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
270
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
271
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'read', sentAt: NOW });
272
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'unread', sentAt: NOW });
273
+ services.markMemoRead('alice', 1);
274
+ const conn = makeConn();
275
+ conn.account = 'alice';
276
+ conn.userModes.registered = true;
277
+ const ctx = makeCtx(conn, services);
278
+
279
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
280
+
281
+ const texts = sentTexts(out.effects);
282
+ expect(texts).not.toContain(':MemoServ!MemoServ@services NOTICE alice :#1 bob: read');
283
+ expect(texts).toContain(':MemoServ!MemoServ@services NOTICE alice :#2 carol: unread');
284
+ });
285
+ });
286
+
287
+ describe('memoservReducer — READ', () => {
288
+ it('requires the caller to be identified', () => {
289
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
290
+ const conn = makeConn();
291
+ const ctx = makeCtx(conn, services);
292
+
293
+ const out = memoservReducer(conn, privmsg('READ 1'), ctx);
294
+
295
+ expect(sentTexts(out.effects)).toContain(
296
+ ':MemoServ!MemoServ@services NOTICE alice :You must identify before reading memos.',
297
+ );
298
+ });
299
+
300
+ it('delivers the targeted memo via MemoServ NOTICE and marks it read', () => {
301
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
302
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
303
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
304
+ const conn = makeConn();
305
+ conn.account = 'alice';
306
+ conn.userModes.registered = true;
307
+ const ctx = makeCtx(conn, services);
308
+
309
+ const out = memoservReducer(conn, privmsg('READ 1'), ctx);
310
+
311
+ expect(sentTexts(out.effects)).toContain(
312
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
313
+ );
314
+ expect(services.listMemos('alice')[0]?.read).toBe(true);
315
+ });
316
+
317
+ it('rejects READ with no number argument', () => {
318
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
319
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
320
+ const conn = makeConn();
321
+ conn.account = 'alice';
322
+ conn.userModes.registered = true;
323
+ const ctx = makeCtx(conn, services);
324
+
325
+ const out = memoservReducer(conn, privmsg('READ'), ctx);
326
+
327
+ expect(sentTexts(out.effects)).toContain(
328
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: READ <number>',
329
+ );
330
+ });
331
+
332
+ it('rejects READ with a non-numeric argument', () => {
333
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
334
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
335
+ const conn = makeConn();
336
+ conn.account = 'alice';
337
+ conn.userModes.registered = true;
338
+ const ctx = makeCtx(conn, services);
339
+
340
+ const out = memoservReducer(conn, privmsg('READ abc'), ctx);
341
+
342
+ expect(sentTexts(out.effects)).toContain(
343
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: READ <number>',
344
+ );
345
+ });
346
+
347
+ it('reports a missing memo by id', () => {
348
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
349
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
350
+ const conn = makeConn();
351
+ conn.account = 'alice';
352
+ conn.userModes.registered = true;
353
+ const ctx = makeCtx(conn, services);
354
+
355
+ const out = memoservReducer(conn, privmsg('READ 42'), ctx);
356
+
357
+ expect(sentTexts(out.effects)).toContain(
358
+ ':MemoServ!MemoServ@services NOTICE alice :No such memo #42.',
359
+ );
360
+ });
361
+
362
+ it('rejects a non-positive memo number', () => {
363
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
364
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
365
+ const conn = makeConn();
366
+ conn.account = 'alice';
367
+ conn.userModes.registered = true;
368
+ const ctx = makeCtx(conn, services);
369
+
370
+ const out = memoservReducer(conn, privmsg('READ 0'), ctx);
371
+
372
+ expect(sentTexts(out.effects)).toContain(
373
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: READ <number>',
374
+ );
375
+ });
376
+ });
377
+
378
+ describe('memoservReducer — DEL', () => {
379
+ it('requires the caller to be identified', () => {
380
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
381
+ const conn = makeConn();
382
+ const ctx = makeCtx(conn, services);
383
+
384
+ const out = memoservReducer(conn, privmsg('DEL 1'), ctx);
385
+
386
+ expect(sentTexts(out.effects)).toContain(
387
+ ':MemoServ!MemoServ@services NOTICE alice :You must identify before deleting memos.',
388
+ );
389
+ });
390
+
391
+ it('removes the targeted memo and confirms via NOTICE', () => {
392
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
393
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
394
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'bye', sentAt: NOW });
395
+ const conn = makeConn();
396
+ conn.account = 'alice';
397
+ conn.userModes.registered = true;
398
+ const ctx = makeCtx(conn, services);
399
+
400
+ const out = memoservReducer(conn, privmsg('DEL 1'), ctx);
401
+
402
+ expect(services.listMemos('alice')).toEqual([]);
403
+ expect(sentTexts(out.effects)).toContain(
404
+ ':MemoServ!MemoServ@services NOTICE alice :Deleted memo #1.',
405
+ );
406
+ });
407
+
408
+ it('rejects DEL with no number argument', () => {
409
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
410
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
411
+ const conn = makeConn();
412
+ conn.account = 'alice';
413
+ conn.userModes.registered = true;
414
+ const ctx = makeCtx(conn, services);
415
+
416
+ const out = memoservReducer(conn, privmsg('DEL'), ctx);
417
+
418
+ expect(sentTexts(out.effects)).toContain(
419
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: DEL <number>',
420
+ );
421
+ });
422
+
423
+ it('rejects DEL with a non-numeric argument', () => {
424
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
425
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
426
+ const conn = makeConn();
427
+ conn.account = 'alice';
428
+ conn.userModes.registered = true;
429
+ const ctx = makeCtx(conn, services);
430
+
431
+ const out = memoservReducer(conn, privmsg('DEL foo'), ctx);
432
+
433
+ expect(sentTexts(out.effects)).toContain(
434
+ ':MemoServ!MemoServ@services NOTICE alice :Syntax: DEL <number>',
435
+ );
436
+ });
437
+
438
+ it('reports a missing memo by id', () => {
439
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
440
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
441
+ const conn = makeConn();
442
+ conn.account = 'alice';
443
+ conn.userModes.registered = true;
444
+ const ctx = makeCtx(conn, services);
445
+
446
+ const out = memoservReducer(conn, privmsg('DEL 9'), ctx);
447
+
448
+ expect(services.listMemos('alice')).toEqual([]);
449
+ expect(sentTexts(out.effects)).toContain(
450
+ ':MemoServ!MemoServ@services NOTICE alice :No such memo #9.',
451
+ );
452
+ });
453
+ });
454
+
455
+ describe('memoservReducer — misc', () => {
456
+ it('emits a help NOTICE on an empty body', () => {
457
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
458
+ const conn = makeConn();
459
+ conn.account = 'alice';
460
+ conn.userModes.registered = true;
461
+ const ctx = makeCtx(conn, services);
462
+
463
+ const out = memoservReducer(conn, privmsg(''), ctx);
464
+
465
+ expect(sentTexts(out.effects)).toContain(
466
+ ':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
467
+ );
468
+ });
469
+
470
+ it('emits an unknown-command NOTICE for an unrecognized subcommand', () => {
471
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
472
+ const conn = makeConn();
473
+ conn.account = 'alice';
474
+ conn.userModes.registered = true;
475
+ const ctx = makeCtx(conn, services);
476
+
477
+ const out = memoservReducer(conn, privmsg('FROBNICATE'), ctx);
478
+
479
+ expect(sentTexts(out.effects)).toContain(
480
+ ':MemoServ!MemoServ@services NOTICE alice :Unknown command. Available: SEND, LIST, READ, DEL',
481
+ );
482
+ });
483
+
484
+ it('treats subcommand keywords case-insensitively', () => {
485
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
486
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
487
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
488
+ const conn = makeConn();
489
+ conn.account = 'alice';
490
+ conn.userModes.registered = true;
491
+ const ctx = makeCtx(conn, services);
492
+
493
+ memoservReducer(conn, privmsg('send bob Hi'), ctx);
494
+
495
+ expect(services.listMemos('bob')).toHaveLength(1);
496
+ });
497
+
498
+ it('updates lastSeen on the connection', () => {
499
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
500
+ const conn = makeConn();
501
+ conn.account = 'alice';
502
+ conn.userModes.registered = true;
503
+ conn.lastSeen = 0;
504
+ const ctx = makeCtx(conn, services);
505
+
506
+ memoservReducer(conn, privmsg('LIST'), ctx);
507
+
508
+ expect(conn.lastSeen).toBe(NOW);
509
+ });
510
+
511
+ it('uses the connection nick in the NOTICE target when no nick is set', () => {
512
+ const conn = makeConn();
513
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
514
+ delete conn.nick;
515
+ const ctx = makeCtx(conn);
516
+
517
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
518
+
519
+ expect(sentTexts(out.effects)).toContain(
520
+ ':MemoServ!MemoServ@services NOTICE * :Services are not available on this server.',
521
+ );
522
+ });
523
+ });
524
+
525
+ describe('MEMOSERV_NICK constant', () => {
526
+ it('equals MemoServ', () => {
527
+ expect(MEMOSERV_NICK).toBe('MemoServ');
528
+ });
529
+ });
530
+
531
+ describe('deliverUnreadMemos', () => {
532
+ it('delivers every unread memo for the account as MemoServ NOTICEs', () => {
533
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
534
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'first', sentAt: NOW });
535
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'second', sentAt: NOW });
536
+ const conn = makeConn();
537
+ const effects: EffectType[] = [];
538
+
539
+ deliverUnreadMemos(conn, services, 'alice', effects);
540
+
541
+ const texts = sentTexts(effects);
542
+ expect(texts).toContain(':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] first');
543
+ expect(texts).toContain(
544
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #2 from carol] second',
545
+ );
546
+ });
547
+
548
+ it('marks each delivered memo as read', () => {
549
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
550
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'first', sentAt: NOW });
551
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'second', sentAt: NOW });
552
+ const conn = makeConn();
553
+ const effects: EffectType[] = [];
554
+
555
+ deliverUnreadMemos(conn, services, 'alice', effects);
556
+
557
+ const memos = services.listMemos('alice');
558
+ expect(memos[0]?.read).toBe(true);
559
+ expect(memos[1]?.read).toBe(true);
560
+ });
561
+
562
+ it('skips memos that are already read', () => {
563
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
564
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'read', sentAt: NOW });
565
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'unread', sentAt: NOW });
566
+ services.markMemoRead('alice', 1);
567
+ const conn = makeConn();
568
+ const effects: EffectType[] = [];
569
+
570
+ deliverUnreadMemos(conn, services, 'alice', effects);
571
+
572
+ const texts = sentTexts(effects);
573
+ expect(texts).toHaveLength(1);
574
+ expect(texts[0]).toBe(':MemoServ!MemoServ@services NOTICE alice :[Memo #2 from carol] unread');
575
+ });
576
+
577
+ it('emits no effects when the account has no memos', () => {
578
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
579
+ const conn = makeConn();
580
+ const effects: EffectType[] = [];
581
+
582
+ deliverUnreadMemos(conn, services, 'ghost', effects);
583
+
584
+ expect(effects).toEqual([]);
585
+ });
586
+
587
+ it('looks up the recipient case-insensitively', () => {
588
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
589
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'folded', sentAt: NOW });
590
+ const conn = makeConn();
591
+ const effects: EffectType[] = [];
592
+
593
+ deliverUnreadMemos(conn, services, 'ALICE', effects);
594
+
595
+ expect(sentTexts(effects)).toContain(
596
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] folded',
597
+ );
598
+ });
599
+
600
+ it('delivers memos in storage order (oldest-first)', () => {
601
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
602
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'one', sentAt: NOW });
603
+ services.recordMemo({ from: 'carol', to: 'alice', body: 'two', sentAt: NOW });
604
+ services.recordMemo({ from: 'dave', to: 'alice', body: 'three', sentAt: NOW });
605
+ const conn = makeConn();
606
+ const effects: EffectType[] = [];
607
+
608
+ deliverUnreadMemos(conn, services, 'alice', effects);
609
+
610
+ const ids = sentTexts(effects).map((t) => t.match(/#(\d+)/u)?.[1]);
611
+ expect(ids).toEqual(['1', '2', '3']);
612
+ });
613
+
614
+ it('addresses the NOTICE to the connection nick', () => {
615
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
616
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
617
+ const conn = makeConn('c1', 'Alice');
618
+ const effects: EffectType[] = [];
619
+
620
+ deliverUnreadMemos(conn, services, 'alice', effects);
621
+
622
+ expect(sentTexts(effects)[0]).toContain('NOTICE Alice :');
623
+ });
624
+ });
625
+
626
+ describe('memoservReducer — Effect construction', () => {
627
+ it('emits Send effects addressed to the invoking connection', () => {
628
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
629
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
630
+ const conn = makeConn('c7');
631
+ conn.account = 'alice';
632
+ conn.userModes.registered = true;
633
+ const ctx = makeCtx(conn, services);
634
+
635
+ const out = memoservReducer(conn, privmsg('LIST'), ctx);
636
+
637
+ expect(out.effects.every((e) => e.tag !== 'Send' || e.to === 'c7')).toBe(true);
638
+ });
639
+
640
+ it('does not mutate services state for read-only commands', () => {
641
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
642
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
643
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'persisted', sentAt: NOW });
644
+ const conn = makeConn();
645
+ conn.account = 'alice';
646
+ conn.userModes.registered = true;
647
+ const ctx = makeCtx(conn, services);
648
+
649
+ memoservReducer(conn, privmsg('LIST'), ctx);
650
+
651
+ expect(services.listMemos('alice')).toHaveLength(1);
652
+ expect(services.listMemos('alice')[0]?.read).toBe(false);
653
+ });
654
+ });