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.
- package/.github/workflows/ci.yml +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -528,3 +528,66 @@ describe('InMemoryMessageStore — query defensive branches', () => {
|
|
|
528
528
|
).toEqual([]);
|
|
529
529
|
});
|
|
530
530
|
});
|
|
531
|
+
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// findAtOrBefore / getMsg — timestamp<->msgid translation for draft/read-marker
|
|
534
|
+
// MARKREAD support. findAtOrBefore resolves a client-supplied server-time
|
|
535
|
+
// timestamp to the most recent retained message at or before it; getMsg
|
|
536
|
+
// resolves a stored marker msgid back to its message (for the echo + the
|
|
537
|
+
// monotonicity check).
|
|
538
|
+
// ============================================================================
|
|
539
|
+
describe('InMemoryMessageStore — findAtOrBefore (timestamp -> message)', () => {
|
|
540
|
+
it('returns the message whose time equals the timestamp exactly', () => {
|
|
541
|
+
const store = seededStore(); // m1..m5 at 1_000..5_000
|
|
542
|
+
expect(store.findAtOrBefore('#foo', 3_000)?.msgid).toBe('m3');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('returns the most recent message at or before an in-between timestamp', () => {
|
|
546
|
+
const store = seededStore();
|
|
547
|
+
// 3_500 is between m3 (3_000) and m4 (4_000); the marker resolves to m3.
|
|
548
|
+
expect(store.findAtOrBefore('#foo', 3_500)?.msgid).toBe('m3');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('returns the newest message when the timestamp is at/after the last', () => {
|
|
552
|
+
const store = seededStore();
|
|
553
|
+
expect(store.findAtOrBefore('#foo', 9_999)?.msgid).toBe('m5');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('returns undefined when the timestamp predates every retained message', () => {
|
|
557
|
+
const store = seededStore();
|
|
558
|
+
expect(store.findAtOrBefore('#foo', 500)).toBeUndefined();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('returns undefined for a channel with no retained messages', () => {
|
|
562
|
+
const store = new InMemoryMessageStore();
|
|
563
|
+
expect(store.findAtOrBefore('#ghost', 1_000)).toBeUndefined();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('folds the channel name case-insensitively', () => {
|
|
567
|
+
const store = seededStore();
|
|
568
|
+
expect(store.findAtOrBefore('#FOO', 2_000)?.msgid).toBe('m2');
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
|
|
573
|
+
it('returns the message for a retained msgid', () => {
|
|
574
|
+
const store = seededStore();
|
|
575
|
+
expect(store.getMsg('#foo', 'm3')?.text).toBe('three');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('returns undefined for an unknown msgid', () => {
|
|
579
|
+
const store = seededStore();
|
|
580
|
+
expect(store.getMsg('#foo', 'ghost')).toBeUndefined();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('returns undefined for an evicted (no-longer-retained) msgid', () => {
|
|
584
|
+
const store = seededStore(3); // keeps m3..m5, evicts m1..m2
|
|
585
|
+
expect(store.getMsg('#foo', 'm1')).toBeUndefined();
|
|
586
|
+
expect(store.getMsg('#foo', 'm4')?.msgid).toBe('m4');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('folds the channel name case-insensitively', () => {
|
|
590
|
+
const store = seededStore();
|
|
591
|
+
expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
|
|
592
|
+
});
|
|
593
|
+
});
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PersistentServicesStore` — the shared write-behind base class used by the
|
|
3
|
+
* D1 (Cloudflare) and DynamoDB (AWS) backends.
|
|
4
|
+
*
|
|
5
|
+
* The services port is synchronous (reducers stay free of IO); persistent
|
|
6
|
+
* adapters pre-load a snapshot into the in-memory cache and enqueue
|
|
7
|
+
* write-behind ops on every mutation. This suite verifies the enqueue /
|
|
8
|
+
* flush contract and the snapshot hydration round-trip using a recording
|
|
9
|
+
* fake backend — no real database is touched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { hashAccountCredential } from '../src/credential-hashing';
|
|
14
|
+
import {
|
|
15
|
+
FakeClock,
|
|
16
|
+
InMemoryServicesStore,
|
|
17
|
+
type MemoInput,
|
|
18
|
+
PersistentServicesStore,
|
|
19
|
+
type ServicesNickRow,
|
|
20
|
+
type ServicesSnapshot,
|
|
21
|
+
type ServicesWrite,
|
|
22
|
+
} from '../src/ports';
|
|
23
|
+
|
|
24
|
+
const NOW = 1_700_000_000_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Concrete recording subclass: collects every flushed batch in order so the
|
|
28
|
+
* tests can assert which ops each mutator produced.
|
|
29
|
+
*/
|
|
30
|
+
class RecordingStore extends PersistentServicesStore {
|
|
31
|
+
readonly batches: ServicesWrite[][] = [];
|
|
32
|
+
protected async applyWrites(writes: ServicesWrite[]): Promise<void> {
|
|
33
|
+
this.batches.push(writes);
|
|
34
|
+
}
|
|
35
|
+
/** Test-only exposure of the protected helper so its `undefined` branch is covered. */
|
|
36
|
+
peekStoredNick(nick: string): ServicesNickRow | undefined {
|
|
37
|
+
return this.getStoredNick(nick);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function recordingStore(): RecordingStore {
|
|
42
|
+
return new RecordingStore({ clock: new FakeClock(NOW) });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Snapshot hydration
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
describe('PersistentServicesStore — snapshot hydration', () => {
|
|
50
|
+
it('hydrates from an empty snapshot into an empty store', () => {
|
|
51
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: emptySnapshot() });
|
|
52
|
+
expect(s.listAkills()).toEqual([]);
|
|
53
|
+
expect(s.listMemos('alice')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('round-trips a full snapshot through a peer InMemoryServicesStore', () => {
|
|
57
|
+
const original = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
58
|
+
original.registerNick('alice', 'pw', 'a@example.com');
|
|
59
|
+
original.setNickEnforce('alice', 'ghost');
|
|
60
|
+
original.registerChannel('#chan', 'alice');
|
|
61
|
+
original.setChannelAccess('#chan', 'bob', 5);
|
|
62
|
+
original.setChannelLevel('#chan', 'AUTOOP', 10);
|
|
63
|
+
original.setVhost('alice', 'hidden.example');
|
|
64
|
+
original.requestVhost('bob', 'pending.example');
|
|
65
|
+
original.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
|
|
66
|
+
original.setLastReadMarker('alice', '#chan', 'msgid-1');
|
|
67
|
+
original.addAkill('*!*@bad.example', 'flooding');
|
|
68
|
+
original.addJupe('jupedNick', 'reason');
|
|
69
|
+
|
|
70
|
+
const snap = original.snapshot();
|
|
71
|
+
const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
72
|
+
|
|
73
|
+
// NickServ
|
|
74
|
+
expect(reloaded.isRegisteredNick('Alice')).toBe(true);
|
|
75
|
+
expect(reloaded.getNickEnforce('alice')).toBe('ghost');
|
|
76
|
+
expect(reloaded.verifyNick('alice', 'pw')).toEqual({ ok: true, account: 'alice' });
|
|
77
|
+
// ChanServ
|
|
78
|
+
expect(reloaded.getChannelFounder('#chan')).toBe('alice');
|
|
79
|
+
expect(reloaded.getChannelAccess('#chan', 'bob')).toBe(5);
|
|
80
|
+
expect(reloaded.getChannelLevel('#chan', 'AUTOOP')).toBe(10);
|
|
81
|
+
// HostServ
|
|
82
|
+
expect(reloaded.getVhost('alice')).toBe('hidden.example');
|
|
83
|
+
expect(reloaded.getPendingVhost('bob')).toBe('pending.example');
|
|
84
|
+
// MemoServ — id preserved across reload
|
|
85
|
+
const memos = reloaded.listMemos('alice');
|
|
86
|
+
expect(memos).toHaveLength(1);
|
|
87
|
+
expect(memos[0]).toMatchObject({ id: 1, from: 'bob', body: 'hi' });
|
|
88
|
+
// Next memo id continues after the reloaded max.
|
|
89
|
+
expect(reloaded.recordMemo({ from: 'x', to: 'alice', body: 'y', sentAt: NOW })).toBe(2);
|
|
90
|
+
// Read marker
|
|
91
|
+
expect(reloaded.getLastReadMarker('alice', '#chan')).toBe('msgid-1');
|
|
92
|
+
// OperServ
|
|
93
|
+
expect(reloaded.matchAkill('user!~u@bad.example')?.reason).toBe('flooding');
|
|
94
|
+
expect(reloaded.isJuped('JupedNick')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('snapshot omits topic for a channel that never had KEEPTOPIC set', () => {
|
|
98
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
99
|
+
s.registerChannel('#notopic', 'alice');
|
|
100
|
+
const snap = s.snapshot();
|
|
101
|
+
expect(snap.channels).toHaveLength(1);
|
|
102
|
+
expect(snap.channels[0]?.topic).toBeUndefined();
|
|
103
|
+
// Hydrating a no-topic channel round-trips (covers the hydrate false branch).
|
|
104
|
+
const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
105
|
+
expect(reloaded.getChannel('#notopic')?.topic).toBeUndefined();
|
|
106
|
+
expect(reloaded.getChannelFounder('#notopic')).toBe('alice');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('hydrate reuses existing maps for multi-entry groups + round-trips a topic', () => {
|
|
110
|
+
// A snapshot with multiple entries per channel / account exercises every
|
|
111
|
+
// "map already exists, reuse it" branch in hydrate (access, levels,
|
|
112
|
+
// memos, readMarkers) plus the topic-present branch in snapshot+hydrate.
|
|
113
|
+
const original = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
114
|
+
original.registerChannel('#chan', 'alice');
|
|
115
|
+
original.setChannelKeepTopic('#chan', true);
|
|
116
|
+
original.setChannelTopic('#chan', { text: 't', setter: 'alice', setAt: NOW });
|
|
117
|
+
original.setChannelAccess('#chan', 'bob', 5);
|
|
118
|
+
original.setChannelAccess('#chan', 'carol', 3); // second entry → reuse map
|
|
119
|
+
original.setChannelLevel('#chan', 'AUTOOP', 10);
|
|
120
|
+
original.setChannelLevel('#chan', 'AUTOVOICE', 1); // second entry → reuse map
|
|
121
|
+
original.recordMemo({ from: 'x', to: 'alice', body: 'm1', sentAt: NOW });
|
|
122
|
+
original.recordMemo({ from: 'y', to: 'alice', body: 'm2', sentAt: NOW }); // second → reuse arr
|
|
123
|
+
original.setLastReadMarker('alice', '#chan', 'a');
|
|
124
|
+
original.setLastReadMarker('alice', '#other', 'b'); // second → reuse chanMap
|
|
125
|
+
|
|
126
|
+
const snap = original.snapshot();
|
|
127
|
+
const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
128
|
+
expect(reloaded.getChannelTopic('#chan')?.text).toBe('t');
|
|
129
|
+
expect(reloaded.getChannelAccess('#chan', 'carol')).toBe(3);
|
|
130
|
+
expect(reloaded.getChannelLevel('#chan', 'AUTOVOICE')).toBe(1);
|
|
131
|
+
expect(reloaded.listMemos('alice')).toHaveLength(2);
|
|
132
|
+
expect(reloaded.getLastReadMarker('alice', '#other')).toBe('b');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('hydrate handles non-monotonic memo ids (counter max is idempotent)', () => {
|
|
136
|
+
// A snapshot whose memos are out of order exercises the
|
|
137
|
+
// `memo.id > memoCounter === false` branch in hydrate.
|
|
138
|
+
const snap: ServicesSnapshot = {
|
|
139
|
+
nicks: [],
|
|
140
|
+
channels: [],
|
|
141
|
+
access: [],
|
|
142
|
+
levels: [],
|
|
143
|
+
vhosts: [],
|
|
144
|
+
pendingVhosts: [],
|
|
145
|
+
memos: [
|
|
146
|
+
{ id: 5, from: 'x', to: 'alice', body: 'big', sentAt: NOW, read: false },
|
|
147
|
+
{ id: 2, from: 'y', to: 'alice', body: 'small', sentAt: NOW, read: false },
|
|
148
|
+
],
|
|
149
|
+
readMarkers: [],
|
|
150
|
+
akills: [],
|
|
151
|
+
jupes: [],
|
|
152
|
+
};
|
|
153
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
154
|
+
expect(s.listMemos('alice')).toHaveLength(2);
|
|
155
|
+
// Counter resumes after the max id (5), not the last-seen.
|
|
156
|
+
expect(s.recordMemo({ from: 'z', to: 'alice', body: 'next', sentAt: NOW })).toBe(6);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('getStoredNick returns undefined for an unregistered nick', () => {
|
|
160
|
+
const s = recordingStore();
|
|
161
|
+
expect(s.peekStoredNick('ghost')).toBeUndefined();
|
|
162
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
163
|
+
// The credential is hashed scrypt; the plaintext password is not stored.
|
|
164
|
+
const stored = s.peekStoredNick('alice');
|
|
165
|
+
expect(stored).toBeDefined();
|
|
166
|
+
expect(stored?.credential.algorithm).toBe('scrypt');
|
|
167
|
+
expect(stored?.credential.account).toBe('alice');
|
|
168
|
+
expect((stored as unknown as Record<string, unknown>).password).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Write-behind enqueue / flush
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
describe('PersistentServicesStore — enqueue + flush', () => {
|
|
177
|
+
it('flush is a no-op when no mutations occurred', async () => {
|
|
178
|
+
const s = recordingStore();
|
|
179
|
+
await s.flush();
|
|
180
|
+
expect(s.batches).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('enqueues a single batch covering many mutations and drains it on flush', async () => {
|
|
184
|
+
const s = recordingStore();
|
|
185
|
+
s.registerNick('alice', 'pw', 'a@example.com');
|
|
186
|
+
s.registerChannel('#chan', 'alice');
|
|
187
|
+
s.addAkill('*!*@bad', 'reason');
|
|
188
|
+
await s.flush();
|
|
189
|
+
expect(s.batches).toHaveLength(1);
|
|
190
|
+
const ops = s.batches[0]?.map((w) => w.kind) ?? [];
|
|
191
|
+
expect(ops).toContain('upsertNick');
|
|
192
|
+
expect(ops).toContain('upsertChannel');
|
|
193
|
+
expect(ops).toContain('upsertAkill');
|
|
194
|
+
// Queue is drained after flush.
|
|
195
|
+
await s.flush();
|
|
196
|
+
expect(s.batches).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('coalesces concurrent flush calls into a single applyWrites invocation', async () => {
|
|
200
|
+
const s = recordingStore();
|
|
201
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
202
|
+
const [a, b] = await Promise.all([s.flush(), s.flush()]);
|
|
203
|
+
expect(a).toBe(b);
|
|
204
|
+
expect(s.batches).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ============================================================================
|
|
209
|
+
// Per-mutator op coverage
|
|
210
|
+
// ============================================================================
|
|
211
|
+
|
|
212
|
+
describe('PersistentServicesStore — per-mutator ops', () => {
|
|
213
|
+
function ops(s: RecordingStore): ServicesWrite[] {
|
|
214
|
+
return s.batches.flat();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('registerNick → upsertNick (with hashed credential + createdAt)', async () => {
|
|
218
|
+
const s = recordingStore();
|
|
219
|
+
s.registerNick('alice', 'hunter2', 'a@x');
|
|
220
|
+
await s.flush();
|
|
221
|
+
const op = ops(s).find((w) => w.kind === 'upsertNick');
|
|
222
|
+
expect(op).toBeDefined();
|
|
223
|
+
if (op?.kind === 'upsertNick') {
|
|
224
|
+
expect(op.row).toMatchObject({ nick: 'alice', email: 'a@x' });
|
|
225
|
+
expect(op.row.createdAt).toBe(NOW);
|
|
226
|
+
// The credential is scrypt-hashed; the plaintext is not on the row.
|
|
227
|
+
expect(op.row.credential.algorithm).toBe('scrypt');
|
|
228
|
+
expect(op.row.credential.account).toBe('alice');
|
|
229
|
+
expect((op.row as unknown as Record<string, unknown>).password).toBeUndefined();
|
|
230
|
+
expect(JSON.stringify(op.row)).not.toContain('hunter2');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('a duplicate registerNick enqueues nothing', async () => {
|
|
235
|
+
const s = recordingStore();
|
|
236
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
237
|
+
s.registerNick('alice', 'pw', 'a@x'); // rejected
|
|
238
|
+
await s.flush();
|
|
239
|
+
expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('dropNick → deleteNick only when something was removed', async () => {
|
|
243
|
+
const s = recordingStore();
|
|
244
|
+
s.dropNick('ghost'); // nothing to remove
|
|
245
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
246
|
+
s.dropNick('alice');
|
|
247
|
+
await s.flush();
|
|
248
|
+
expect(ops(s).some((w) => w.kind === 'deleteNick')).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('setNickEnforce → upsertNick (full row re-persisted)', async () => {
|
|
252
|
+
const s = recordingStore();
|
|
253
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
254
|
+
s.setNickEnforce('alice', 'kill');
|
|
255
|
+
await s.flush();
|
|
256
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
257
|
+
const last = upserts[upserts.length - 1];
|
|
258
|
+
if (last?.kind === 'upsertNick') expect(last.row.enforce).toBe('kill');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('setNickEnforce on an unregistered nick enqueues nothing', async () => {
|
|
262
|
+
const s = recordingStore();
|
|
263
|
+
s.setNickEnforce('ghost', 'kill');
|
|
264
|
+
await s.flush();
|
|
265
|
+
expect(s.batches).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('ingestAccountCredential enqueues upsertNick when a new credential is adopted', async () => {
|
|
269
|
+
const s = recordingStore();
|
|
270
|
+
const cred = hashAccountCredential('alice', 'hunter2');
|
|
271
|
+
s.ingestAccountCredential(cred);
|
|
272
|
+
await s.flush();
|
|
273
|
+
expect(ops(s).some((w) => w.kind === 'upsertNick')).toBe(true);
|
|
274
|
+
const op = ops(s).find((w) => w.kind === 'upsertNick');
|
|
275
|
+
if (op?.kind === 'upsertNick') {
|
|
276
|
+
expect(op.row.account).toBe('alice');
|
|
277
|
+
expect(op.row.credential.algorithm).toBe('scrypt');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('ingestAccountCredential enqueues nothing when the nick is already registered', async () => {
|
|
282
|
+
const s = recordingStore();
|
|
283
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
284
|
+
const cred = hashAccountCredential('alice', 'otherpw');
|
|
285
|
+
s.ingestAccountCredential(cred);
|
|
286
|
+
await s.flush();
|
|
287
|
+
expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('registerChannel → upsertChannel; duplicate enqueues once', async () => {
|
|
291
|
+
const s = recordingStore();
|
|
292
|
+
s.registerChannel('#chan', 'alice');
|
|
293
|
+
s.registerChannel('#chan', 'alice');
|
|
294
|
+
await s.flush();
|
|
295
|
+
expect(ops(s).filter((w) => w.kind === 'upsertChannel')).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('dropChannel → deleteChannel (clears access + levels remotely)', async () => {
|
|
299
|
+
const s = recordingStore();
|
|
300
|
+
s.registerChannel('#chan', 'alice');
|
|
301
|
+
s.setChannelAccess('#chan', 'bob', 5);
|
|
302
|
+
s.setChannelLevel('#chan', 'AUTOOP', 3);
|
|
303
|
+
s.dropChannel('#chan');
|
|
304
|
+
await s.flush();
|
|
305
|
+
expect(ops(s).some((w) => w.kind === 'deleteChannel')).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('dropChannel on an unregistered channel enqueues nothing', async () => {
|
|
309
|
+
const s = recordingStore();
|
|
310
|
+
s.dropChannel('#ghost');
|
|
311
|
+
await s.flush();
|
|
312
|
+
expect(s.batches).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it.each([
|
|
316
|
+
'setChannelFounder',
|
|
317
|
+
'setChannelMlock',
|
|
318
|
+
'setChannelRestricted',
|
|
319
|
+
'setChannelKeepTopic',
|
|
320
|
+
] as const)('%s → upsertChannel', async (method) => {
|
|
321
|
+
const s = recordingStore();
|
|
322
|
+
s.registerChannel('#chan', 'alice');
|
|
323
|
+
switch (method) {
|
|
324
|
+
case 'setChannelFounder':
|
|
325
|
+
s.setChannelFounder('#chan', 'bob');
|
|
326
|
+
break;
|
|
327
|
+
case 'setChannelMlock':
|
|
328
|
+
s.setChannelMlock('#chan', '+nrt');
|
|
329
|
+
break;
|
|
330
|
+
case 'setChannelRestricted':
|
|
331
|
+
s.setChannelRestricted('#chan', true);
|
|
332
|
+
break;
|
|
333
|
+
case 'setChannelKeepTopic':
|
|
334
|
+
s.setChannelKeepTopic('#chan', true);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
await s.flush();
|
|
338
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertChannel');
|
|
339
|
+
expect(upserts.length).toBeGreaterThanOrEqual(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('setChannelAccess level>0 → upsertChannelAccess; level<=0 → deleteChannelAccess', async () => {
|
|
343
|
+
const s = recordingStore();
|
|
344
|
+
s.registerChannel('#chan', 'alice');
|
|
345
|
+
s.setChannelAccess('#chan', 'bob', 5);
|
|
346
|
+
s.setChannelAccess('#chan', 'bob', 0);
|
|
347
|
+
await s.flush();
|
|
348
|
+
const all = ops(s);
|
|
349
|
+
expect(all.some((w) => w.kind === 'upsertChannelAccess')).toBe(true);
|
|
350
|
+
expect(all.some((w) => w.kind === 'deleteChannelAccess')).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('setChannelLevel → upsertChannelLevel; resetChannelLevels → resetChannelLevels', async () => {
|
|
354
|
+
const s = recordingStore();
|
|
355
|
+
s.registerChannel('#chan', 'alice');
|
|
356
|
+
s.setChannelLevel('#chan', 'AUTOOP', 7);
|
|
357
|
+
s.resetChannelLevels('#chan');
|
|
358
|
+
await s.flush();
|
|
359
|
+
expect(ops(s).some((w) => w.kind === 'upsertChannelLevel')).toBe(true);
|
|
360
|
+
expect(ops(s).some((w) => w.kind === 'resetChannelLevels')).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('setChannelTopic → upsertChannel (carrying the topic snapshot)', async () => {
|
|
364
|
+
const s = recordingStore();
|
|
365
|
+
s.registerChannel('#chan', 'alice');
|
|
366
|
+
s.setChannelKeepTopic('#chan', true);
|
|
367
|
+
s.setChannelTopic('#chan', { text: 'hello', setter: 'alice', setAt: NOW });
|
|
368
|
+
await s.flush();
|
|
369
|
+
const last = ops(s)
|
|
370
|
+
.filter((w) => w.kind === 'upsertChannel')
|
|
371
|
+
.pop();
|
|
372
|
+
if (last?.kind === 'upsertChannel') expect(last.row.topic?.text).toBe('hello');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it.each([
|
|
376
|
+
'setChannelFounder',
|
|
377
|
+
'setChannelMlock',
|
|
378
|
+
'setChannelRestricted',
|
|
379
|
+
'setChannelKeepTopic',
|
|
380
|
+
'setChannelTopic',
|
|
381
|
+
] as const)('%s on an unregistered channel enqueues nothing', async (method) => {
|
|
382
|
+
const s = recordingStore();
|
|
383
|
+
switch (method) {
|
|
384
|
+
case 'setChannelFounder':
|
|
385
|
+
s.setChannelFounder('#ghost', 'bob');
|
|
386
|
+
break;
|
|
387
|
+
case 'setChannelMlock':
|
|
388
|
+
s.setChannelMlock('#ghost', '+nrt');
|
|
389
|
+
break;
|
|
390
|
+
case 'setChannelRestricted':
|
|
391
|
+
s.setChannelRestricted('#ghost', true);
|
|
392
|
+
break;
|
|
393
|
+
case 'setChannelKeepTopic':
|
|
394
|
+
s.setChannelKeepTopic('#ghost', true);
|
|
395
|
+
break;
|
|
396
|
+
case 'setChannelTopic':
|
|
397
|
+
s.setChannelTopic('#ghost', { text: 'x', setter: 'y', setAt: NOW });
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
await s.flush();
|
|
401
|
+
expect(s.batches).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('setChannelAccess level<=0 on a non-existent entry enqueues nothing', async () => {
|
|
405
|
+
const s = recordingStore();
|
|
406
|
+
s.setChannelAccess('#ghost', 'bob', 0);
|
|
407
|
+
await s.flush();
|
|
408
|
+
expect(s.batches).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('setVhost non-empty → upsertVhost; empty → deleteVhost', async () => {
|
|
412
|
+
const s = recordingStore();
|
|
413
|
+
s.setVhost('alice', 'cloak.example');
|
|
414
|
+
s.setVhost('alice', '');
|
|
415
|
+
await s.flush();
|
|
416
|
+
expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(true);
|
|
417
|
+
expect(ops(s).some((w) => w.kind === 'deleteVhost')).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('setVhost empty on an account with no prior vhost enqueues nothing', async () => {
|
|
421
|
+
const s = recordingStore();
|
|
422
|
+
s.setVhost('ghost', '');
|
|
423
|
+
await s.flush();
|
|
424
|
+
expect(s.batches).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('requestVhost → upsertPendingVhost', async () => {
|
|
428
|
+
const s = recordingStore();
|
|
429
|
+
s.requestVhost('alice', 'queued.example');
|
|
430
|
+
await s.flush();
|
|
431
|
+
expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
|
|
432
|
+
const op = ops(s).find((w) => w.kind === 'upsertPendingVhost');
|
|
433
|
+
if (op?.kind === 'upsertPendingVhost') {
|
|
434
|
+
expect(op.row).toEqual({ account: 'alice', vhost: 'queued.example' });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('approveVhost → deletePendingVhost + upsertVhost (activation)', async () => {
|
|
439
|
+
const s = recordingStore();
|
|
440
|
+
s.requestVhost('alice', 'queued.example');
|
|
441
|
+
s.approveVhost('alice');
|
|
442
|
+
await s.flush();
|
|
443
|
+
expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
|
|
444
|
+
expect(ops(s).some((w) => w.kind === 'deletePendingVhost')).toBe(true);
|
|
445
|
+
expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(true);
|
|
446
|
+
const upsert = ops(s)
|
|
447
|
+
.filter((w) => w.kind === 'upsertVhost')
|
|
448
|
+
.pop();
|
|
449
|
+
if (upsert?.kind === 'upsertVhost') {
|
|
450
|
+
expect(upsert.row).toEqual({ account: 'alice', vhost: 'queued.example' });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('approveVhost on an unknown account enqueues nothing', async () => {
|
|
455
|
+
const s = recordingStore();
|
|
456
|
+
s.approveVhost('ghost');
|
|
457
|
+
await s.flush();
|
|
458
|
+
expect(s.batches).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('rejectVhost → deletePendingVhost only (no activation)', async () => {
|
|
462
|
+
const s = recordingStore();
|
|
463
|
+
s.requestVhost('alice', 'queued.example');
|
|
464
|
+
s.rejectVhost('alice');
|
|
465
|
+
await s.flush();
|
|
466
|
+
expect(ops(s).some((w) => w.kind === 'upsertPendingVhost')).toBe(true);
|
|
467
|
+
expect(ops(s).some((w) => w.kind === 'deletePendingVhost')).toBe(true);
|
|
468
|
+
expect(ops(s).some((w) => w.kind === 'upsertVhost')).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('rejectVhost on an unknown account enqueues nothing', async () => {
|
|
472
|
+
const s = recordingStore();
|
|
473
|
+
s.rejectVhost('ghost');
|
|
474
|
+
await s.flush();
|
|
475
|
+
expect(s.batches).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('snapshot round-trips pending vhost requests across a peer store', () => {
|
|
479
|
+
const s = recordingStore();
|
|
480
|
+
s.requestVhost('alice', 'one.example');
|
|
481
|
+
s.requestVhost('bob', 'two.example');
|
|
482
|
+
const snap = s.snapshot();
|
|
483
|
+
expect(snap.pendingVhosts).toContainEqual({ account: 'alice', vhost: 'one.example' });
|
|
484
|
+
expect(snap.pendingVhosts).toContainEqual({ account: 'bob', vhost: 'two.example' });
|
|
485
|
+
const reloaded = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: snap });
|
|
486
|
+
expect(reloaded.getPendingVhost('alice')).toBe('one.example');
|
|
487
|
+
expect(reloaded.getPendingVhost('bob')).toBe('two.example');
|
|
488
|
+
expect(reloaded.listPendingVhosts()).toHaveLength(2);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('recordMemo → insertMemo with the assigned id', async () => {
|
|
492
|
+
const s = recordingStore();
|
|
493
|
+
const memo: MemoInput = { from: 'bob', to: 'alice', body: 'hi', sentAt: NOW };
|
|
494
|
+
const id = s.recordMemo(memo);
|
|
495
|
+
await s.flush();
|
|
496
|
+
const op = ops(s).find((w) => w.kind === 'insertMemo');
|
|
497
|
+
if (op?.kind === 'insertMemo') expect(op.row.id).toBe(id);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('markMemoRead → updateMemoRead only when the memo existed', async () => {
|
|
501
|
+
const s = recordingStore();
|
|
502
|
+
s.markMemoRead('alice', 9); // no such memo
|
|
503
|
+
s.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
|
|
504
|
+
s.markMemoRead('alice', 1);
|
|
505
|
+
await s.flush();
|
|
506
|
+
expect(ops(s).some((w) => w.kind === 'updateMemoRead')).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('deleteMemo → deleteMemo only when something was deleted', async () => {
|
|
510
|
+
const s = recordingStore();
|
|
511
|
+
s.deleteMemo('alice', 9); // no such memo
|
|
512
|
+
s.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
|
|
513
|
+
s.deleteMemo('alice', 1);
|
|
514
|
+
await s.flush();
|
|
515
|
+
expect(ops(s).some((w) => w.kind === 'deleteMemo')).toBe(true);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('setLastReadMarker → upsertReadMarker', async () => {
|
|
519
|
+
const s = recordingStore();
|
|
520
|
+
s.setLastReadMarker('alice', '#chan', 'm1');
|
|
521
|
+
await s.flush();
|
|
522
|
+
expect(ops(s).some((w) => w.kind === 'upsertReadMarker')).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('addAkill → upsertAkill; removeAkill → deleteAkill', async () => {
|
|
526
|
+
const s = recordingStore();
|
|
527
|
+
s.addAkill('*!*@bad', 'reason');
|
|
528
|
+
s.removeAkill('*!*@bad');
|
|
529
|
+
await s.flush();
|
|
530
|
+
expect(ops(s).some((w) => w.kind === 'upsertAkill')).toBe(true);
|
|
531
|
+
expect(ops(s).some((w) => w.kind === 'deleteAkill')).toBe(true);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('removeAkill on an unknown mask enqueues nothing', async () => {
|
|
535
|
+
const s = recordingStore();
|
|
536
|
+
s.removeAkill('*!*@unknown');
|
|
537
|
+
await s.flush();
|
|
538
|
+
expect(s.batches).toEqual([]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('addJupe → upsertJupe; removeJupe → deleteJupe', async () => {
|
|
542
|
+
const s = recordingStore();
|
|
543
|
+
s.addJupe('bad', 'reason');
|
|
544
|
+
s.removeJupe('bad');
|
|
545
|
+
await s.flush();
|
|
546
|
+
expect(ops(s).some((w) => w.kind === 'upsertJupe')).toBe(true);
|
|
547
|
+
expect(ops(s).some((w) => w.kind === 'deleteJupe')).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('removeJupe on an unknown name enqueues nothing', async () => {
|
|
551
|
+
const s = recordingStore();
|
|
552
|
+
s.removeJupe('unknown');
|
|
553
|
+
await s.flush();
|
|
554
|
+
expect(s.batches).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('mutated state is visible synchronously (before flush)', () => {
|
|
558
|
+
const s = recordingStore();
|
|
559
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
560
|
+
expect(s.isRegisteredNick('alice')).toBe(true);
|
|
561
|
+
expect(s.verifyNick('alice', 'pw')).toEqual({ ok: true, account: 'alice' });
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ============================================================================
|
|
566
|
+
// helpers
|
|
567
|
+
// ============================================================================
|
|
568
|
+
|
|
569
|
+
function emptySnapshot(): ServicesSnapshot {
|
|
570
|
+
return {
|
|
571
|
+
nicks: [],
|
|
572
|
+
channels: [],
|
|
573
|
+
access: [],
|
|
574
|
+
levels: [],
|
|
575
|
+
vhosts: [],
|
|
576
|
+
pendingVhosts: [],
|
|
577
|
+
memos: [],
|
|
578
|
+
readMarkers: [],
|
|
579
|
+
akills: [],
|
|
580
|
+
jupes: [],
|
|
581
|
+
};
|
|
582
|
+
}
|