@openora/core 0.4.1-canary.124 → 0.4.1-canary.126

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 (92) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/audit/__tests__/map-event.test.js +83 -0
  3. package/dist/audit/__tests__/map-event.test.js.map +1 -1
  4. package/dist/audit/plugin.d.ts.map +1 -1
  5. package/dist/audit/plugin.js +47 -0
  6. package/dist/audit/plugin.js.map +1 -1
  7. package/dist/contracts/adapters/index.d.ts +1 -1
  8. package/dist/contracts/adapters/index.d.ts.map +1 -1
  9. package/dist/contracts/adapters/index.js +1 -1
  10. package/dist/contracts/adapters/index.js.map +1 -1
  11. package/dist/contracts/adapters/realtime.d.ts +22 -5
  12. package/dist/contracts/adapters/realtime.d.ts.map +1 -1
  13. package/dist/contracts/adapters/realtime.js +7 -0
  14. package/dist/contracts/adapters/realtime.js.map +1 -1
  15. package/dist/contracts/schemas/events.d.ts +46 -0
  16. package/dist/contracts/schemas/events.d.ts.map +1 -1
  17. package/dist/contracts/schemas/events.js +34 -0
  18. package/dist/contracts/schemas/events.js.map +1 -1
  19. package/dist/engagement/chat/__tests__/chat.service.int.test.js +13 -1
  20. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  21. package/dist/engagement/chat/__tests__/room-ownership-handover.int.test.d.ts +2 -0
  22. package/dist/engagement/chat/__tests__/room-ownership-handover.int.test.d.ts.map +1 -0
  23. package/dist/engagement/chat/__tests__/room-ownership-handover.int.test.js +736 -0
  24. package/dist/engagement/chat/__tests__/room-ownership-handover.int.test.js.map +1 -0
  25. package/dist/engagement/chat/contract/constants.d.ts +2 -0
  26. package/dist/engagement/chat/contract/constants.d.ts.map +1 -1
  27. package/dist/engagement/chat/contract/constants.js +3 -1
  28. package/dist/engagement/chat/contract/constants.js.map +1 -1
  29. package/dist/engagement/chat/contract/index.d.ts +21 -0
  30. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  31. package/dist/engagement/chat/contract/index.js +7 -0
  32. package/dist/engagement/chat/contract/index.js.map +1 -1
  33. package/dist/engagement/chat/drizzle/migrations/0012_strange_mephisto.sql +3 -0
  34. package/dist/engagement/chat/drizzle/migrations/0013_chilly_sprite.sql +6 -0
  35. package/dist/engagement/chat/drizzle/migrations/meta/0012_snapshot.json +1517 -0
  36. package/dist/engagement/chat/drizzle/migrations/meta/0013_snapshot.json +1517 -0
  37. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +14 -0
  38. package/dist/engagement/chat/plugin.d.ts +2 -1
  39. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  40. package/dist/engagement/chat/plugin.js +138 -3
  41. package/dist/engagement/chat/plugin.js.map +1 -1
  42. package/dist/engagement/chat/router/index.d.ts +13 -0
  43. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  44. package/dist/engagement/chat/schema/index.d.ts +34 -0
  45. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  46. package/dist/engagement/chat/schema/index.js +7 -2
  47. package/dist/engagement/chat/schema/index.js.map +1 -1
  48. package/dist/engagement/chat/service/chat-room-ban.service.d.ts.map +1 -1
  49. package/dist/engagement/chat/service/chat-room-ban.service.js +11 -9
  50. package/dist/engagement/chat/service/chat-room-ban.service.js.map +1 -1
  51. package/dist/engagement/chat/service/chat-room-membership.service.d.ts +23 -3
  52. package/dist/engagement/chat/service/chat-room-membership.service.d.ts.map +1 -1
  53. package/dist/engagement/chat/service/chat-room-membership.service.js +280 -12
  54. package/dist/engagement/chat/service/chat-room-membership.service.js.map +1 -1
  55. package/dist/engagement/chat/service/chat-room-purge.service.d.ts +13 -0
  56. package/dist/engagement/chat/service/chat-room-purge.service.d.ts.map +1 -0
  57. package/dist/engagement/chat/service/chat-room-purge.service.js +75 -0
  58. package/dist/engagement/chat/service/chat-room-purge.service.js.map +1 -0
  59. package/dist/engagement/chat/service/chat.service.d.ts +19 -8
  60. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  61. package/dist/engagement/chat/service/chat.service.js +9 -3
  62. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  63. package/dist/engagement/notifications/__tests__/chat-room-notifications.int.test.d.ts +2 -0
  64. package/dist/engagement/notifications/__tests__/chat-room-notifications.int.test.d.ts.map +1 -0
  65. package/dist/engagement/notifications/__tests__/chat-room-notifications.int.test.js +187 -0
  66. package/dist/engagement/notifications/__tests__/chat-room-notifications.int.test.js.map +1 -0
  67. package/dist/engagement/notifications/__tests__/notification-event-map.test.js +35 -1
  68. package/dist/engagement/notifications/__tests__/notification-event-map.test.js.map +1 -1
  69. package/dist/engagement/notifications/contract/index.d.ts +11 -3
  70. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  71. package/dist/engagement/notifications/contract/index.js +2 -0
  72. package/dist/engagement/notifications/contract/index.js.map +1 -1
  73. package/dist/engagement/notifications/plugin.d.ts +5 -0
  74. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  75. package/dist/engagement/notifications/plugin.js +46 -0
  76. package/dist/engagement/notifications/plugin.js.map +1 -1
  77. package/dist/engagement/notifications/router/index.d.ts +4 -2
  78. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  79. package/dist/fx/exchange-rate/__tests__/exchange-rate-reader.service.int.test.js +4 -3
  80. package/dist/fx/exchange-rate/__tests__/exchange-rate-reader.service.int.test.js.map +1 -1
  81. package/dist/pam/player-management/__tests__/player.service.int.test.js +50 -0
  82. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  83. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  84. package/dist/pam/player-management/service/player.service.js +19 -0
  85. package/dist/pam/player-management/service/player.service.js.map +1 -1
  86. package/dist/server/kernel/__tests__/redis-pubsub-realtime-transport.int.test.js +33 -0
  87. package/dist/server/kernel/__tests__/redis-pubsub-realtime-transport.int.test.js.map +1 -1
  88. package/dist/server/kernel/redis-pubsub-realtime-transport.d.ts +10 -2
  89. package/dist/server/kernel/redis-pubsub-realtime-transport.d.ts.map +1 -1
  90. package/dist/server/kernel/redis-pubsub-realtime-transport.js +0 -0
  91. package/dist/server/kernel/redis-pubsub-realtime-transport.js.map +1 -1
  92. package/package.json +1 -1
@@ -0,0 +1,736 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { and, eq, sql } from 'drizzle-orm';
4
+ import { createTestDb, createTestRedis, RedisPubSubRealtimeTransport, } from '@openora/core/testing';
5
+ import { user } from '@openora/core/pam/schema/identity';
6
+ import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
7
+ import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile';
8
+ import { NO_CLIENT_META, makeEventBus, makeIdentityReader, mock } from '../../../testing/mock.js';
9
+ import { migrate } from '../migrate.js';
10
+ import { CHAT_MEMBER_ROLE_CHANGED_SIGNAL, CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL, OWNERLESS_ROOM_RETENTION_DAYS, } from '../contract/constants.js';
11
+ import { chatMessage, chatMute, chatPlatformBan, chatRoom, chatRoomBan, chatRoomConfiguration, chatRoomMember, chatRoomMute, chatRoomRemove, chatRoomRule, chatUserBlock, chatUserIgnore, } from '../schema/index.js';
12
+ import { ChatService } from '../service/chat.service.js';
13
+ import { ChatModerationService } from '../service/chat-moderation.service.js';
14
+ import { ChatRoomBanService } from '../service/chat-room-ban.service.js';
15
+ import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js';
16
+ import { ChatRoomNotModeratorError } from '../service/errors/chat-moderation.errors.js';
17
+ import { ChatRoomPurgeService } from '../service/chat-room-purge.service.js';
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ let db;
20
+ let redis;
21
+ const transports = [];
22
+ function makeTransport() {
23
+ const transport = new RedisPubSubRealtimeTransport(redis.client, `chat-handover-test-${randomUUID()}`);
24
+ transports.push(transport);
25
+ return transport;
26
+ }
27
+ function makeServices(transport = makeTransport()) {
28
+ const events = makeEventBus();
29
+ const audit = mock({
30
+ record: vi.fn().mockResolvedValue(undefined),
31
+ recordInTransaction: vi.fn().mockResolvedValue(undefined),
32
+ });
33
+ const directory = mock({
34
+ lookupPlayers: async () => [],
35
+ lookupUsers: async (ids) => ids.map((id) => ({
36
+ id,
37
+ email: `${id}@example.com`,
38
+ name: id,
39
+ createdAt: new Date(),
40
+ isActive: true,
41
+ role: 'player',
42
+ })),
43
+ });
44
+ const identityReader = makeIdentityReader();
45
+ const moderation = new ChatModerationService(db.drizzle, transport, audit);
46
+ const chat = new ChatService({
47
+ drizzle: db.drizzle,
48
+ events,
49
+ transport,
50
+ directory,
51
+ audit,
52
+ moderation,
53
+ identityReader,
54
+ allowedAttachmentHosts: [],
55
+ });
56
+ const membership = new ChatRoomMembershipService(db.drizzle, events, audit, transport, identityReader);
57
+ const purge = new ChatRoomPurgeService(db.drizzle, events, transport, audit);
58
+ const ban = new ChatRoomBanService(db.drizzle, events, audit, transport, identityReader);
59
+ return { chat, membership, purge, ban, events, transport, audit };
60
+ }
61
+ async function seedPrivateRoom(services, ownerId, memberIds = []) {
62
+ const room = await services.chat.createPrivateRoom({
63
+ userId: ownerId,
64
+ name: 'Wheel Spin',
65
+ ...NO_CLIENT_META,
66
+ });
67
+ for (const userId of memberIds) {
68
+ await services.membership.joinRoom({ userId, joinCode: room.joinCode, ...NO_CLIENT_META });
69
+ }
70
+ return room;
71
+ }
72
+ async function promote(services, room, ownerId, userId, roleAssignedAt) {
73
+ await services.membership.setMemberRole({
74
+ actorId: ownerId,
75
+ roomId: room.id,
76
+ userId,
77
+ role: 'moderator',
78
+ ...NO_CLIENT_META,
79
+ });
80
+ if (roleAssignedAt !== undefined) {
81
+ await db.drizzle.db
82
+ .update(chatRoomMember)
83
+ .set({ roleAssignedAt })
84
+ .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, userId)));
85
+ }
86
+ }
87
+ function readMember(roomId, userId) {
88
+ return db.drizzle.db
89
+ .select()
90
+ .from(chatRoomMember)
91
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)))
92
+ .limit(1)
93
+ .then(([row]) => row);
94
+ }
95
+ function emittedAudience(services) {
96
+ const call = services.events.emit.mock.calls.find((args) => args[0] === 'chat.room.scheduled_for_deletion');
97
+ return call?.[1]?.memberIds ?? [];
98
+ }
99
+ function readRoom(roomId) {
100
+ return db.drizzle.db
101
+ .select()
102
+ .from(chatRoom)
103
+ .where(eq(chatRoom.id, roomId))
104
+ .limit(1)
105
+ .then(([row]) => row ?? null);
106
+ }
107
+ beforeAll(async () => {
108
+ db = await createTestDb([migrate, migrateIdentity, migrateProfile]);
109
+ redis = await createTestRedis();
110
+ });
111
+ afterAll(async () => {
112
+ await db.drop();
113
+ await redis.quit();
114
+ });
115
+ afterEach(async () => {
116
+ await Promise.allSettled(transports.splice(0).map((t) => t.close()));
117
+ });
118
+ beforeEach(async () => {
119
+ await db.drizzle.db.execute(sql `TRUNCATE ${chatMessage}, ${chatRoomRule}, ${chatRoomConfiguration}, ${chatRoomMember}, ${chatRoomBan}, ${chatRoomMute}, ${chatRoomRemove}, ${chatMute}, ${chatPlatformBan}, ${chatUserBlock}, ${chatUserIgnore}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`);
120
+ });
121
+ describe('ChatRoomMembershipService.handleAccountClosed - transfer (real PG)', () => {
122
+ it('hands the room to the only moderator, moving both halves of ownership', async () => {
123
+ const services = makeServices();
124
+ const ownerId = randomUUID();
125
+ const modId = randomUUID();
126
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
127
+ await promote(services, room, ownerId, modId);
128
+ const closedAt = new Date('2026-08-31T10:00:00.000Z');
129
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt });
130
+ const stored = await readRoom(room.id);
131
+ expect(stored?.creatorId).toBe(modId);
132
+ expect(stored?.scheduledDeletionAt).toBeNull();
133
+ const successor = await readMember(room.id, modId);
134
+ expect(successor.role).toBe('owner');
135
+ expect(successor.roleAssignedAt).toBeInstanceOf(Date);
136
+ const previous = await readMember(room.id, ownerId);
137
+ expect(previous).toMatchObject({ role: 'member', roleAssignedAt: null });
138
+ expect(previous.accountClosedAt?.toISOString()).toBe(closedAt.toISOString());
139
+ expect(services.events.emit).toHaveBeenCalledWith('chat.room.ownership.transferred', {
140
+ roomId: room.id,
141
+ roomName: 'Wheel Spin',
142
+ previousOwnerId: ownerId,
143
+ newOwnerId: modId,
144
+ reason: 'account-closed',
145
+ });
146
+ });
147
+ it('picks the moderator with the earliest assignment, skipping one already closed', async () => {
148
+ const services = makeServices();
149
+ const ownerId = randomUUID();
150
+ const [deadMod, earlyMod, lateMod] = [randomUUID(), randomUUID(), randomUUID()];
151
+ const room = await seedPrivateRoom(services, ownerId, [deadMod, earlyMod, lateMod]);
152
+ await promote(services, room, ownerId, deadMod, new Date('2026-01-01T00:00:00.000Z'));
153
+ await promote(services, room, ownerId, earlyMod, new Date('2026-02-01T00:00:00.000Z'));
154
+ await promote(services, room, ownerId, lateMod, new Date('2026-03-01T00:00:00.000Z'));
155
+ await services.membership.handleAccountClosed({
156
+ userId: deadMod,
157
+ closedAt: new Date('2026-08-01T00:00:00.000Z'),
158
+ });
159
+ await services.membership.handleAccountClosed({
160
+ userId: ownerId,
161
+ closedAt: new Date('2026-08-31T10:00:00.000Z'),
162
+ });
163
+ expect((await readRoom(room.id))?.creatorId).toBe(earlyMod);
164
+ expect((await readMember(room.id, earlyMod)).role).toBe('owner');
165
+ expect((await readMember(room.id, lateMod)).role).toBe('moderator');
166
+ expect(await readMember(room.id, deadMod)).toMatchObject({ role: 'moderator' });
167
+ expect((await readMember(room.id, deadMod)).accountClosedAt).toBeInstanceOf(Date);
168
+ });
169
+ it('falls back to joinedAt for moderators a backfill left without an assignment stamp', async () => {
170
+ const services = makeServices();
171
+ const ownerId = randomUUID();
172
+ const first = randomUUID();
173
+ const second = randomUUID();
174
+ const room = await seedPrivateRoom(services, ownerId, [first, second]);
175
+ await promote(services, room, ownerId, first, null);
176
+ await promote(services, room, ownerId, second, null);
177
+ await db.drizzle.db
178
+ .update(chatRoomMember)
179
+ .set({ joinedAt: new Date('2020-01-01T00:00:00.000Z') })
180
+ .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, second)));
181
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: new Date() });
182
+ expect((await readRoom(room.id))?.creatorId).toBe(second);
183
+ });
184
+ it('signals both the successor and the demoted owner so rosters refetch', async () => {
185
+ const signal = vi.fn();
186
+ const services = makeServices(Object.assign(makeTransport(), { signal }));
187
+ const ownerId = randomUUID();
188
+ const modId = randomUUID();
189
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
190
+ await promote(services, room, ownerId, modId);
191
+ signal.mockClear();
192
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: new Date() });
193
+ expect(signal).toHaveBeenCalledWith(`chat:room:${room.id}`, CHAT_MEMBER_ROLE_CHANGED_SIGNAL, {
194
+ roomId: room.id,
195
+ userId: modId,
196
+ role: 'owner',
197
+ });
198
+ expect(signal).toHaveBeenCalledWith(`chat:room:${room.id}`, CHAT_MEMBER_ROLE_CHANGED_SIGNAL, {
199
+ roomId: room.id,
200
+ userId: ownerId,
201
+ role: 'member',
202
+ });
203
+ });
204
+ });
205
+ describe('ChatRoomMembershipService.handleAccountClosed - countdown (real PG)', () => {
206
+ it('starts a 30-day countdown and freezes administration when nobody can inherit', async () => {
207
+ const signal = vi.fn();
208
+ const services = makeServices(Object.assign(makeTransport(), { signal }));
209
+ const ownerId = randomUUID();
210
+ const memberIds = [randomUUID(), randomUUID()];
211
+ const room = await seedPrivateRoom(services, ownerId, memberIds);
212
+ const closedAt = new Date('2026-08-31T10:00:00.000Z');
213
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt });
214
+ const stored = await readRoom(room.id);
215
+ expect(stored?.creatorId).toBeNull();
216
+ expect(stored?.scheduledDeletionAt?.toISOString()).toBe(new Date(closedAt.getTime() + OWNERLESS_ROOM_RETENTION_DAYS * DAY_MS).toISOString());
217
+ expect(services.events.emit).toHaveBeenCalledWith('chat.room.scheduled_for_deletion', expect.objectContaining({
218
+ roomId: room.id,
219
+ roomName: 'Wheel Spin',
220
+ previousOwnerId: ownerId,
221
+ memberIds: expect.arrayContaining(memberIds),
222
+ scheduledDeletionAt: stored.scheduledDeletionAt.toISOString(),
223
+ }));
224
+ expect(emittedAudience(services)).not.toContain(ownerId);
225
+ expect(signal).toHaveBeenCalledWith(`chat:room:${room.id}`, CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL, { roomId: room.id, scheduledDeletionAt: stored.scheduledDeletionAt.toISOString() });
226
+ });
227
+ it('leaves a member whose own account is already closed out of the audience', async () => {
228
+ const services = makeServices();
229
+ const ownerId = randomUUID();
230
+ const activeId = randomUUID();
231
+ const closedId = randomUUID();
232
+ const room = await seedPrivateRoom(services, ownerId, [activeId, closedId]);
233
+ await services.membership.handleAccountClosed({
234
+ userId: closedId,
235
+ closedAt: new Date('2026-08-30T10:00:00.000Z'),
236
+ });
237
+ services.events.emit.mockClear();
238
+ await services.membership.handleAccountClosed({
239
+ userId: ownerId,
240
+ closedAt: new Date('2026-08-31T10:00:00.000Z'),
241
+ });
242
+ expect(emittedAudience(services)).toEqual([activeId]);
243
+ const roster = await services.chat.listRoomMembers({ roomId: room.id, viewerId: activeId });
244
+ expect(roster).toHaveLength(3);
245
+ expect(roster
246
+ .filter((m) => m.isDeletedAccount)
247
+ .map((m) => m.userId)
248
+ .sort()).toEqual([ownerId, closedId].sort());
249
+ });
250
+ it('leaves the room readable and writable for its members throughout the window (AC4)', async () => {
251
+ const services = makeServices();
252
+ const ownerId = randomUUID();
253
+ const memberId = randomUUID();
254
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
255
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: new Date() });
256
+ await services.chat.sendRoomMessage({
257
+ userId: memberId,
258
+ username: 'bob',
259
+ roomId: room.id,
260
+ content: 'still here',
261
+ });
262
+ const messages = await services.chat.getRoomMessages({ roomId: room.id, viewerId: memberId });
263
+ expect(messages.map((m) => m.content)).toContain('still here');
264
+ });
265
+ it('never moves the deadline once it is running, whatever members do (AC6)', async () => {
266
+ const services = makeServices();
267
+ const ownerId = randomUUID();
268
+ const memberId = randomUUID();
269
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
270
+ const closedAt = new Date('2026-08-31T10:00:00.000Z');
271
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt });
272
+ const deadline = (await readRoom(room.id)).scheduledDeletionAt;
273
+ await services.chat.sendRoomMessage({
274
+ userId: memberId,
275
+ username: 'bob',
276
+ roomId: room.id,
277
+ content: 'hello',
278
+ });
279
+ const latecomer = randomUUID();
280
+ await services.membership.joinRoom({
281
+ userId: latecomer,
282
+ joinCode: room.joinCode,
283
+ ...NO_CLIENT_META,
284
+ });
285
+ await services.membership.handleAccountClosed({
286
+ userId: ownerId,
287
+ closedAt: new Date('2026-09-15T10:00:00.000Z'),
288
+ });
289
+ expect((await readRoom(room.id)).scheduledDeletionAt?.toISOString()).toBe(deadline?.toISOString());
290
+ });
291
+ it('is a no-op the second time it runs for the same account', async () => {
292
+ const services = makeServices();
293
+ const ownerId = randomUUID();
294
+ const modId = randomUUID();
295
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
296
+ await promote(services, room, ownerId, modId);
297
+ await services.membership.handleAccountClosed({
298
+ userId: ownerId,
299
+ closedAt: new Date('2026-08-31T10:00:00.000Z'),
300
+ });
301
+ const afterFirst = await readRoom(room.id);
302
+ const successorAfterFirst = await readMember(room.id, modId);
303
+ services.events.emit.mockClear();
304
+ await services.membership.handleAccountClosed({
305
+ userId: ownerId,
306
+ closedAt: new Date('2026-09-01T10:00:00.000Z'),
307
+ });
308
+ expect(await readRoom(room.id)).toEqual(afterFirst);
309
+ expect(await readMember(room.id, modId)).toEqual(successorAfterFirst);
310
+ expect(services.events.emit).not.toHaveBeenCalled();
311
+ });
312
+ });
313
+ describe('handleAccountClosed redelivery (real PG)', () => {
314
+ const CLOSED_AT = new Date('2026-08-31T10:00:00.000Z');
315
+ it('re-announces the countdown a previous delivery committed but may not have sent', async () => {
316
+ const services = makeServices();
317
+ const ownerId = randomUUID();
318
+ const memberId = randomUUID();
319
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
320
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
321
+ const afterFirst = await readRoom(room.id);
322
+ services.events.emit.mockClear();
323
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
324
+ expect(services.events.emit).toHaveBeenCalledWith('chat.room.scheduled_for_deletion', expect.objectContaining({
325
+ roomId: room.id,
326
+ previousOwnerId: ownerId,
327
+ scheduledDeletionAt: afterFirst.scheduledDeletionAt.toISOString(),
328
+ }));
329
+ expect(emittedAudience(services)).toEqual([memberId]);
330
+ expect(await readRoom(room.id)).toEqual(afterFirst);
331
+ });
332
+ it('re-announces a transfer, naming the successor the first delivery installed', async () => {
333
+ const services = makeServices();
334
+ const ownerId = randomUUID();
335
+ const modId = randomUUID();
336
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
337
+ await promote(services, room, ownerId, modId);
338
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
339
+ const afterFirst = await readRoom(room.id);
340
+ const successorAfterFirst = await readMember(room.id, modId);
341
+ services.events.emit.mockClear();
342
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
343
+ expect(services.events.emit).toHaveBeenCalledWith('chat.room.ownership.transferred', {
344
+ roomId: room.id,
345
+ roomName: room.name,
346
+ previousOwnerId: ownerId,
347
+ newOwnerId: modId,
348
+ reason: 'account-closed',
349
+ });
350
+ expect(await readRoom(room.id)).toEqual(afterFirst);
351
+ expect(await readMember(room.id, modId)).toEqual(successorAfterFirst);
352
+ });
353
+ it("says nothing about a room somebody else's closure changed", async () => {
354
+ const services = makeServices();
355
+ const firstOwnerId = randomUUID();
356
+ const secondOwnerId = randomUUID();
357
+ const room = await seedPrivateRoom(services, firstOwnerId, [secondOwnerId]);
358
+ await promote(services, room, firstOwnerId, secondOwnerId);
359
+ await services.membership.handleAccountClosed({ userId: firstOwnerId, closedAt: CLOSED_AT });
360
+ const secondClosedAt = new Date('2026-09-02T10:00:00.000Z');
361
+ await services.membership.handleAccountClosed({
362
+ userId: secondOwnerId,
363
+ closedAt: secondClosedAt,
364
+ });
365
+ services.events.emit.mockClear();
366
+ await services.membership.handleAccountClosed({ userId: firstOwnerId, closedAt: CLOSED_AT });
367
+ expect(services.events.emit).not.toHaveBeenCalled();
368
+ });
369
+ it('leaves a non-owner redelivery silent - there was nothing to announce', async () => {
370
+ const services = makeServices();
371
+ const ownerId = randomUUID();
372
+ const memberId = randomUUID();
373
+ await seedPrivateRoom(services, ownerId, [memberId]);
374
+ await services.membership.handleAccountClosed({ userId: memberId, closedAt: CLOSED_AT });
375
+ services.events.emit.mockClear();
376
+ await services.membership.handleAccountClosed({ userId: memberId, closedAt: CLOSED_AT });
377
+ expect(services.events.emit).not.toHaveBeenCalled();
378
+ });
379
+ });
380
+ describe('ChatRoomMembershipService.handleAccountClosed - non-owners (real PG)', () => {
381
+ it('marks a closed moderator without transferring or starting a countdown', async () => {
382
+ const services = makeServices();
383
+ const ownerId = randomUUID();
384
+ const modId = randomUUID();
385
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
386
+ await promote(services, room, ownerId, modId);
387
+ await services.membership.handleAccountClosed({ userId: modId, closedAt: new Date() });
388
+ const stored = await readRoom(room.id);
389
+ expect(stored?.creatorId).toBe(ownerId);
390
+ expect(stored?.scheduledDeletionAt).toBeNull();
391
+ expect((await readMember(room.id, modId)).accountClosedAt).toBeInstanceOf(Date);
392
+ expect((await readMember(room.id, ownerId)).role).toBe('owner');
393
+ });
394
+ it('marks a closed plain member and changes nothing else', async () => {
395
+ const services = makeServices();
396
+ const ownerId = randomUUID();
397
+ const memberId = randomUUID();
398
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
399
+ await services.membership.handleAccountClosed({ userId: memberId, closedAt: new Date() });
400
+ const stored = await readRoom(room.id);
401
+ expect(stored?.creatorId).toBe(ownerId);
402
+ expect(stored?.scheduledDeletionAt).toBeNull();
403
+ expect((await readMember(room.id, memberId)).accountClosedAt).toBeInstanceOf(Date);
404
+ expect(services.events.emit).not.toHaveBeenCalledWith('chat.room.scheduled_for_deletion', expect.anything());
405
+ });
406
+ it('leaves public rooms alone - they have no owner to inherit', async () => {
407
+ const services = makeServices();
408
+ const ownerId = randomUUID();
409
+ const [publicRoom] = await db.drizzle.db
410
+ .insert(chatRoom)
411
+ .values({ name: 'Lobby', slug: `lobby-${randomUUID()}`, isPublic: true })
412
+ .returning();
413
+ await services.membership.joinPublicRoom({
414
+ roomId: publicRoom.id,
415
+ userId: ownerId,
416
+ ...NO_CLIENT_META,
417
+ });
418
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: new Date() });
419
+ expect((await readMember(publicRoom.id, ownerId)).accountClosedAt).toBeNull();
420
+ expect((await readRoom(publicRoom.id))?.scheduledDeletionAt).toBeNull();
421
+ });
422
+ });
423
+ describe('handleAccountClosed against a concurrent membership write (real PG)', () => {
424
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 150));
425
+ function holdRoomLock(roomId) {
426
+ let held;
427
+ const acquired = new Promise((resolve) => {
428
+ held = resolve;
429
+ });
430
+ let release;
431
+ const work = new Promise((resolve) => {
432
+ release = resolve;
433
+ });
434
+ const done = db.drizzle.db.transaction(async (t) => {
435
+ await t.execute(sql `select pg_advisory_xact_lock(hashtext(${`chat-room:${roomId}`}))`);
436
+ held();
437
+ await (await work)(t);
438
+ });
439
+ return { acquired, release: (w) => release(w), done };
440
+ }
441
+ const handoverWrites = (roomId, closedOwnerId, successorId) => async (t) => {
442
+ await t
443
+ .update(chatRoomMember)
444
+ .set({ role: 'owner', roleAssignedAt: new Date() })
445
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, successorId)));
446
+ await t
447
+ .update(chatRoomMember)
448
+ .set({ role: 'member', roleAssignedAt: null, accountClosedAt: new Date() })
449
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, closedOwnerId)));
450
+ await t.update(chatRoom).set({ creatorId: successorId }).where(eq(chatRoom.id, roomId));
451
+ };
452
+ it('starts the countdown rather than naming a creator whose membership vanished mid-transfer', async () => {
453
+ const services = makeServices();
454
+ const ownerId = randomUUID();
455
+ const modId = randomUUID();
456
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
457
+ await promote(services, room, ownerId, modId);
458
+ const closedAt = new Date('2026-08-31T10:00:00.000Z');
459
+ let commitDelete;
460
+ const deleteCommitted = new Promise((resolve) => {
461
+ commitDelete = resolve;
462
+ });
463
+ const deleting = db.drizzle.db.transaction(async (t) => {
464
+ await t
465
+ .delete(chatRoomMember)
466
+ .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, modId)));
467
+ await deleteCommitted;
468
+ });
469
+ await settle();
470
+ const handover = services.membership.handleAccountClosed({ userId: ownerId, closedAt });
471
+ await settle();
472
+ commitDelete();
473
+ await deleting;
474
+ await handover;
475
+ const stored = await readRoom(room.id);
476
+ expect(stored?.creatorId).toBeNull();
477
+ expect(stored?.scheduledDeletionAt?.toISOString()).toBe(new Date(closedAt.getTime() + OWNERLESS_ROOM_RETENTION_DAYS * DAY_MS).toISOString());
478
+ });
479
+ it('makes removeMember wait on the room lock and re-read the roles it was authorized against', async () => {
480
+ const services = makeServices();
481
+ const ownerId = randomUUID();
482
+ const modId = randomUUID();
483
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
484
+ await promote(services, room, ownerId, modId);
485
+ const lock = holdRoomLock(room.id);
486
+ await lock.acquired;
487
+ const removing = services.membership.removeMember({
488
+ moderatorId: ownerId,
489
+ roomId: room.id,
490
+ userId: modId,
491
+ ...NO_CLIENT_META,
492
+ });
493
+ const outcome = removing.then(() => 'settled', () => 'settled');
494
+ expect(await Promise.race([outcome, settle().then(() => 'blocked')])).toBe('blocked');
495
+ lock.release(handoverWrites(room.id, ownerId, modId));
496
+ await lock.done;
497
+ await expect(removing).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
498
+ expect((await readMember(room.id, modId)).role).toBe('owner');
499
+ expect((await readRoom(room.id))?.creatorId).toBe(modId);
500
+ });
501
+ it('authorizes banMember inside the room lock, so a demoted owner cannot ban their successor', async () => {
502
+ const services = makeServices();
503
+ const ownerId = randomUUID();
504
+ const modId = randomUUID();
505
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
506
+ await promote(services, room, ownerId, modId);
507
+ const lock = holdRoomLock(room.id);
508
+ await lock.acquired;
509
+ const banning = services.ban.banMember({
510
+ moderatorId: ownerId,
511
+ roomId: room.id,
512
+ userId: modId,
513
+ ...NO_CLIENT_META,
514
+ });
515
+ const outcome = banning.then(() => 'settled', () => 'settled');
516
+ expect(await Promise.race([outcome, settle().then(() => 'blocked')])).toBe('blocked');
517
+ lock.release(handoverWrites(room.id, ownerId, modId));
518
+ await lock.done;
519
+ await expect(banning).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
520
+ expect(await readMember(room.id, modId)).toMatchObject({ role: 'owner' });
521
+ });
522
+ });
523
+ describe('ChatRoomMembershipService.handleAccountReopened (real PG)', () => {
524
+ const CLOSED_AT = new Date('2026-08-31T10:00:00.000Z');
525
+ it('cancels the countdown and gives the room back to the owner who came back', async () => {
526
+ const services = makeServices();
527
+ const ownerId = randomUUID();
528
+ const memberId = randomUUID();
529
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
530
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
531
+ expect((await readRoom(room.id))?.scheduledDeletionAt).not.toBeNull();
532
+ await services.membership.handleAccountReopened({ userId: ownerId });
533
+ const restored = await readRoom(room.id);
534
+ expect(restored?.scheduledDeletionAt).toBeNull();
535
+ expect(restored?.creatorId).toBe(ownerId);
536
+ const owner = await readMember(room.id, ownerId);
537
+ expect(owner.role).toBe('owner');
538
+ expect(owner.accountClosedAt).toBeNull();
539
+ await expect(services.chat.updatePrivateRoom({
540
+ id: room.id,
541
+ actorId: ownerId,
542
+ name: 'Back In Business',
543
+ ...NO_CLIENT_META,
544
+ })).resolves.toBeDefined();
545
+ const cancelled = services.events.emit.mock.calls.find((args) => args[0] === 'chat.room.deletion.cancelled');
546
+ expect(cancelled?.[1]).toMatchObject({ roomId: room.id, ownerId });
547
+ expect((cancelled?.[1]).memberIds).toEqual(expect.arrayContaining([ownerId, memberId]));
548
+ });
549
+ it('clears the deleted-account flag but leaves a room a moderator inherited alone', async () => {
550
+ const services = makeServices();
551
+ const ownerId = randomUUID();
552
+ const modId = randomUUID();
553
+ const room = await seedPrivateRoom(services, ownerId, [modId]);
554
+ await promote(services, room, ownerId, modId);
555
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
556
+ expect((await readRoom(room.id))?.creatorId).toBe(modId);
557
+ await services.membership.handleAccountReopened({ userId: ownerId });
558
+ expect((await readRoom(room.id))?.creatorId).toBe(modId);
559
+ const previousOwner = await readMember(room.id, ownerId);
560
+ expect(previousOwner.role).toBe('member');
561
+ expect(previousOwner.accountClosedAt).toBeNull();
562
+ expect(services.events.emit.mock.calls.some((args) => args[0] === 'chat.room.deletion.cancelled')).toBe(false);
563
+ });
564
+ it('does not hand a room to a plain member whose own account reopens', async () => {
565
+ const services = makeServices();
566
+ const ownerId = randomUUID();
567
+ const memberId = randomUUID();
568
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
569
+ await services.membership.handleAccountClosed({
570
+ userId: memberId,
571
+ closedAt: new Date('2026-08-30T10:00:00.000Z'),
572
+ });
573
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
574
+ await services.membership.handleAccountReopened({ userId: memberId });
575
+ const room2 = await readRoom(room.id);
576
+ expect(room2?.creatorId).toBeNull();
577
+ expect(room2?.scheduledDeletionAt).toEqual(new Date(CLOSED_AT.getTime() + 30 * DAY_MS));
578
+ expect((await readMember(room.id, memberId)).accountClosedAt).toBeNull();
579
+ expect((await readMember(room.id, memberId)).role).toBe('member');
580
+ });
581
+ it('is a no-op the second time, so a redelivery cannot re-announce', async () => {
582
+ const services = makeServices();
583
+ const ownerId = randomUUID();
584
+ const room = await seedPrivateRoom(services, ownerId);
585
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
586
+ await services.membership.handleAccountReopened({ userId: ownerId });
587
+ services.events.emit.mockClear();
588
+ await services.membership.handleAccountReopened({ userId: ownerId });
589
+ expect(services.events.emit).not.toHaveBeenCalled();
590
+ expect((await readRoom(room.id))?.creatorId).toBe(ownerId);
591
+ });
592
+ it('stops the purge that would otherwise have deleted the room anyway', async () => {
593
+ const services = makeServices();
594
+ const ownerId = randomUUID();
595
+ const room = await seedPrivateRoom(services, ownerId);
596
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: CLOSED_AT });
597
+ await services.membership.handleAccountReopened({ userId: ownerId });
598
+ expect(await services.purge.listDueRooms(100)).toEqual([]);
599
+ expect(await services.purge.purgeRoom(room.id)).toBe(false);
600
+ expect(await readRoom(room.id)).not.toBeNull();
601
+ });
602
+ });
603
+ describe('ChatService.listRoomMembers with a closed account (real PG)', () => {
604
+ it('keeps the closed member on the roster, flagged', async () => {
605
+ const services = makeServices();
606
+ const ownerId = randomUUID();
607
+ const memberId = randomUUID();
608
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
609
+ await services.membership.handleAccountClosed({ userId: ownerId, closedAt: new Date() });
610
+ const members = await services.chat.listRoomMembers({ roomId: room.id, viewerId: memberId });
611
+ expect(members).toEqual(expect.arrayContaining([
612
+ expect.objectContaining({ userId: ownerId, isDeletedAccount: true }),
613
+ expect.objectContaining({ userId: memberId, isDeletedAccount: false }),
614
+ ]));
615
+ });
616
+ });
617
+ describe('ChatRoomPurgeService (real PG)', () => {
618
+ async function seedRoom(overrides) {
619
+ const [row] = await db.drizzle.db
620
+ .insert(chatRoom)
621
+ .values({ name: 'Room', slug: `room-${randomUUID()}`, isPublic: false, ...overrides })
622
+ .returning();
623
+ return row;
624
+ }
625
+ it('hard-deletes a room past its deadline together with everything hanging off it', async () => {
626
+ const services = makeServices();
627
+ const ownerId = randomUUID();
628
+ const memberId = randomUUID();
629
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
630
+ await services.chat.sendRoomMessage({
631
+ userId: memberId,
632
+ username: 'bob',
633
+ roomId: room.id,
634
+ content: 'goodbye',
635
+ });
636
+ await db.drizzle.db.insert(chatPlatformBan).values({
637
+ userId: memberId,
638
+ bannedBy: ownerId,
639
+ scope: 'room',
640
+ roomId: room.id,
641
+ reason: 'spam',
642
+ });
643
+ await db.drizzle.db
644
+ .insert(chatMute)
645
+ .values({ userId: memberId, roomId: room.id, mutedBy: ownerId, reason: 'spam' });
646
+ await db.drizzle.db
647
+ .update(chatRoom)
648
+ .set({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) })
649
+ .where(eq(chatRoom.id, room.id));
650
+ await expect(services.purge.purgeRoom(room.id)).resolves.toBe(true);
651
+ expect(await readRoom(room.id)).toBeNull();
652
+ expect(await db.drizzle.db.select().from(chatMessage).where(eq(chatMessage.roomId, room.id))).toHaveLength(0);
653
+ expect(await db.drizzle.db.select().from(chatRoomMember).where(eq(chatRoomMember.roomId, room.id))).toHaveLength(0);
654
+ expect(await db.drizzle.db.select().from(chatPlatformBan).where(eq(chatPlatformBan.roomId, room.id))).toHaveLength(0);
655
+ expect(await db.drizzle.db.select().from(chatMute).where(eq(chatMute.roomId, room.id))).toHaveLength(0);
656
+ });
657
+ it('leaves a public room, a null-deadline room and a future-deadline room untouched', async () => {
658
+ const services = makeServices();
659
+ const publicRoom = await seedRoom({
660
+ isPublic: true,
661
+ scheduledDeletionAt: new Date(Date.now() - DAY_MS),
662
+ });
663
+ const noDeadline = await seedRoom({});
664
+ const future = await seedRoom({ scheduledDeletionAt: new Date(Date.now() + DAY_MS) });
665
+ const soft = await seedRoom({
666
+ deletedAt: new Date(),
667
+ scheduledDeletionAt: new Date(Date.now() - DAY_MS),
668
+ });
669
+ await expect(services.purge.listDueRooms(100)).resolves.toEqual([]);
670
+ for (const room of [publicRoom, noDeadline, future, soft]) {
671
+ await expect(services.purge.purgeRoom(room.id)).resolves.toBe(false);
672
+ expect(await readRoom(room.id)).not.toBeNull();
673
+ }
674
+ });
675
+ it('emits the purge event carrying the message count', async () => {
676
+ const services = makeServices();
677
+ const ownerId = randomUUID();
678
+ const memberId = randomUUID();
679
+ const room = await seedPrivateRoom(services, ownerId, [memberId]);
680
+ for (const content of ['one', 'two']) {
681
+ await services.chat.sendRoomMessage({
682
+ userId: memberId,
683
+ username: 'bob',
684
+ roomId: room.id,
685
+ content,
686
+ });
687
+ }
688
+ await db.drizzle.db
689
+ .update(chatRoom)
690
+ .set({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) })
691
+ .where(eq(chatRoom.id, room.id));
692
+ await services.purge.purgeRoom(room.id);
693
+ expect(services.audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
694
+ actorType: 'system',
695
+ action: 'chat.private_room.purged',
696
+ resourceType: 'chat_room',
697
+ resourceId: room.id,
698
+ after: { messageCount: 2 },
699
+ }));
700
+ expect(services.events.emit).toHaveBeenCalledWith('chat.private_room.purged', {
701
+ roomId: room.id,
702
+ messageCount: 2,
703
+ });
704
+ });
705
+ it('takes the room down with the audit write when the audit write fails', async () => {
706
+ const services = makeServices();
707
+ const room = await seedRoom({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) });
708
+ vi.mocked(services.audit.recordInTransaction).mockRejectedValueOnce(new Error('audit down'));
709
+ await expect(services.purge.purgeRoom(room.id)).rejects.toThrow('audit down');
710
+ expect(await readRoom(room.id)).not.toBeNull();
711
+ });
712
+ it('hands out only what is due, leaving the rest of the countdown running', async () => {
713
+ const services = makeServices();
714
+ const due = await seedRoom({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) });
715
+ const notYet = await seedRoom({ scheduledDeletionAt: new Date(Date.now() + 30 * DAY_MS) });
716
+ await expect(services.purge.listDueRooms(100)).resolves.toEqual([due.id]);
717
+ await services.purge.purgeRoom(due.id);
718
+ expect(await readRoom(due.id)).toBeNull();
719
+ expect(await readRoom(notYet.id)).not.toBeNull();
720
+ });
721
+ it('caps one tick at the batch limit, oldest deadline first', async () => {
722
+ const services = makeServices();
723
+ const oldest = await seedRoom({ scheduledDeletionAt: new Date(Date.now() - 3 * DAY_MS) });
724
+ const middle = await seedRoom({ scheduledDeletionAt: new Date(Date.now() - 2 * DAY_MS) });
725
+ await seedRoom({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) });
726
+ await expect(services.purge.listDueRooms(2)).resolves.toEqual([oldest.id, middle.id]);
727
+ });
728
+ it('is a no-op the second time a job runs for the same room', async () => {
729
+ const services = makeServices();
730
+ const room = await seedRoom({ scheduledDeletionAt: new Date(Date.now() - DAY_MS) });
731
+ await expect(services.purge.purgeRoom(room.id)).resolves.toBe(true);
732
+ await expect(services.purge.purgeRoom(room.id)).resolves.toBe(false);
733
+ expect(services.audit.recordInTransaction).toHaveBeenCalledTimes(1);
734
+ });
735
+ });
736
+ //# sourceMappingURL=room-ownership-handover.int.test.js.map