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,1289 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type ChannelLevelOp,
4
+ type ChannelRegisterResult,
5
+ FakeClock,
6
+ InMemoryServicesStore,
7
+ type JupeEntry,
8
+ type MemoEntry,
9
+ type MemoInput,
10
+ type NickEnforcePolicy,
11
+ type ReadMarkerEntry,
12
+ type RegisteredNick,
13
+ type ServicesAuthResult,
14
+ type ServicesStore,
15
+ } from '../src/ports';
16
+
17
+ const NOW = 1_700_000_000_000;
18
+
19
+ function store(): InMemoryServicesStore {
20
+ return new InMemoryServicesStore({ clock: new FakeClock(NOW) });
21
+ }
22
+
23
+ describe('InMemoryServicesStore — port conformance', () => {
24
+ it('implements the ServicesStore port', () => {
25
+ const s: ServicesStore = new InMemoryServicesStore();
26
+ expect(s).toBeDefined();
27
+ });
28
+ });
29
+
30
+ // ============================================================================
31
+ // NickServ
32
+ // ============================================================================
33
+
34
+ describe('InMemoryServicesStore — NickServ REGISTER (registerNick)', () => {
35
+ it('registers a fresh nick and returns ok with the account name', () => {
36
+ const s = store();
37
+ const out = s.registerNick('alice', 'hunter2', 'alice@example.com');
38
+ expect(out).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
39
+ });
40
+
41
+ it('rejects a duplicate nick (case-insensitive) with a readable reason', () => {
42
+ const s = store();
43
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
44
+ const out = s.registerNick('Alice', 'other', 'alice2@example.com');
45
+ expect(out.ok).toBe(false);
46
+ if (out.ok) return;
47
+ expect(out.reason.length).toBeGreaterThan(0);
48
+ });
49
+
50
+ it('stores the email and a createdAt timestamp on the record', () => {
51
+ const s = store();
52
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
53
+ const rec = s.getNick('alice');
54
+ expect(rec).toBeDefined();
55
+ if (rec === undefined) return;
56
+ expect(rec.email).toBe('alice@example.com');
57
+ expect(rec.createdAt).toBe(NOW);
58
+ });
59
+
60
+ it('defaults the enforce policy to "none" on a fresh registration', () => {
61
+ const s = store();
62
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
63
+ expect(s.getNickEnforce('alice')).toBe<NickEnforcePolicy>('none');
64
+ });
65
+
66
+ it('is markable as registered via isRegisteredNick', () => {
67
+ const s = store();
68
+ expect(s.isRegisteredNick('alice')).toBe(false);
69
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
70
+ expect(s.isRegisteredNick('alice')).toBe(true);
71
+ });
72
+
73
+ it('matches isRegisteredNick case-insensitively', () => {
74
+ const s = store();
75
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
76
+ expect(s.isRegisteredNick('alice')).toBe(true);
77
+ expect(s.isRegisteredNick('ALICE')).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
82
+ it('returns ok with the account name for a correct password', () => {
83
+ const s = store();
84
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
85
+ expect(s.verifyNick('alice', 'hunter2')).toEqual<ServicesAuthResult>({
86
+ ok: true,
87
+ account: 'alice',
88
+ });
89
+ });
90
+
91
+ it('rejects a wrong password', () => {
92
+ const s = store();
93
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
94
+ expect(s.verifyNick('alice', 'wrong').ok).toBe(false);
95
+ });
96
+
97
+ it('rejects an unknown nick', () => {
98
+ const s = store();
99
+ expect(s.verifyNick('ghost', 'hunter2').ok).toBe(false);
100
+ });
101
+
102
+ it('matches the nick case-insensitively', () => {
103
+ const s = store();
104
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
105
+ expect(s.verifyNick('alice', 'hunter2')).toEqual<ServicesAuthResult>({
106
+ ok: true,
107
+ account: 'Alice',
108
+ });
109
+ });
110
+
111
+ it('returns a human-readable reason on failure', () => {
112
+ const s = store();
113
+ const out = s.verifyNick('alice', 'wrong');
114
+ if (out.ok) throw new Error('expected failure');
115
+ expect(out.reason.length).toBeGreaterThan(0);
116
+ });
117
+ });
118
+
119
+ describe('InMemoryServicesStore — NickServ DROP (dropNick)', () => {
120
+ it('returns true and removes the registration for a known nick', () => {
121
+ const s = store();
122
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
123
+ expect(s.dropNick('alice')).toBe(true);
124
+ expect(s.isRegisteredNick('alice')).toBe(false);
125
+ expect(s.getNick('alice')).toBeUndefined();
126
+ });
127
+
128
+ it('returns false for an unknown nick', () => {
129
+ const s = store();
130
+ expect(s.dropNick('ghost')).toBe(false);
131
+ });
132
+
133
+ it('folds the nick case-insensitively', () => {
134
+ const s = store();
135
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
136
+ expect(s.dropNick('ALICE')).toBe(true);
137
+ expect(s.isRegisteredNick('alice')).toBe(false);
138
+ });
139
+
140
+ it('clears the enforce policy on drop', () => {
141
+ const s = store();
142
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
143
+ s.setNickEnforce('alice', 'kill');
144
+ s.dropNick('alice');
145
+ expect(s.getNickEnforce('alice')).toBe<NickEnforcePolicy>('none');
146
+ });
147
+ });
148
+
149
+ describe('InMemoryServicesStore — NickServ SET ENFORCE (setNickEnforce)', () => {
150
+ it('updates the policy observable via getNickEnforce', () => {
151
+ const s = store();
152
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
153
+ s.setNickEnforce('alice', 'ghost');
154
+ expect(s.getNickEnforce('alice')).toBe<NickEnforcePolicy>('ghost');
155
+ s.setNickEnforce('alice', 'kill');
156
+ expect(s.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
157
+ });
158
+
159
+ it('folds the nick case-insensitively', () => {
160
+ const s = store();
161
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
162
+ s.setNickEnforce('alice', 'kill');
163
+ expect(s.getNickEnforce('ALICE')).toBe<NickEnforcePolicy>('kill');
164
+ });
165
+
166
+ it('is a no-op on an unregistered nick (policy stays "none")', () => {
167
+ const s = store();
168
+ s.setNickEnforce('ghost', 'kill');
169
+ expect(s.getNickEnforce('ghost')).toBe<NickEnforcePolicy>('none');
170
+ });
171
+
172
+ it('defaults to "none" for an unregistered nick', () => {
173
+ const s = store();
174
+ expect(s.getNickEnforce('ghost')).toBe<NickEnforcePolicy>('none');
175
+ });
176
+ });
177
+
178
+ describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
179
+ it('returns the full record for a registered nick', () => {
180
+ const s = store();
181
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
182
+ const rec = s.getNick('alice');
183
+ expect(rec).toEqual<RegisteredNick>({
184
+ nick: 'Alice',
185
+ account: 'Alice',
186
+ email: 'alice@example.com',
187
+ createdAt: NOW,
188
+ enforce: 'none',
189
+ });
190
+ });
191
+
192
+ it('returns undefined for an unknown nick', () => {
193
+ const s = store();
194
+ expect(s.getNick('ghost')).toBeUndefined();
195
+ });
196
+
197
+ it('reflects subsequent enforce-policy changes on the returned record', () => {
198
+ const s = store();
199
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
200
+ s.setNickEnforce('alice', 'kill');
201
+ const rec = s.getNick('alice');
202
+ expect(rec?.enforce).toBe<NickEnforcePolicy>('kill');
203
+ });
204
+ });
205
+
206
+ // ============================================================================
207
+ // ChanServ
208
+ // ============================================================================
209
+
210
+ describe('InMemoryServicesStore — ChanServ REGISTER (registerChannel)', () => {
211
+ it('registers a fresh channel and returns ok with the founder', () => {
212
+ const s = store();
213
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
214
+ const out = s.registerChannel('#home', 'alice');
215
+ expect(out).toEqual<ChannelRegisterResult>({ ok: true, founder: 'alice' });
216
+ });
217
+
218
+ it('rejects a duplicate channel (case-insensitive)', () => {
219
+ const s = store();
220
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
221
+ s.registerChannel('#home', 'alice');
222
+ const out = s.registerChannel('#Home', 'bob');
223
+ expect(out.ok).toBe(false);
224
+ if (out.ok) return;
225
+ expect(out.reason.length).toBeGreaterThan(0);
226
+ });
227
+
228
+ it('stores the founder and a createdAt timestamp', () => {
229
+ const s = store();
230
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
231
+ s.registerChannel('#home', 'alice');
232
+ expect(s.getChannelFounder('#home')).toBe('alice');
233
+ });
234
+
235
+ it('initializes the new record with empty mlock / restricted=false / keepTopic=false', () => {
236
+ const s = store();
237
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
238
+ s.registerChannel('#home', 'alice');
239
+ const rec = s.getChannel('#home');
240
+ expect(rec?.mlock).toBe('');
241
+ expect(rec?.restricted).toBe(false);
242
+ expect(rec?.keepTopic).toBe(false);
243
+ expect(rec?.topic).toBeUndefined();
244
+ });
245
+ });
246
+
247
+ describe('InMemoryServicesStore — ChanServ DROP (dropChannel)', () => {
248
+ it('returns true and clears the founder for a known channel', () => {
249
+ const s = store();
250
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
251
+ s.registerChannel('#home', 'alice');
252
+ expect(s.dropChannel('#home')).toBe(true);
253
+ expect(s.getChannelFounder('#home')).toBeUndefined();
254
+ });
255
+
256
+ it('returns false for an unknown channel', () => {
257
+ const s = store();
258
+ expect(s.dropChannel('#ghost')).toBe(false);
259
+ });
260
+
261
+ it('folds the channel name case-insensitively', () => {
262
+ const s = store();
263
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
264
+ s.registerChannel('#Home', 'alice');
265
+ expect(s.dropChannel('#home')).toBe(true);
266
+ expect(s.getChannelFounder('#home')).toBeUndefined();
267
+ });
268
+
269
+ it('clears the access list on drop', () => {
270
+ const s = store();
271
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
272
+ s.registerNick('bob', 'pw', 'bob@example.com');
273
+ s.registerChannel('#home', 'alice');
274
+ s.setChannelAccess('#home', 'bob', 5);
275
+ s.dropChannel('#home');
276
+ expect(s.getChannelAccess('#home', 'bob')).toBe(0);
277
+ });
278
+ });
279
+
280
+ describe('InMemoryServicesStore — ChanServ founder lookup (getChannelFounder)', () => {
281
+ it('returns undefined for an unknown channel', () => {
282
+ const s = store();
283
+ expect(s.getChannelFounder('#ghost')).toBeUndefined();
284
+ });
285
+
286
+ it('folds the channel name case-insensitively', () => {
287
+ const s = store();
288
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
289
+ s.registerChannel('#Home', 'alice');
290
+ expect(s.getChannelFounder('#home')).toBe('alice');
291
+ expect(s.getChannelFounder('#HOME')).toBe('alice');
292
+ });
293
+ });
294
+
295
+ describe('InMemoryServicesStore — ChanServ ACCESS (setChannelAccess / getChannelAccess)', () => {
296
+ it('returns 0 (no access) for an unknown (channel, account) pair', () => {
297
+ const s = store();
298
+ expect(s.getChannelAccess('#home', 'alice')).toBe(0);
299
+ });
300
+
301
+ it('sets and returns the level for a known pair', () => {
302
+ const s = store();
303
+ s.setChannelAccess('#home', 'alice', 5);
304
+ expect(s.getChannelAccess('#home', 'alice')).toBe(5);
305
+ });
306
+
307
+ it('overwrites the level on a subsequent set for the same pair', () => {
308
+ const s = store();
309
+ s.setChannelAccess('#home', 'alice', 3);
310
+ s.setChannelAccess('#home', 'alice', 10);
311
+ expect(s.getChannelAccess('#home', 'alice')).toBe(10);
312
+ });
313
+
314
+ it('folds channel and account case-insensitively', () => {
315
+ const s = store();
316
+ s.setChannelAccess('#Home', 'Alice', 5);
317
+ expect(s.getChannelAccess('#home', 'alice')).toBe(5);
318
+ expect(s.getChannelAccess('#HOME', 'ALICE')).toBe(5);
319
+ });
320
+
321
+ it('keeps access for distinct accounts on the same channel separate', () => {
322
+ const s = store();
323
+ s.setChannelAccess('#home', 'alice', 5);
324
+ s.setChannelAccess('#home', 'bob', 3);
325
+ expect(s.getChannelAccess('#home', 'alice')).toBe(5);
326
+ expect(s.getChannelAccess('#home', 'bob')).toBe(3);
327
+ });
328
+
329
+ it('keeps access for the same account across distinct channels separate', () => {
330
+ const s = store();
331
+ s.setChannelAccess('#home', 'alice', 5);
332
+ s.setChannelAccess('#other', 'alice', 3);
333
+ expect(s.getChannelAccess('#home', 'alice')).toBe(5);
334
+ expect(s.getChannelAccess('#other', 'alice')).toBe(3);
335
+ });
336
+
337
+ it('removes the entry when the level is set to 0', () => {
338
+ const s = store();
339
+ s.setChannelAccess('#home', 'alice', 5);
340
+ s.setChannelAccess('#home', 'alice', 0);
341
+ expect(s.getChannelAccess('#home', 'alice')).toBe(0);
342
+ });
343
+
344
+ it('removes the entry when the level is set to a negative value', () => {
345
+ const s = store();
346
+ s.setChannelAccess('#home', 'alice', 5);
347
+ s.setChannelAccess('#home', 'alice', -1);
348
+ expect(s.getChannelAccess('#home', 'alice')).toBe(0);
349
+ });
350
+
351
+ it('leaves sibling entries intact when removing one entry from a multi-entry map', () => {
352
+ const s = store();
353
+ s.setChannelAccess('#home', 'alice', 5);
354
+ s.setChannelAccess('#home', 'bob', 3);
355
+ s.setChannelAccess('#home', 'alice', 0);
356
+ expect(s.getChannelAccess('#home', 'alice')).toBe(0);
357
+ expect(s.getChannelAccess('#home', 'bob')).toBe(3);
358
+ });
359
+
360
+ it('re-adds an entry after the per-channel map was emptied out', () => {
361
+ const s = store();
362
+ // Fill, then drain: the per-channel map is allocated then dropped.
363
+ s.setChannelAccess('#home', 'alice', 5);
364
+ s.setChannelAccess('#home', 'alice', 0);
365
+ // Re-adding must allocate a fresh per-channel map.
366
+ s.setChannelAccess('#home', 'bob', 3);
367
+ expect(s.getChannelAccess('#home', 'bob')).toBe(3);
368
+ });
369
+ });
370
+
371
+ describe('InMemoryServicesStore — ChanServ LEVELS (getChannelLevel / setChannelLevel / listChannelLevels / resetChannelLevels)', () => {
372
+ it('returns undefined for an op with no override stored', () => {
373
+ const s = store();
374
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBeUndefined();
375
+ });
376
+
377
+ it('stores and returns an override for a single op', () => {
378
+ const s = store();
379
+ s.setChannelLevel('#home', 'AUTOOP', 7);
380
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBe(7);
381
+ });
382
+
383
+ it('overwrites the override on a subsequent set for the same op', () => {
384
+ const s = store();
385
+ s.setChannelLevel('#home', 'AUTOOP', 7);
386
+ s.setChannelLevel('#home', 'AUTOOP', 9);
387
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBe(9);
388
+ });
389
+
390
+ it('reuses the existing per-channel map on a second setChannelLevel for the same channel', () => {
391
+ const s = store();
392
+ s.setChannelLevel('#home', 'AUTOOP', 7);
393
+ s.setChannelLevel('#home', 'AUTOVOICE', 2);
394
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBe(7);
395
+ expect(s.getChannelLevel('#home', 'AUTOVOICE')).toBe(2);
396
+ });
397
+
398
+ it('folds the channel case-insensitively', () => {
399
+ const s = store();
400
+ s.setChannelLevel('#Home', 'AUTOOP', 7);
401
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBe(7);
402
+ expect(s.getChannelLevel('#HOME', 'AUTOOP')).toBe(7);
403
+ });
404
+
405
+ it('keeps overrides for distinct channels separate', () => {
406
+ const s = store();
407
+ s.setChannelLevel('#home', 'AUTOOP', 7);
408
+ s.setChannelLevel('#other', 'AUTOOP', 4);
409
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBe(7);
410
+ expect(s.getChannelLevel('#other', 'AUTOOP')).toBe(4);
411
+ });
412
+
413
+ it('listChannelLevels returns empty for a channel with no overrides', () => {
414
+ const s = store();
415
+ expect(s.listChannelLevels('#home')).toEqual([]);
416
+ });
417
+
418
+ it('listChannelLevels enumerates every override on the channel', () => {
419
+ const s = store();
420
+ s.setChannelLevel('#home', 'AUTOOP', 7);
421
+ s.setChannelLevel('#home', 'AUTOVOICE', 2);
422
+ const listed = s.listChannelLevels('#home');
423
+ expect(listed).toContainEqual({ op: 'AUTOOP', level: 7 });
424
+ expect(listed).toContainEqual({ op: 'AUTOVOICE', level: 2 });
425
+ });
426
+
427
+ it('resetChannelLevels drops every override for the channel', () => {
428
+ const s = store();
429
+ s.setChannelLevel('#home', 'AUTOOP', 7);
430
+ s.resetChannelLevels('#home');
431
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBeUndefined();
432
+ expect(s.listChannelLevels('#home')).toEqual([]);
433
+ });
434
+
435
+ it('resetChannelLevels is a no-op when no overrides are set', () => {
436
+ const s = store();
437
+ s.resetChannelLevels('#home');
438
+ expect(s.listChannelLevels('#home')).toEqual([]);
439
+ });
440
+
441
+ it('dropChannel also drops the channel levels map', () => {
442
+ const s = store();
443
+ s.setChannelLevel('#home', 'AUTOOP', 7);
444
+ s.registerChannel('#home', 'alice');
445
+ s.dropChannel('#home');
446
+ expect(s.getChannelLevel('#home', 'AUTOOP')).toBeUndefined();
447
+ });
448
+
449
+ it('LEVELS methods are reachable via the ServicesStore surface type', () => {
450
+ const s: ServicesStore = store();
451
+ const op: ChannelLevelOp = 'AUTOOP';
452
+ s.setChannelLevel('#home', op, 7);
453
+ expect(s.getChannelLevel('#home', op)).toBe(7);
454
+ expect(s.listChannelLevels('#home').length).toBe(1);
455
+ s.resetChannelLevels('#home');
456
+ expect(s.getChannelLevel('#home', op)).toBeUndefined();
457
+ });
458
+ });
459
+
460
+ describe('InMemoryServicesStore — ChanServ record lookup (getChannel)', () => {
461
+ it('returns undefined for an unknown channel', () => {
462
+ const s = store();
463
+ expect(s.getChannel('#home')).toBeUndefined();
464
+ });
465
+
466
+ it('returns a defensive copy (mutating the returned record does not mutate the store)', () => {
467
+ const s = store();
468
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
469
+ s.registerChannel('#home', 'alice');
470
+ const rec = s.getChannel('#home');
471
+ if (rec === undefined) {
472
+ expect.fail('expected a record');
473
+ return;
474
+ }
475
+ rec.founder = 'mallory';
476
+ rec.mlock = '+nt';
477
+ expect(s.getChannel('#home')?.founder).toBe('alice');
478
+ expect(s.getChannel('#home')?.mlock).toBe('');
479
+ });
480
+
481
+ it('folds the channel name case-insensitively', () => {
482
+ const s = store();
483
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
484
+ s.registerChannel('#Home', 'alice');
485
+ expect(s.getChannel('#HOME')?.founder).toBe('alice');
486
+ });
487
+ });
488
+
489
+ describe('InMemoryServicesStore — ChanServ SET FOUNDER (setChannelFounder)', () => {
490
+ it('transfers the founder of a registered channel', () => {
491
+ const s = store();
492
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
493
+ s.registerNick('bob', 'pw', 'bob@example.com');
494
+ s.registerChannel('#home', 'alice');
495
+ s.setChannelFounder('#home', 'bob');
496
+ expect(s.getChannelFounder('#home')).toBe('bob');
497
+ });
498
+
499
+ it('is a no-op on an unregistered channel', () => {
500
+ const s = store();
501
+ s.setChannelFounder('#ghost', 'bob');
502
+ expect(s.getChannelFounder('#ghost')).toBeUndefined();
503
+ });
504
+ });
505
+
506
+ describe('InMemoryServicesStore — ChanServ SET MLOCK (setChannelMlock / getChannelMlock)', () => {
507
+ it('stores and returns the modestring', () => {
508
+ const s = store();
509
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
510
+ s.registerChannel('#home', 'alice');
511
+ s.setChannelMlock('#home', '+nt');
512
+ expect(s.getChannelMlock('#home')).toBe('+nt');
513
+ });
514
+
515
+ it('overwrites a previous value on a subsequent set', () => {
516
+ const s = store();
517
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
518
+ s.registerChannel('#home', 'alice');
519
+ s.setChannelMlock('#home', '+nt');
520
+ s.setChannelMlock('#home', '+n');
521
+ expect(s.getChannelMlock('#home')).toBe('+n');
522
+ });
523
+
524
+ it('returns an empty string for an unregistered channel', () => {
525
+ const s = store();
526
+ expect(s.getChannelMlock('#ghost')).toBe('');
527
+ });
528
+
529
+ it('is a no-op on an unregistered channel (does not throw)', () => {
530
+ const s = store();
531
+ s.setChannelMlock('#ghost', '+nt');
532
+ expect(s.getChannelMlock('#ghost')).toBe('');
533
+ });
534
+
535
+ it('folds the channel name case-insensitively', () => {
536
+ const s = store();
537
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
538
+ s.registerChannel('#Home', 'alice');
539
+ s.setChannelMlock('#HOME', '+nt');
540
+ expect(s.getChannelMlock('#home')).toBe('+nt');
541
+ });
542
+ });
543
+
544
+ describe('InMemoryServicesStore — ChanServ SET RESTRICTED (setChannelRestricted)', () => {
545
+ it('toggles the restricted flag on', () => {
546
+ const s = store();
547
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
548
+ s.registerChannel('#home', 'alice');
549
+ s.setChannelRestricted('#home', true);
550
+ expect(s.getChannel('#home')?.restricted).toBe(true);
551
+ });
552
+
553
+ it('toggles the restricted flag off', () => {
554
+ const s = store();
555
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
556
+ s.registerChannel('#home', 'alice');
557
+ s.setChannelRestricted('#home', true);
558
+ s.setChannelRestricted('#home', false);
559
+ expect(s.getChannel('#home')?.restricted).toBe(false);
560
+ });
561
+
562
+ it('is a no-op on an unregistered channel', () => {
563
+ const s = store();
564
+ s.setChannelRestricted('#ghost', true);
565
+ expect(s.getChannel('#ghost')).toBeUndefined();
566
+ });
567
+ });
568
+
569
+ describe('InMemoryServicesStore — ChanServ SET KEEPTOPIC (setChannelKeepTopic)', () => {
570
+ it('toggles the keepTopic flag on', () => {
571
+ const s = store();
572
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
573
+ s.registerChannel('#home', 'alice');
574
+ s.setChannelKeepTopic('#home', true);
575
+ expect(s.getChannel('#home')?.keepTopic).toBe(true);
576
+ });
577
+
578
+ it('drops the persisted topic snapshot when toggled off', () => {
579
+ const s = store();
580
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
581
+ s.registerChannel('#home', 'alice');
582
+ s.setChannelKeepTopic('#home', true);
583
+ s.setChannelTopic('#home', { text: 't', setter: 's', setAt: 0 });
584
+ s.setChannelKeepTopic('#home', false);
585
+ expect(s.getChannel('#home')?.keepTopic).toBe(false);
586
+ expect(s.getChannel('#home')?.topic).toBeUndefined();
587
+ });
588
+
589
+ it('is a no-op on an unregistered channel', () => {
590
+ const s = store();
591
+ s.setChannelKeepTopic('#ghost', true);
592
+ expect(s.getChannel('#ghost')).toBeUndefined();
593
+ });
594
+ });
595
+
596
+ describe('InMemoryServicesStore — ChanServ topic persistence (setChannelTopic / getChannelTopic)', () => {
597
+ it('stores and returns the topic snapshot', () => {
598
+ const s = store();
599
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
600
+ s.registerChannel('#home', 'alice');
601
+ s.setChannelTopic('#home', { text: 'hello', setter: 'alice!alice@e', setAt: 1 });
602
+ expect(s.getChannelTopic('#home')).toEqual({
603
+ text: 'hello',
604
+ setter: 'alice!alice@e',
605
+ setAt: 1,
606
+ });
607
+ });
608
+
609
+ it('returns a defensive copy (mutating the returned snapshot does not mutate the store)', () => {
610
+ const s = store();
611
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
612
+ s.registerChannel('#home', 'alice');
613
+ s.setChannelTopic('#home', { text: 'hello', setter: 's', setAt: 1 });
614
+ const snap = s.getChannelTopic('#home');
615
+ if (snap === undefined) {
616
+ expect.fail('expected a snapshot');
617
+ return;
618
+ }
619
+ snap.text = 'tampered';
620
+ expect(s.getChannelTopic('#home')?.text).toBe('hello');
621
+ });
622
+
623
+ it('clears the snapshot when passed undefined', () => {
624
+ const s = store();
625
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
626
+ s.registerChannel('#home', 'alice');
627
+ s.setChannelTopic('#home', { text: 'hello', setter: 's', setAt: 1 });
628
+ s.setChannelTopic('#home', undefined);
629
+ expect(s.getChannelTopic('#home')).toBeUndefined();
630
+ });
631
+
632
+ it('returns undefined for an unregistered channel', () => {
633
+ const s = store();
634
+ expect(s.getChannelTopic('#ghost')).toBeUndefined();
635
+ // setChannelTopic on an unregistered channel is a no-op (not a throw).
636
+ s.setChannelTopic('#ghost', { text: 'x', setter: 's', setAt: 0 });
637
+ expect(s.getChannelTopic('#ghost')).toBeUndefined();
638
+ });
639
+
640
+ it('folds the channel name case-insensitively', () => {
641
+ const s = store();
642
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
643
+ s.registerChannel('#Home', 'alice');
644
+ s.setChannelTopic('#home', { text: 'hello', setter: 's', setAt: 1 });
645
+ expect(s.getChannelTopic('#HOME')?.text).toBe('hello');
646
+ });
647
+ });
648
+
649
+ // ============================================================================
650
+ // HostServ
651
+ // ============================================================================
652
+
653
+ describe('InMemoryServicesStore — HostServ (setVhost / getVhost)', () => {
654
+ it('returns undefined for an unknown account', () => {
655
+ const s = store();
656
+ expect(s.getVhost('alice')).toBeUndefined();
657
+ });
658
+
659
+ it('sets and returns the vhost for a known account', () => {
660
+ const s = store();
661
+ s.setVhost('alice', 'cool.example.net');
662
+ expect(s.getVhost('alice')).toBe('cool.example.net');
663
+ });
664
+
665
+ it('overwrites the vhost on a subsequent set for the same account', () => {
666
+ const s = store();
667
+ s.setVhost('alice', 'one.example.net');
668
+ s.setVhost('alice', 'two.example.net');
669
+ expect(s.getVhost('alice')).toBe('two.example.net');
670
+ });
671
+
672
+ it('folds the account name case-insensitively', () => {
673
+ const s = store();
674
+ s.setVhost('Alice', 'one.example.net');
675
+ expect(s.getVhost('alice')).toBe('one.example.net');
676
+ expect(s.getVhost('ALICE')).toBe('one.example.net');
677
+ });
678
+
679
+ it('keeps vhosts for distinct accounts separate', () => {
680
+ const s = store();
681
+ s.setVhost('alice', 'a.example.net');
682
+ s.setVhost('bob', 'b.example.net');
683
+ expect(s.getVhost('alice')).toBe('a.example.net');
684
+ expect(s.getVhost('bob')).toBe('b.example.net');
685
+ });
686
+
687
+ it('clears the vhost when set to an empty string', () => {
688
+ const s = store();
689
+ s.setVhost('alice', 'cool.example.net');
690
+ s.setVhost('alice', '');
691
+ expect(s.getVhost('alice')).toBeUndefined();
692
+ });
693
+
694
+ it('is a no-op to clear an account that has no vhost', () => {
695
+ const s = store();
696
+ s.setVhost('alice', '');
697
+ expect(s.getVhost('alice')).toBeUndefined();
698
+ });
699
+ });
700
+
701
+ describe('InMemoryServicesStore — HostServ pending vhost queue', () => {
702
+ it('returns undefined for a pending vhost when no request has been recorded', () => {
703
+ const s = store();
704
+ expect(s.getPendingVhost('alice')).toBeUndefined();
705
+ });
706
+
707
+ it('records a pending vhost request and exposes it via getPendingVhost', () => {
708
+ const s = store();
709
+ s.requestVhost('alice', 'cool.example.net');
710
+ expect(s.getPendingVhost('alice')).toBe('cool.example.net');
711
+ });
712
+
713
+ it('does NOT activate the vhost on requestVhost (queue mode)', () => {
714
+ const s = store();
715
+ s.requestVhost('alice', 'cool.example.net');
716
+ expect(s.getVhost('alice')).toBeUndefined();
717
+ });
718
+
719
+ it('overwrites a previously pending vhost on a subsequent request', () => {
720
+ const s = store();
721
+ s.requestVhost('alice', 'one.example.net');
722
+ s.requestVhost('alice', 'two.example.net');
723
+ expect(s.getPendingVhost('alice')).toBe('two.example.net');
724
+ });
725
+
726
+ it('folds the account name case-insensitively on request and lookup', () => {
727
+ const s = store();
728
+ s.requestVhost('Alice', 'one.example.net');
729
+ expect(s.getPendingVhost('alice')).toBe('one.example.net');
730
+ expect(s.getPendingVhost('ALICE')).toBe('one.example.net');
731
+ });
732
+
733
+ it('listPendingVhosts returns an empty array when no requests are queued', () => {
734
+ const s = store();
735
+ expect(s.listPendingVhosts()).toEqual([]);
736
+ });
737
+
738
+ it('listPendingVhosts enumerates every queued request', () => {
739
+ const s = store();
740
+ s.requestVhost('alice', 'a.example.net');
741
+ s.requestVhost('bob', 'b.example.net');
742
+ const list = s.listPendingVhosts();
743
+ expect(list).toHaveLength(2);
744
+ expect(list).toContainEqual({ account: 'alice', vhost: 'a.example.net' });
745
+ expect(list).toContainEqual({ account: 'bob', vhost: 'b.example.net' });
746
+ });
747
+
748
+ it('approveVhost returns undefined when no pending request exists', () => {
749
+ const s = store();
750
+ expect(s.approveVhost('alice')).toBeUndefined();
751
+ });
752
+
753
+ it('approveVhost activates the pending vhost and returns the activated value', () => {
754
+ const s = store();
755
+ s.requestVhost('alice', 'cool.example.net');
756
+ expect(s.approveVhost('alice')).toBe('cool.example.net');
757
+ expect(s.getVhost('alice')).toBe('cool.example.net');
758
+ });
759
+
760
+ it('approveVhost removes the pending request after activation', () => {
761
+ const s = store();
762
+ s.requestVhost('alice', 'cool.example.net');
763
+ s.approveVhost('alice');
764
+ expect(s.getPendingVhost('alice')).toBeUndefined();
765
+ expect(s.listPendingVhosts()).toEqual([]);
766
+ });
767
+
768
+ it('rejectVhost returns undefined when no pending request exists', () => {
769
+ const s = store();
770
+ expect(s.rejectVhost('alice')).toBeUndefined();
771
+ });
772
+
773
+ it('rejectVhost drops the pending request and returns the rejected value', () => {
774
+ const s = store();
775
+ s.requestVhost('alice', 'cool.example.net');
776
+ expect(s.rejectVhost('alice')).toBe('cool.example.net');
777
+ expect(s.getPendingVhost('alice')).toBeUndefined();
778
+ });
779
+
780
+ it('rejectVhost does NOT activate the vhost', () => {
781
+ const s = store();
782
+ s.requestVhost('alice', 'cool.example.net');
783
+ s.rejectVhost('alice');
784
+ expect(s.getVhost('alice')).toBeUndefined();
785
+ });
786
+
787
+ it('rejectVhost removes the pending request', () => {
788
+ const s = store();
789
+ s.requestVhost('alice', 'cool.example.net');
790
+ s.rejectVhost('alice');
791
+ expect(s.listPendingVhosts()).toEqual([]);
792
+ });
793
+
794
+ it('folds the account name case-insensitively on approve', () => {
795
+ const s = store();
796
+ s.requestVhost('Alice', 'cool.example.net');
797
+ expect(s.approveVhost('ALICE')).toBe('cool.example.net');
798
+ expect(s.getVhost('alice')).toBe('cool.example.net');
799
+ });
800
+
801
+ it('folds the account name case-insensitively on reject', () => {
802
+ const s = store();
803
+ s.requestVhost('Alice', 'cool.example.net');
804
+ expect(s.rejectVhost('ALICE')).toBe('cool.example.net');
805
+ expect(s.getPendingVhost('alice')).toBeUndefined();
806
+ });
807
+ });
808
+
809
+ // ============================================================================
810
+ // MemoServ
811
+ // ============================================================================
812
+
813
+ const MEMO: MemoInput = {
814
+ from: 'bob',
815
+ to: 'alice',
816
+ body: 'Hi there',
817
+ sentAt: NOW,
818
+ };
819
+
820
+ describe('InMemoryServicesStore — MemoServ SEND (recordMemo)', () => {
821
+ it('returns a fresh monotonic id for each recorded memo', () => {
822
+ const s = store();
823
+ const id1 = s.recordMemo(MEMO);
824
+ const id2 = s.recordMemo({ ...MEMO, body: 'second' });
825
+ expect(id2).toBeGreaterThan(id1);
826
+ });
827
+
828
+ it('starts the id counter at 1', () => {
829
+ const s = store();
830
+ expect(s.recordMemo(MEMO)).toBe(1);
831
+ });
832
+
833
+ it('stores the memo with read:false observable via listMemos', () => {
834
+ const s = store();
835
+ s.recordMemo(MEMO);
836
+ const list = s.listMemos('alice');
837
+ expect(list).toHaveLength(1);
838
+ expect(list[0]).toEqual<MemoEntry>({
839
+ id: 1,
840
+ from: 'bob',
841
+ to: 'alice',
842
+ body: 'Hi there',
843
+ sentAt: NOW,
844
+ read: false,
845
+ });
846
+ });
847
+ });
848
+
849
+ describe('InMemoryServicesStore — MemoServ LIST (listMemos)', () => {
850
+ it('returns an empty array for an unknown recipient', () => {
851
+ const s = store();
852
+ expect(s.listMemos('alice')).toEqual([]);
853
+ });
854
+
855
+ it('returns only the memos addressed to the given recipient account', () => {
856
+ const s = store();
857
+ s.recordMemo({ from: 'bob', to: 'alice', body: 'for alice', sentAt: NOW });
858
+ s.recordMemo({ from: 'carol', to: 'dave', body: 'for dave', sentAt: NOW });
859
+ s.recordMemo({ from: 'eve', to: 'alice', body: 'also for alice', sentAt: NOW });
860
+ const list = s.listMemos('alice');
861
+ expect(list).toHaveLength(2);
862
+ expect(list.map((m) => m.body)).toEqual(['for alice', 'also for alice']);
863
+ });
864
+
865
+ it('folds the recipient account case-insensitively', () => {
866
+ const s = store();
867
+ s.recordMemo({ from: 'bob', to: 'Alice', body: 'x', sentAt: NOW });
868
+ expect(s.listMemos('alice')).toHaveLength(1);
869
+ expect(s.listMemos('ALICE')).toHaveLength(1);
870
+ });
871
+
872
+ it('returns a snapshot that does not mutate when the store updates', () => {
873
+ const s = store();
874
+ s.recordMemo(MEMO);
875
+ const snapshot = s.listMemos('alice');
876
+ s.recordMemo({ ...MEMO, body: 'second' });
877
+ expect(snapshot).toHaveLength(1);
878
+ });
879
+ });
880
+
881
+ describe('InMemoryServicesStore — MemoServ READ (markMemoRead)', () => {
882
+ it('returns true and marks the memo read for a known (recipient, id)', () => {
883
+ const s = store();
884
+ const id = s.recordMemo(MEMO);
885
+ expect(s.markMemoRead('alice', id)).toBe(true);
886
+ expect(s.listMemos('alice')[0]?.read).toBe(true);
887
+ });
888
+
889
+ it('returns false for an unknown memo id', () => {
890
+ const s = store();
891
+ s.recordMemo(MEMO);
892
+ expect(s.markMemoRead('alice', 999)).toBe(false);
893
+ });
894
+
895
+ it('returns false when the memo exists but belongs to another recipient', () => {
896
+ const s = store();
897
+ const id = s.recordMemo({ from: 'bob', to: 'alice', body: 'x', sentAt: NOW });
898
+ expect(s.markMemoRead('dave', id)).toBe(false);
899
+ expect(s.listMemos('alice')[0]?.read).toBe(false);
900
+ });
901
+
902
+ it('folds the recipient case-insensitively on a hit', () => {
903
+ const s = store();
904
+ const id = s.recordMemo({ from: 'bob', to: 'Alice', body: 'x', sentAt: NOW });
905
+ expect(s.markMemoRead('alice', id)).toBe(true);
906
+ });
907
+
908
+ it('is idempotent: re-marking a read memo returns true', () => {
909
+ const s = store();
910
+ const id = s.recordMemo(MEMO);
911
+ s.markMemoRead('alice', id);
912
+ expect(s.markMemoRead('alice', id)).toBe(true);
913
+ });
914
+ });
915
+
916
+ describe('InMemoryServicesStore — MemoServ DEL (deleteMemo)', () => {
917
+ it('returns true and removes the memo for a known (recipient, id)', () => {
918
+ const s = store();
919
+ const id = s.recordMemo(MEMO);
920
+ expect(s.deleteMemo('alice', id)).toBe(true);
921
+ expect(s.listMemos('alice')).toEqual([]);
922
+ });
923
+
924
+ it('returns false for an unknown memo id', () => {
925
+ const s = store();
926
+ s.recordMemo(MEMO);
927
+ expect(s.deleteMemo('alice', 999)).toBe(false);
928
+ });
929
+
930
+ it('returns false when the memo exists but belongs to another recipient', () => {
931
+ const s = store();
932
+ const id = s.recordMemo({ from: 'bob', to: 'alice', body: 'x', sentAt: NOW });
933
+ expect(s.deleteMemo('dave', id)).toBe(false);
934
+ expect(s.listMemos('alice')).toHaveLength(1);
935
+ });
936
+
937
+ it('only removes the targeted memo, leaving the rest intact', () => {
938
+ const s = store();
939
+ const id1 = s.recordMemo({ from: 'bob', to: 'alice', body: 'one', sentAt: NOW });
940
+ const id2 = s.recordMemo({ from: 'bob', to: 'alice', body: 'two', sentAt: NOW });
941
+ expect(s.deleteMemo('alice', id1)).toBe(true);
942
+ const remaining = s.listMemos('alice');
943
+ expect(remaining).toHaveLength(1);
944
+ expect(remaining[0]?.id).toBe(id2);
945
+ });
946
+
947
+ it('folds the recipient case-insensitively on a hit', () => {
948
+ const s = store();
949
+ const id = s.recordMemo({ from: 'bob', to: 'Alice', body: 'x', sentAt: NOW });
950
+ expect(s.deleteMemo('alice', id)).toBe(true);
951
+ expect(s.listMemos('alice')).toEqual([]);
952
+ });
953
+
954
+ it('is safe to call on an account with no memos', () => {
955
+ const s = store();
956
+ expect(s.deleteMemo('alice', 1)).toBe(false);
957
+ });
958
+ });
959
+
960
+ // ============================================================================
961
+ // Read-marker persistence (draft/read-marker)
962
+ // ============================================================================
963
+
964
+ describe('InMemoryServicesStore — read-marker setLastReadMarker + getLastReadMarker', () => {
965
+ it('returns undefined for an unknown (account, channel) pair', () => {
966
+ const s = store();
967
+ expect(s.getLastReadMarker('alice', '#foo')).toBeUndefined();
968
+ });
969
+
970
+ it('stores and returns the msgid for a known (account, channel) pair', () => {
971
+ const s = store();
972
+ s.setLastReadMarker('alice', '#foo', 'm1');
973
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m1');
974
+ });
975
+
976
+ it('overwrites the marker on a subsequent set for the same pair', () => {
977
+ const s = store();
978
+ s.setLastReadMarker('alice', '#foo', 'm1');
979
+ s.setLastReadMarker('alice', '#foo', 'm2');
980
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m2');
981
+ });
982
+
983
+ it('keys channels case-insensitively (rfc1459 fold)', () => {
984
+ const s = store();
985
+ s.setLastReadMarker('alice', '#Foo', 'm1');
986
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m1');
987
+ expect(s.getLastReadMarker('alice', '#FOO')).toBe('m1');
988
+ });
989
+
990
+ it('keys accounts case-insensitively (rfc1459 fold)', () => {
991
+ const s = store();
992
+ s.setLastReadMarker('Alice', '#foo', 'm1');
993
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m1');
994
+ expect(s.getLastReadMarker('ALICE', '#foo')).toBe('m1');
995
+ });
996
+
997
+ it('keeps markers for distinct accounts on the same channel separate', () => {
998
+ const s = store();
999
+ s.setLastReadMarker('alice', '#foo', 'm1');
1000
+ s.setLastReadMarker('bob', '#foo', 'm9');
1001
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m1');
1002
+ expect(s.getLastReadMarker('bob', '#foo')).toBe('m9');
1003
+ });
1004
+
1005
+ it('keeps markers for distinct channels on the same account separate', () => {
1006
+ const s = store();
1007
+ s.setLastReadMarker('alice', '#foo', 'm1');
1008
+ s.setLastReadMarker('alice', '#bar', 'm2');
1009
+ expect(s.getLastReadMarker('alice', '#foo')).toBe('m1');
1010
+ expect(s.getLastReadMarker('alice', '#bar')).toBe('m2');
1011
+ });
1012
+ });
1013
+
1014
+ describe('InMemoryServicesStore — read-marker listReadMarkers', () => {
1015
+ it('returns an empty array for an account with no markers', () => {
1016
+ const s = store();
1017
+ expect(s.listReadMarkers('alice')).toEqual([]);
1018
+ });
1019
+
1020
+ it('returns every (channel, msgid) pair recorded for the account', () => {
1021
+ const s = store();
1022
+ s.setLastReadMarker('alice', '#foo', 'm1');
1023
+ s.setLastReadMarker('alice', '#bar', 'm2');
1024
+
1025
+ const entries = s.listReadMarkers('alice');
1026
+ expect(entries).toHaveLength(2);
1027
+ const byChan = Object.fromEntries(entries.map((e) => [e.channel, e.msgid]));
1028
+ expect(byChan['#foo']).toBe('m1');
1029
+ expect(byChan['#bar']).toBe('m2');
1030
+ });
1031
+
1032
+ it('reports the account name on each entry', () => {
1033
+ const s = store();
1034
+ s.setLastReadMarker('alice', '#foo', 'm1');
1035
+ const entries = s.listReadMarkers('alice');
1036
+ expect(entries[0]?.account).toBe('alice');
1037
+ });
1038
+
1039
+ it('reflects the most recent set when a pair is overwritten', () => {
1040
+ const s = store();
1041
+ s.setLastReadMarker('alice', '#foo', 'm1');
1042
+ s.setLastReadMarker('alice', '#foo', 'm2');
1043
+ const entries = s.listReadMarkers('alice');
1044
+ expect(entries).toHaveLength(1);
1045
+ expect(entries[0]?.msgid).toBe('m2');
1046
+ });
1047
+
1048
+ it('does not leak markers belonging to a different account', () => {
1049
+ const s = store();
1050
+ s.setLastReadMarker('alice', '#foo', 'm1');
1051
+ s.setLastReadMarker('bob', '#bar', 'm2');
1052
+ expect(s.listReadMarkers('alice')).toHaveLength(1);
1053
+ expect(s.listReadMarkers('bob')).toHaveLength(1);
1054
+ });
1055
+
1056
+ it('folds the account name case-insensitively on lookup', () => {
1057
+ const s = store();
1058
+ s.setLastReadMarker('Alice', '#foo', 'm1');
1059
+ const entries = s.listReadMarkers('ALICE') as ReadonlyArray<ReadMarkerEntry>;
1060
+ expect(entries).toHaveLength(1);
1061
+ });
1062
+ });
1063
+
1064
+ // ============================================================================
1065
+ // OperServ — AKILL (network-wide ban)
1066
+ // ============================================================================
1067
+
1068
+ describe('InMemoryServicesStore — OperServ AKILL addAkill + listAkills', () => {
1069
+ it('returns an empty list before any AKILL is added', () => {
1070
+ const s = store();
1071
+ expect(s.listAkills()).toEqual([]);
1072
+ });
1073
+
1074
+ it('records an AKILL with the supplied mask and reason', () => {
1075
+ const s = store();
1076
+ s.addAkill('*!*@bad.example.net', 'flooding');
1077
+ const entries = s.listAkills();
1078
+ expect(entries).toHaveLength(1);
1079
+ expect(entries[0]).toMatchObject({ mask: '*!*@bad.example.net', reason: 'flooding' });
1080
+ });
1081
+
1082
+ it('stamps a createdAt timestamp from the injected clock', () => {
1083
+ const s = store();
1084
+ s.addAkill('*!*@bad.example.net', 'flooding');
1085
+ expect(s.listAkills()[0]?.createdAt).toBe(NOW);
1086
+ });
1087
+
1088
+ it('replaces the reason when the same mask is added twice', () => {
1089
+ const s = store();
1090
+ s.addAkill('*!*@bad.example.net', 'flooding');
1091
+ s.addAkill('*!*@bad.example.net', 'worse flooding');
1092
+ const entries = s.listAkills();
1093
+ expect(entries).toHaveLength(1);
1094
+ expect(entries[0]?.reason).toBe('worse flooding');
1095
+ });
1096
+
1097
+ it('preserves insertion order across multiple distinct masks', () => {
1098
+ const s = store();
1099
+ s.addAkill('*!*@a.example', 'one');
1100
+ s.addAkill('*!*@b.example', 'two');
1101
+ s.addAkill('*!*@c.example', 'three');
1102
+ const masks = s.listAkills().map((e) => e.mask);
1103
+ expect(masks).toEqual(['*!*@a.example', '*!*@b.example', '*!*@c.example']);
1104
+ });
1105
+
1106
+ it('accepts an empty reason (verifies the no-default-reason contract)', () => {
1107
+ const s = store();
1108
+ s.addAkill('*!*@quiet.example', '');
1109
+ expect(s.listAkills()[0]?.reason).toBe('');
1110
+ });
1111
+ });
1112
+
1113
+ describe('InMemoryServicesStore — OperServ AKILL removeAkill', () => {
1114
+ it('returns true and drops the entry on a known mask', () => {
1115
+ const s = store();
1116
+ s.addAkill('*!*@bad.example.net', 'flooding');
1117
+ expect(s.removeAkill('*!*@bad.example.net')).toBe(true);
1118
+ expect(s.listAkills()).toEqual([]);
1119
+ });
1120
+
1121
+ it('matches the mask case-insensitively (rfc1459 fold)', () => {
1122
+ const s = store();
1123
+ s.addAkill('*!*@Bad.Example.Net', 'flooding');
1124
+ expect(s.removeAkill('*!*@bad.example.net')).toBe(true);
1125
+ });
1126
+
1127
+ it('returns false for an unknown mask without mutating state', () => {
1128
+ const s = store();
1129
+ s.addAkill('*!*@bad.example.net', 'flooding');
1130
+ expect(s.removeAkill('*!*@other.example.net')).toBe(false);
1131
+ expect(s.listAkills()).toHaveLength(1);
1132
+ });
1133
+
1134
+ it('returns false on an empty store', () => {
1135
+ const s = store();
1136
+ expect(s.removeAkill('*!*@anywhere.example')).toBe(false);
1137
+ });
1138
+ });
1139
+
1140
+ describe('InMemoryServicesStore — OperServ AKILL matchAkill', () => {
1141
+ it('returns undefined when no AKILL is recorded', () => {
1142
+ const s = store();
1143
+ expect(s.matchAkill('evil!spammer@bad.example.net')).toBeUndefined();
1144
+ });
1145
+
1146
+ it('returns the first matching entry for a hostmask that fits the mask', () => {
1147
+ const s = store();
1148
+ s.addAkill('*!*@bad.example.net', 'flooding');
1149
+ const hit = s.matchAkill('evil!spammer@bad.example.net');
1150
+ expect(hit).toBeDefined();
1151
+ expect(hit?.mask).toBe('*!*@bad.example.net');
1152
+ expect(hit?.reason).toBe('flooding');
1153
+ });
1154
+
1155
+ it('returns undefined when no mask matches the hostmask', () => {
1156
+ const s = store();
1157
+ s.addAkill('*!*@bad.example.net', 'flooding');
1158
+ expect(s.matchAkill('alice!alice@good.example.net')).toBeUndefined();
1159
+ });
1160
+
1161
+ it('returns the first matching entry in insertion order', () => {
1162
+ const s = store();
1163
+ s.addAkill('*!*@a.example', 'first');
1164
+ s.addAkill('*!*@b.example', 'second');
1165
+ const hit = s.matchAkill('evil!spam@a.example');
1166
+ expect(hit?.reason).toBe('first');
1167
+ });
1168
+
1169
+ it('honours wildcards in the nick fragment of the mask', () => {
1170
+ const s = store();
1171
+ s.addAkill('bot*!*@*', 'bot flood');
1172
+ expect(s.matchAkill('botnet!x@y.example')?.reason).toBe('bot flood');
1173
+ expect(s.matchAkill('alice!x@y.example')).toBeUndefined();
1174
+ });
1175
+
1176
+ it('honours ? wildcard as a single-char matcher', () => {
1177
+ const s = store();
1178
+ s.addAkill('a?c!*@*', 'mid match');
1179
+ expect(s.matchAkill('abc!x@y.example')?.reason).toBe('mid match');
1180
+ });
1181
+
1182
+ it('returns a defensive copy so callers cannot mutate the stored record', () => {
1183
+ const s = store();
1184
+ s.addAkill('*!*@bad.example.net', 'flooding');
1185
+ const hit = s.matchAkill('evil!spammer@bad.example.net');
1186
+ if (hit === undefined) return;
1187
+ hit.reason = 'mutated';
1188
+ expect(s.listAkills()[0]?.reason).toBe('flooding');
1189
+ });
1190
+ });
1191
+
1192
+ // ============================================================================
1193
+ // OperServ — JUPE (blocked nick/server)
1194
+ // ============================================================================
1195
+
1196
+ describe('InMemoryServicesStore — OperServ JUPE addJupe + isJuped', () => {
1197
+ it('reports not-juped on a fresh store', () => {
1198
+ const s = store();
1199
+ expect(s.isJuped('moo')).toBe(false);
1200
+ });
1201
+
1202
+ it('records a jupe and reports it as juped', () => {
1203
+ const s = store();
1204
+ s.addJupe('moo', 'blocked');
1205
+ expect(s.isJuped('moo')).toBe(true);
1206
+ });
1207
+
1208
+ it('matches the juped name case-insensitively (rfc1459 fold)', () => {
1209
+ const s = store();
1210
+ s.addJupe('Moo', 'blocked');
1211
+ expect(s.isJuped('moo')).toBe(true);
1212
+ expect(s.isJuped('MOO')).toBe(true);
1213
+ });
1214
+
1215
+ it('accepts an undefined reason (records an empty string)', () => {
1216
+ const s = store();
1217
+ s.addJupe('moo');
1218
+ expect(s.getJupe('moo')?.reason).toBe('');
1219
+ });
1220
+
1221
+ it('clears the reason when an existing jupe is re-added without a reason', () => {
1222
+ const s = store();
1223
+ s.addJupe('moo', 'blocked');
1224
+ s.addJupe('moo');
1225
+ expect(s.getJupe('moo')?.reason).toBe('');
1226
+ });
1227
+ });
1228
+
1229
+ describe('InMemoryServicesStore — OperServ JUPE removeJupe', () => {
1230
+ it('returns true and clears the jupe on a known name', () => {
1231
+ const s = store();
1232
+ s.addJupe('moo', 'blocked');
1233
+ expect(s.removeJupe('moo')).toBe(true);
1234
+ expect(s.isJuped('moo')).toBe(false);
1235
+ });
1236
+
1237
+ it('matches the name case-insensitively (rfc1459 fold)', () => {
1238
+ const s = store();
1239
+ s.addJupe('Moo', 'blocked');
1240
+ expect(s.removeJupe('moo')).toBe(true);
1241
+ });
1242
+
1243
+ it('returns false for an unknown name without mutating state', () => {
1244
+ const s = store();
1245
+ s.addJupe('moo', 'blocked');
1246
+ expect(s.removeJupe('other')).toBe(false);
1247
+ expect(s.isJuped('moo')).toBe(true);
1248
+ });
1249
+ });
1250
+
1251
+ describe('InMemoryServicesStore — OperServ JUPE getJupe', () => {
1252
+ it('returns undefined for a name with no jupe', () => {
1253
+ const s = store();
1254
+ expect(s.getJupe('moo')).toBeUndefined();
1255
+ });
1256
+
1257
+ it('returns the recorded reason and createdAt timestamp', () => {
1258
+ const s = store();
1259
+ s.addJupe('moo', 'blocked');
1260
+ const entry = s.getJupe('moo');
1261
+ expect(entry).toBeDefined();
1262
+ expect(entry?.reason).toBe('blocked');
1263
+ expect(entry?.createdAt).toBe(NOW);
1264
+ });
1265
+
1266
+ it('returns a defensive copy so callers cannot mutate the stored record', () => {
1267
+ const s = store();
1268
+ s.addJupe('moo', 'blocked');
1269
+ const entry = s.getJupe('moo');
1270
+ if (entry === undefined) return;
1271
+ entry.reason = 'mutated';
1272
+ expect(s.getJupe('moo')?.reason).toBe('blocked');
1273
+ });
1274
+ });
1275
+
1276
+ describe('InMemoryServicesStore — OperServ JUPE listJupes', () => {
1277
+ it('returns an empty array on a fresh store', () => {
1278
+ const s = store();
1279
+ expect(s.listJupes()).toEqual([]);
1280
+ });
1281
+
1282
+ it('enumerates every recorded jupe in insertion order', () => {
1283
+ const s = store();
1284
+ s.addJupe('moo', 'one');
1285
+ s.addJupe('bar', 'two');
1286
+ const names = (s.listJupes() as ReadonlyArray<JupeEntry>).map((e) => e.name);
1287
+ expect(names).toEqual(['moo', 'bar']);
1288
+ });
1289
+ });