@openora/core 0.4.1-canary.77 → 0.4.1-canary.79

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 (123) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +2 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +2 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/__tests__/audit.service.int.test.js +44 -0
  7. package/dist/audit/__tests__/audit.service.int.test.js.map +1 -1
  8. package/dist/audit/plugin.d.ts +2 -0
  9. package/dist/audit/plugin.d.ts.map +1 -1
  10. package/dist/audit/plugin.js +36 -0
  11. package/dist/audit/plugin.js.map +1 -1
  12. package/dist/casino/gaming/plugin.d.ts +2 -0
  13. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  14. package/dist/casino/lobby/plugin.d.ts +2 -0
  15. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  16. package/dist/cms/plugin.d.ts +2 -0
  17. package/dist/cms/plugin.d.ts.map +1 -1
  18. package/dist/compliance/plugin.d.ts +2 -0
  19. package/dist/compliance/plugin.d.ts.map +1 -1
  20. package/dist/contracts/adapters/index.d.ts +4 -0
  21. package/dist/contracts/adapters/index.d.ts.map +1 -1
  22. package/dist/contracts/adapters/index.js +2 -0
  23. package/dist/contracts/adapters/index.js.map +1 -1
  24. package/dist/contracts/adapters/player-activity.d.ts +6 -0
  25. package/dist/contracts/adapters/player-activity.d.ts.map +1 -0
  26. package/dist/contracts/adapters/player-activity.js +3 -0
  27. package/dist/contracts/adapters/player-activity.js.map +1 -0
  28. package/dist/contracts/adapters/social-commands.d.ts +12 -0
  29. package/dist/contracts/adapters/social-commands.d.ts.map +1 -0
  30. package/dist/contracts/adapters/social-commands.js +7 -0
  31. package/dist/contracts/adapters/social-commands.js.map +1 -0
  32. package/dist/contracts/schemas/events.d.ts +26 -0
  33. package/dist/contracts/schemas/events.d.ts.map +1 -1
  34. package/dist/contracts/schemas/events.js +17 -9
  35. package/dist/contracts/schemas/events.js.map +1 -1
  36. package/dist/engagement/chat/__tests__/chat.service.int.test.js +123 -2
  37. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  38. package/dist/engagement/chat/contract/index.d.ts +4 -0
  39. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  40. package/dist/engagement/chat/contract/index.js +2 -0
  41. package/dist/engagement/chat/contract/index.js.map +1 -1
  42. package/dist/engagement/chat/plugin.d.ts +2 -0
  43. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  44. package/dist/engagement/chat/plugin.js +2 -2
  45. package/dist/engagement/chat/plugin.js.map +1 -1
  46. package/dist/engagement/chat/router/index.d.ts +2 -0
  47. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  48. package/dist/engagement/chat/service/chat.service.d.ts +6 -2
  49. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  50. package/dist/engagement/chat/service/chat.service.js +29 -8
  51. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  52. package/dist/engagement/chat-commands/plugin.d.ts +2 -0
  53. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  54. package/dist/engagement/notifications/plugin.d.ts +2 -0
  55. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  56. package/dist/engagement/social/__tests__/social.plugin.int.test.d.ts +2 -0
  57. package/dist/engagement/social/__tests__/social.plugin.int.test.d.ts.map +1 -0
  58. package/dist/engagement/social/__tests__/social.plugin.int.test.js +106 -0
  59. package/dist/engagement/social/__tests__/social.plugin.int.test.js.map +1 -0
  60. package/dist/engagement/social/__tests__/social.router.int.test.js +124 -2
  61. package/dist/engagement/social/__tests__/social.router.int.test.js.map +1 -1
  62. package/dist/engagement/social/__tests__/social.service.int.test.js +587 -6
  63. package/dist/engagement/social/__tests__/social.service.int.test.js.map +1 -1
  64. package/dist/engagement/social/contract/index.d.ts +114 -0
  65. package/dist/engagement/social/contract/index.d.ts.map +1 -1
  66. package/dist/engagement/social/contract/index.js +53 -0
  67. package/dist/engagement/social/contract/index.js.map +1 -1
  68. package/dist/engagement/social/drizzle/migrations/0003_fuzzy_shadow_king.sql +4 -0
  69. package/dist/engagement/social/drizzle/migrations/0004_friendship-cancel-constraint.sql +2 -0
  70. package/dist/engagement/social/drizzle/migrations/meta/0003_snapshot.json +145 -0
  71. package/dist/engagement/social/drizzle/migrations/meta/0004_snapshot.json +145 -0
  72. package/dist/engagement/social/drizzle/migrations/meta/_journal.json +14 -0
  73. package/dist/engagement/social/plugin.d.ts +52 -50
  74. package/dist/engagement/social/plugin.d.ts.map +1 -1
  75. package/dist/engagement/social/plugin.js +8 -7
  76. package/dist/engagement/social/plugin.js.map +1 -1
  77. package/dist/engagement/social/router/index.d.ts +67 -0
  78. package/dist/engagement/social/router/index.d.ts.map +1 -1
  79. package/dist/engagement/social/router/index.js +38 -1
  80. package/dist/engagement/social/router/index.js.map +1 -1
  81. package/dist/engagement/social/schema/index.d.ts +17 -0
  82. package/dist/engagement/social/schema/index.d.ts.map +1 -1
  83. package/dist/engagement/social/schema/index.js +5 -17
  84. package/dist/engagement/social/schema/index.js.map +1 -1
  85. package/dist/engagement/social/service/social.service.d.ts +32 -18
  86. package/dist/engagement/social/service/social.service.d.ts.map +1 -1
  87. package/dist/engagement/social/service/social.service.js +283 -48
  88. package/dist/engagement/social/service/social.service.js.map +1 -1
  89. package/dist/engagement/social-transfers/plugin.d.ts +2 -0
  90. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -1
  91. package/dist/iam/plugin.d.ts +2 -0
  92. package/dist/iam/plugin.d.ts.map +1 -1
  93. package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
  94. package/dist/pam/identity/adapters/identity-reader.service.js +5 -2
  95. package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
  96. package/dist/pam/identity/plugin.d.ts +2 -0
  97. package/dist/pam/identity/plugin.d.ts.map +1 -1
  98. package/dist/pam/player-management/__tests__/player.service.int.test.js +29 -0
  99. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  100. package/dist/pam/player-management/plugin.d.ts +2 -0
  101. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  102. package/dist/pam/player-management/plugin.js +6 -4
  103. package/dist/pam/player-management/plugin.js.map +1 -1
  104. package/dist/pam/player-management/service/player.service.d.ts +3 -2
  105. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  106. package/dist/pam/player-management/service/player.service.js +7 -1
  107. package/dist/pam/player-management/service/player.service.js.map +1 -1
  108. package/dist/pam/player-note/plugin.d.ts +2 -0
  109. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  110. package/dist/pam/profile/plugin.d.ts +2 -0
  111. package/dist/pam/profile/plugin.d.ts.map +1 -1
  112. package/dist/pam/tag/plugin.d.ts +2 -0
  113. package/dist/pam/tag/plugin.d.ts.map +1 -1
  114. package/dist/server/runtime/core-token-catalog.d.ts +2 -0
  115. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  116. package/dist/server/runtime/core-token-catalog.js +3 -1
  117. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  118. package/dist/server/runtime/create-app.d.ts.map +1 -1
  119. package/dist/server/runtime/create-app.js +7 -1
  120. package/dist/server/runtime/create-app.js.map +1 -1
  121. package/dist/wallet/plugin.d.ts +2 -0
  122. package/dist/wallet/plugin.d.ts.map +1 -1
  123. package/package.json +1 -1
@@ -1,19 +1,31 @@
1
1
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { sql } from 'drizzle-orm';
3
+ import { eq, sql } from 'drizzle-orm';
4
4
  import { createTestDb } from '@openora/core/testing';
5
5
  import { migrate as migrateChat } from '@openora/core/engagement/migrate/chat';
6
- import { chatUserBlock } from '@openora/core/engagement/schema/chat';
6
+ import { chatUserBlock, chatUserIgnore } from '@openora/core/engagement/schema/chat';
7
7
  import { player } from '@openora/core/pam/schema/profile';
8
8
  import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile';
9
- import { makeEventBus } from '../../../testing/mock.js';
9
+ import { makeEventBus, makeIdentityReader } from '../../../testing/mock.js';
10
10
  import { migrate } from '../migrate.js';
11
11
  import { friendship } from '../schema/index.js';
12
- import { SocialService, SelfFriendRequestError, FriendRequestTargetNotFoundError, FriendRequestUnavailableError, AlreadyFriendsError, RequestAlreadyPendingError, FriendRequestRefusedError, BlockedBySelfError, } from '../service/social.service.js';
12
+ import { SocialService, SelfFriendRequestError, FriendRequestTargetNotFoundError, FriendRequestUnavailableError, AlreadyFriendsError, RequestAlreadyPendingError, FriendRequestRefusedError, BlockedBySelfError, FriendshipNotFoundError, FriendRequestNotFoundError, } from '../service/social.service.js';
13
13
  let db;
14
+ function realIdentityReader() {
15
+ return {
16
+ ...makeIdentityReader(),
17
+ getPlayerIdByUserIdSafe: async (userId) => {
18
+ const [row] = await db.drizzle.db
19
+ .select({ id: player.id })
20
+ .from(player)
21
+ .where(eq(player.userId, userId));
22
+ return row?.id ?? null;
23
+ },
24
+ };
25
+ }
14
26
  function makeService() {
15
27
  const events = makeEventBus();
16
- return { svc: new SocialService(db.drizzle, events), events };
28
+ return { svc: new SocialService(db.drizzle, events, realIdentityReader()), events };
17
29
  }
18
30
  async function seedPlayer(overrides = {}) {
19
31
  const [row] = await db.drizzle.db
@@ -25,6 +37,9 @@ async function seedPlayer(overrides = {}) {
25
37
  async function seedBlock(blockerId, blockedId) {
26
38
  await db.drizzle.db.insert(chatUserBlock).values({ blockerId, blockedId });
27
39
  }
40
+ async function seedIgnore(ignorerId, ignoredId) {
41
+ await db.drizzle.db.insert(chatUserIgnore).values({ ignorerId, ignoredId });
42
+ }
28
43
  beforeAll(async () => {
29
44
  db = await createTestDb([migrate, migrateProfile, migrateChat]);
30
45
  });
@@ -32,7 +47,7 @@ afterAll(async () => {
32
47
  await db.drop();
33
48
  });
34
49
  beforeEach(async () => {
35
- await db.drizzle.db.execute(sql `TRUNCATE ${friendship}, ${chatUserBlock}, ${player} RESTART IDENTITY CASCADE`);
50
+ await db.drizzle.db.execute(sql `TRUNCATE ${friendship}, ${chatUserBlock}, ${chatUserIgnore}, ${player} RESTART IDENTITY CASCADE`);
36
51
  });
37
52
  describe('SocialService.sendFriendRequest (real PG)', () => {
38
53
  it('inserts a pending row and emits social.friend_request.sent', async () => {
@@ -169,6 +184,21 @@ describe('SocialService.getRelationships (real PG)', () => {
169
184
  { userId: other.userId, status: 'none', friendshipId: null, canSendRequest: true },
170
185
  ]);
171
186
  });
187
+ it('returns none + canSendRequest true for both sides after a decline (regression: previously stuck on refused/canSendRequest:false forever)', async () => {
188
+ const { svc } = makeService();
189
+ const requester = await seedPlayer();
190
+ const addressee = await seedPlayer();
191
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
192
+ await svc.declineFriendRequest(addressee.userId, request.id);
193
+ const fromRequester = await svc.getRelationships(requester.userId, [addressee.userId]);
194
+ const fromAddressee = await svc.getRelationships(addressee.userId, [requester.userId]);
195
+ expect(fromRequester).toEqual([
196
+ { userId: addressee.userId, status: 'none', friendshipId: null, canSendRequest: true },
197
+ ]);
198
+ expect(fromAddressee).toEqual([
199
+ { userId: requester.userId, status: 'none', friendshipId: null, canSendRequest: true },
200
+ ]);
201
+ });
172
202
  it('returns unavailable for a nonexistent user', async () => {
173
203
  const { svc } = makeService();
174
204
  const caller = await seedPlayer();
@@ -230,4 +260,555 @@ describe('SocialService.getRelationships (real PG)', () => {
230
260
  expect(result.map((r) => r.userId)).toEqual([b.userId, a.userId, b.userId]);
231
261
  });
232
262
  });
263
+ async function makeFriends(svc, aUserId, bUserId) {
264
+ const first = await svc.sendFriendRequest(aUserId, bUserId);
265
+ await svc.sendFriendRequest(bUserId, aUserId); // mutual auto-accept
266
+ return first;
267
+ }
268
+ describe('SocialService.removeFriend (real PG)', () => {
269
+ it('dissolves an active friendship (caller as requester) and emits social.friendship.removed', async () => {
270
+ const { svc, events } = makeService();
271
+ const alice = await seedPlayer();
272
+ const bob = await seedPlayer();
273
+ const friendshipRow = await makeFriends(svc, alice.userId, bob.userId);
274
+ events.emit.mockClear();
275
+ await svc.removeFriend(alice.userId, bob.userId);
276
+ expect(events.emit).toHaveBeenCalledWith('social.friendship.removed', {
277
+ friendshipId: friendshipRow.id,
278
+ actorId: alice.userId,
279
+ actorPlayerId: alice.id,
280
+ otherUserId: bob.userId,
281
+ reason: 'removed_by_player',
282
+ });
283
+ const [row] = await db.drizzle.db.select().from(friendship);
284
+ expect(row?.removedAt).toBeInstanceOf(Date);
285
+ });
286
+ it('dissolves an active friendship (caller as addressee) and emits social.friendship.removed', async () => {
287
+ const { svc, events } = makeService();
288
+ const alice = await seedPlayer();
289
+ const bob = await seedPlayer();
290
+ const friendshipRow = await makeFriends(svc, alice.userId, bob.userId);
291
+ events.emit.mockClear();
292
+ await svc.removeFriend(bob.userId, alice.userId);
293
+ expect(events.emit).toHaveBeenCalledWith('social.friendship.removed', {
294
+ friendshipId: friendshipRow.id,
295
+ actorId: bob.userId,
296
+ actorPlayerId: bob.id,
297
+ otherUserId: alice.userId,
298
+ reason: 'removed_by_player',
299
+ });
300
+ });
301
+ it('emits a null actorPlayerId when the caller has no player row (BF-427 audit fix)', async () => {
302
+ const { svc, events } = makeService();
303
+ const alice = await seedPlayer();
304
+ const staffCallerId = randomUUID(); // an identity userId with no `player` row
305
+ await db.drizzle.db.insert(friendship).values({
306
+ requesterId: staffCallerId,
307
+ addresseeId: alice.userId,
308
+ acceptedAt: new Date(),
309
+ });
310
+ await svc.removeFriend(staffCallerId, alice.userId);
311
+ expect(events.emit).toHaveBeenCalledWith('social.friendship.removed', expect.objectContaining({ actorId: staffCallerId, actorPlayerId: null }));
312
+ });
313
+ it('throws FriendshipNotFoundError when the pair was never friended', async () => {
314
+ const { svc, events } = makeService();
315
+ const alice = await seedPlayer();
316
+ const bob = await seedPlayer();
317
+ await expect(svc.removeFriend(alice.userId, bob.userId)).rejects.toBeInstanceOf(FriendshipNotFoundError);
318
+ expect(events.emit).not.toHaveBeenCalled();
319
+ });
320
+ it('throws FriendshipNotFoundError when the request is still pending', async () => {
321
+ const { svc } = makeService();
322
+ const alice = await seedPlayer();
323
+ const bob = await seedPlayer();
324
+ await svc.sendFriendRequest(alice.userId, bob.userId);
325
+ await expect(svc.removeFriend(alice.userId, bob.userId)).rejects.toBeInstanceOf(FriendshipNotFoundError);
326
+ });
327
+ it('throws FriendshipNotFoundError when the request was refused', async () => {
328
+ const { svc } = makeService();
329
+ const alice = await seedPlayer();
330
+ const bob = await seedPlayer();
331
+ await db.drizzle.db
332
+ .insert(friendship)
333
+ .values({ requesterId: alice.userId, addresseeId: bob.userId, refusedAt: new Date() });
334
+ await expect(svc.removeFriend(alice.userId, bob.userId)).rejects.toBeInstanceOf(FriendshipNotFoundError);
335
+ });
336
+ it('throws FriendshipNotFoundError when already removed, and allows re-friending afterward (partial index)', async () => {
337
+ const { svc } = makeService();
338
+ const alice = await seedPlayer();
339
+ const bob = await seedPlayer();
340
+ await makeFriends(svc, alice.userId, bob.userId);
341
+ await svc.removeFriend(alice.userId, bob.userId);
342
+ await expect(svc.removeFriend(alice.userId, bob.userId)).rejects.toBeInstanceOf(FriendshipNotFoundError);
343
+ const resent = await svc.sendFriendRequest(alice.userId, bob.userId);
344
+ expect(resent.acceptedAt).toBeNull();
345
+ const rows = await db.drizzle.db.select().from(friendship);
346
+ expect(rows).toHaveLength(2);
347
+ });
348
+ });
349
+ describe('SocialService.dissolveFriendshipOnBlock (real PG)', () => {
350
+ it('silently no-ops when no friendship exists', async () => {
351
+ const { svc, events } = makeService();
352
+ const alice = await seedPlayer();
353
+ const bob = await seedPlayer();
354
+ await expect(svc.dissolveFriendshipOnBlock(db.drizzle.db, alice.userId, bob.userId)).resolves.toBeNull();
355
+ expect(events.emit).not.toHaveBeenCalled();
356
+ });
357
+ it('silently no-ops when the request is only pending (not yet accepted)', async () => {
358
+ const { svc, events } = makeService();
359
+ const alice = await seedPlayer();
360
+ const bob = await seedPlayer();
361
+ await svc.sendFriendRequest(alice.userId, bob.userId);
362
+ events.emit.mockClear();
363
+ await expect(svc.dissolveFriendshipOnBlock(db.drizzle.db, alice.userId, bob.userId)).resolves.toBeNull();
364
+ expect(events.emit).not.toHaveBeenCalled();
365
+ const [row] = await db.drizzle.db.select().from(friendship);
366
+ expect(row?.removedAt).toBeNull();
367
+ });
368
+ it('dissolves an active friendship and returns the removal payload, without emitting yet', async () => {
369
+ const { svc, events } = makeService();
370
+ const alice = await seedPlayer();
371
+ const bob = await seedPlayer();
372
+ const friendshipRow = await makeFriends(svc, alice.userId, bob.userId);
373
+ events.emit.mockClear();
374
+ const dissolved = await svc.dissolveFriendshipOnBlock(db.drizzle.db, alice.userId, bob.userId);
375
+ // The row commits (or, on a caller-owned tx, may still roll back) before the
376
+ // caller decides whether to publish - dissolveFriendshipOnBlock must never emit
377
+ // itself, or a rollback after this call would ship a "ghost event".
378
+ expect(dissolved).toEqual({
379
+ friendshipId: friendshipRow.id,
380
+ actorId: alice.userId,
381
+ actorPlayerId: alice.id,
382
+ otherUserId: bob.userId,
383
+ reason: 'blocked',
384
+ });
385
+ expect(events.emit).not.toHaveBeenCalled();
386
+ const [row] = await db.drizzle.db.select().from(friendship);
387
+ expect(row?.removedAt).toBeInstanceOf(Date);
388
+ });
389
+ });
390
+ describe('SocialService.listFriends (real PG)', () => {
391
+ it('returns only accepted, non-removed friendships involving the caller', async () => {
392
+ const { svc } = makeService();
393
+ const caller = await seedPlayer({ displayName: 'Caller' });
394
+ const friend = await seedPlayer({ displayName: 'Friend' });
395
+ const pending = await seedPlayer({ displayName: 'Pending' });
396
+ const refusedPlayer = await seedPlayer({ displayName: 'Refused' });
397
+ const removedFriend = await seedPlayer({ displayName: 'Removed' });
398
+ const strangerA = await seedPlayer({ displayName: 'StrangerA' });
399
+ const strangerB = await seedPlayer({ displayName: 'StrangerB' });
400
+ await makeFriends(svc, caller.userId, friend.userId);
401
+ await svc.sendFriendRequest(caller.userId, pending.userId);
402
+ await db.drizzle.db.insert(friendship).values({
403
+ requesterId: caller.userId,
404
+ addresseeId: refusedPlayer.userId,
405
+ refusedAt: new Date(),
406
+ });
407
+ await makeFriends(svc, caller.userId, removedFriend.userId);
408
+ await svc.removeFriend(caller.userId, removedFriend.userId);
409
+ await makeFriends(svc, strangerA.userId, strangerB.userId);
410
+ const result = await svc.listFriends(caller.userId, { page: 1, limit: 20 });
411
+ expect(result.total).toBe(1);
412
+ expect(result.items).toHaveLength(1);
413
+ expect(result.items[0]).toMatchObject({
414
+ userId: friend.userId,
415
+ displayName: 'Friend',
416
+ status: 'offline',
417
+ lastSeenAt: null,
418
+ isIgnored: false,
419
+ });
420
+ });
421
+ it("derives isIgnored from the CALLER's own ignore state, not the friend's", async () => {
422
+ const { svc } = makeService();
423
+ const caller = await seedPlayer({ displayName: 'Caller' });
424
+ const ignoredFriend = await seedPlayer({ displayName: 'IgnoredFriend' });
425
+ const plainFriend = await seedPlayer({ displayName: 'PlainFriend' });
426
+ await makeFriends(svc, caller.userId, ignoredFriend.userId);
427
+ await makeFriends(svc, caller.userId, plainFriend.userId);
428
+ await seedIgnore(caller.userId, ignoredFriend.userId);
429
+ await seedIgnore(plainFriend.userId, caller.userId);
430
+ const result = await svc.listFriends(caller.userId, { page: 1, limit: 20 });
431
+ const byUserId = new Map(result.items.map((i) => [i.userId, i]));
432
+ expect(byUserId.get(ignoredFriend.userId)).toMatchObject({ isIgnored: true });
433
+ expect(byUserId.get(plainFriend.userId)).toMatchObject({ isIgnored: false });
434
+ });
435
+ it('derives status: online when lastSeenAt is within the online window, offline otherwise', async () => {
436
+ const { svc } = makeService();
437
+ const caller = await seedPlayer();
438
+ const recentlyActive = await seedPlayer({ lastSeenAt: new Date() });
439
+ const staleActive = await seedPlayer({
440
+ lastSeenAt: new Date(Date.now() - 10 * 60 * 1000),
441
+ });
442
+ await makeFriends(svc, caller.userId, recentlyActive.userId);
443
+ await makeFriends(svc, caller.userId, staleActive.userId);
444
+ const result = await svc.listFriends(caller.userId, { page: 1, limit: 20 });
445
+ const byUserId = new Map(result.items.map((i) => [i.userId, i]));
446
+ expect(byUserId.get(recentlyActive.userId)).toMatchObject({ status: 'online' });
447
+ expect(typeof byUserId.get(recentlyActive.userId)?.lastSeenAt).toBe('string');
448
+ expect(byUserId.get(staleActive.userId)).toMatchObject({ status: 'offline' });
449
+ });
450
+ it('paginates results', async () => {
451
+ const { svc } = makeService();
452
+ const caller = await seedPlayer();
453
+ for (let i = 0; i < 3; i++) {
454
+ const friend = await seedPlayer({ displayName: `Friend${i}` });
455
+ await makeFriends(svc, caller.userId, friend.userId);
456
+ }
457
+ const page1 = await svc.listFriends(caller.userId, { page: 1, limit: 2 });
458
+ const page2 = await svc.listFriends(caller.userId, { page: 2, limit: 2 });
459
+ expect(page1.total).toBe(3);
460
+ expect(page1.items).toHaveLength(2);
461
+ expect(page2.items).toHaveLength(1);
462
+ const allIds = new Set([...page1.items, ...page2.items].map((i) => i.userId));
463
+ expect(allIds.size).toBe(3);
464
+ });
465
+ it('drops an entry whose player row is missing instead of rendering a blank name', async () => {
466
+ const { svc } = makeService();
467
+ const caller = await seedPlayer({ displayName: 'Caller' });
468
+ const friend = await seedPlayer({ displayName: 'Friend' });
469
+ await makeFriends(svc, caller.userId, friend.userId);
470
+ await db.drizzle.db.insert(friendship).values({
471
+ requesterId: caller.userId,
472
+ addresseeId: randomUUID(),
473
+ acceptedAt: new Date(),
474
+ });
475
+ const result = await svc.listFriends(caller.userId, { page: 1, limit: 20 });
476
+ expect(result.items).toHaveLength(1);
477
+ expect(result.items[0]?.userId).toBe(friend.userId);
478
+ });
479
+ it.each(['suspended', 'closed'])('drops a friend whose account is %s', async (status) => {
480
+ const { svc } = makeService();
481
+ const caller = await seedPlayer({ displayName: 'Caller' });
482
+ const friend = await seedPlayer({ displayName: 'Friend' });
483
+ const unavailableFriend = await seedPlayer({ displayName: 'Unavailable' });
484
+ await makeFriends(svc, caller.userId, friend.userId);
485
+ await makeFriends(svc, caller.userId, unavailableFriend.userId);
486
+ await db.drizzle.db
487
+ .update(player)
488
+ .set({ status })
489
+ .where(eq(player.userId, unavailableFriend.userId));
490
+ const result = await svc.listFriends(caller.userId, { page: 1, limit: 20 });
491
+ expect(result.items.map((i) => i.userId)).toEqual([friend.userId]);
492
+ });
493
+ });
494
+ describe('SocialService.acceptFriendRequest (real PG)', () => {
495
+ it('accepts a pending incoming request and emits social.friend_request.accepted', async () => {
496
+ const { svc, events } = makeService();
497
+ const requester = await seedPlayer({ displayName: 'Alice' });
498
+ const addressee = await seedPlayer({ displayName: 'Bob' });
499
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
500
+ events.emit.mockClear();
501
+ const result = await svc.acceptFriendRequest(addressee.userId, request.id);
502
+ expect(result.id).toBe(request.id);
503
+ expect(result.acceptedAt).toEqual(expect.any(String));
504
+ expect(events.emit).toHaveBeenCalledWith('social.friend_request.accepted', {
505
+ friendshipId: request.id,
506
+ requesterId: requester.userId,
507
+ addresseeId: addressee.userId,
508
+ accepterId: addressee.userId,
509
+ accepterDisplayName: 'Bob',
510
+ });
511
+ const [row] = await db.drizzle.db
512
+ .select()
513
+ .from(friendship)
514
+ .where(eq(friendship.id, request.id));
515
+ expect(row?.acceptedAt).toBeInstanceOf(Date);
516
+ });
517
+ it('throws FriendRequestNotFoundError when the caller is not the addressee', async () => {
518
+ const { svc, events } = makeService();
519
+ const requester = await seedPlayer();
520
+ const addressee = await seedPlayer();
521
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
522
+ events.emit.mockClear();
523
+ // The requester (wrong side) tries to accept their own outgoing request.
524
+ await expect(svc.acceptFriendRequest(requester.userId, request.id)).rejects.toBeInstanceOf(FriendRequestNotFoundError);
525
+ expect(events.emit).not.toHaveBeenCalled();
526
+ });
527
+ it('throws FriendRequestNotFoundError on a nonexistent friendshipId', async () => {
528
+ const { svc } = makeService();
529
+ const addressee = await seedPlayer();
530
+ await expect(svc.acceptFriendRequest(addressee.userId, randomUUID())).rejects.toBeInstanceOf(FriendRequestNotFoundError);
531
+ });
532
+ it('throws FriendRequestNotFoundError on a double-accept', async () => {
533
+ const { svc, events } = makeService();
534
+ const requester = await seedPlayer();
535
+ const addressee = await seedPlayer();
536
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
537
+ await svc.acceptFriendRequest(addressee.userId, request.id);
538
+ events.emit.mockClear();
539
+ await expect(svc.acceptFriendRequest(addressee.userId, request.id)).rejects.toBeInstanceOf(FriendRequestNotFoundError);
540
+ expect(events.emit).not.toHaveBeenCalled();
541
+ });
542
+ it.each([
543
+ [
544
+ 'addressee blocked requester',
545
+ (requesterId, addresseeId) => seedBlock(addresseeId, requesterId),
546
+ ],
547
+ [
548
+ 'requester blocked addressee',
549
+ (requesterId, addresseeId) => seedBlock(requesterId, addresseeId),
550
+ ],
551
+ ])('throws FriendRequestUnavailableError when a block landed after the request was sent (%s)', async (_label, seedTheBlock) => {
552
+ const { svc, events } = makeService();
553
+ const requester = await seedPlayer();
554
+ const addressee = await seedPlayer();
555
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
556
+ await seedTheBlock(requester.userId, addressee.userId);
557
+ events.emit.mockClear();
558
+ await expect(svc.acceptFriendRequest(addressee.userId, request.id)).rejects.toBeInstanceOf(FriendRequestUnavailableError);
559
+ expect(events.emit).not.toHaveBeenCalled();
560
+ const [row] = await db.drizzle.db
561
+ .select()
562
+ .from(friendship)
563
+ .where(eq(friendship.id, request.id));
564
+ expect(row?.acceptedAt).toBeNull();
565
+ });
566
+ });
567
+ describe('SocialService.declineFriendRequest (real PG)', () => {
568
+ it('refuses a pending incoming request, emits social.friend_request.declined, and never emits a notification-triggering event', async () => {
569
+ const { svc, events } = makeService();
570
+ const requester = await seedPlayer();
571
+ const addressee = await seedPlayer();
572
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
573
+ events.emit.mockClear();
574
+ await svc.declineFriendRequest(addressee.userId, request.id);
575
+ expect(events.emit).toHaveBeenCalledWith('social.friend_request.declined', {
576
+ friendshipId: request.id,
577
+ requesterId: requester.userId,
578
+ addresseeId: addressee.userId,
579
+ });
580
+ expect(events.emit).not.toHaveBeenCalledWith('social.friend_request.accepted', expect.anything());
581
+ const [row] = await db.drizzle.db
582
+ .select()
583
+ .from(friendship)
584
+ .where(eq(friendship.id, request.id));
585
+ expect(row?.refusedAt).toBeNull();
586
+ expect(row?.removedAt).toBeInstanceOf(Date);
587
+ });
588
+ it('allows re-requesting the pair after a decline (regression: previously blocked forever)', async () => {
589
+ const { svc } = makeService();
590
+ const requester = await seedPlayer();
591
+ const addressee = await seedPlayer();
592
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
593
+ await svc.declineFriendRequest(addressee.userId, request.id);
594
+ const resent = await svc.sendFriendRequest(requester.userId, addressee.userId);
595
+ expect(resent.id).not.toBe(request.id);
596
+ expect(resent.acceptedAt).toBeNull();
597
+ const rows = await db.drizzle.db.select().from(friendship);
598
+ expect(rows).toHaveLength(2);
599
+ });
600
+ it('throws FriendRequestNotFoundError when the caller is not the addressee', async () => {
601
+ const { svc } = makeService();
602
+ const requester = await seedPlayer();
603
+ const addressee = await seedPlayer();
604
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
605
+ await expect(svc.declineFriendRequest(requester.userId, request.id)).rejects.toBeInstanceOf(FriendRequestNotFoundError);
606
+ });
607
+ });
608
+ describe('SocialService.cancelFriendRequest (real PG)', () => {
609
+ it('soft-removes a pending outgoing request, emits social.friend_request.cancelled, and never emits a notification-triggering event', async () => {
610
+ const { svc, events } = makeService();
611
+ const requester = await seedPlayer();
612
+ const addressee = await seedPlayer();
613
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
614
+ events.emit.mockClear();
615
+ await svc.cancelFriendRequest(requester.userId, request.id);
616
+ expect(events.emit).toHaveBeenCalledWith('social.friend_request.cancelled', {
617
+ friendshipId: request.id,
618
+ requesterId: requester.userId,
619
+ addresseeId: addressee.userId,
620
+ });
621
+ expect(events.emit).not.toHaveBeenCalledWith('social.friend_request.accepted', expect.anything());
622
+ const [row] = await db.drizzle.db
623
+ .select()
624
+ .from(friendship)
625
+ .where(eq(friendship.id, request.id));
626
+ expect(row?.removedAt).toBeInstanceOf(Date);
627
+ expect(row?.refusedAt).toBeNull();
628
+ });
629
+ it('allows re-requesting the pair after a cancel (new behavior enabled by the constraint change)', async () => {
630
+ const { svc } = makeService();
631
+ const requester = await seedPlayer();
632
+ const addressee = await seedPlayer();
633
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
634
+ await svc.cancelFriendRequest(requester.userId, request.id);
635
+ const resent = await svc.sendFriendRequest(requester.userId, addressee.userId);
636
+ expect(resent.id).not.toBe(request.id);
637
+ expect(resent.acceptedAt).toBeNull();
638
+ const rows = await db.drizzle.db.select().from(friendship);
639
+ expect(rows).toHaveLength(2);
640
+ });
641
+ it('throws FriendRequestNotFoundError when the caller is not the requester', async () => {
642
+ const { svc } = makeService();
643
+ const requester = await seedPlayer();
644
+ const addressee = await seedPlayer();
645
+ const request = await svc.sendFriendRequest(requester.userId, addressee.userId);
646
+ await expect(svc.cancelFriendRequest(addressee.userId, request.id)).rejects.toBeInstanceOf(FriendRequestNotFoundError);
647
+ });
648
+ });
649
+ describe('SocialService.listFriendRequests (real PG)', () => {
650
+ it('lists incoming pending requests, newest first, with mutualFriendsCount', async () => {
651
+ const { svc } = makeService();
652
+ const caller = await seedPlayer({ displayName: 'Caller' });
653
+ const senderA = await seedPlayer({ displayName: 'SenderA' });
654
+ const senderB = await seedPlayer({ displayName: 'SenderB' });
655
+ await svc.sendFriendRequest(senderA.userId, caller.userId);
656
+ await svc.sendFriendRequest(senderB.userId, caller.userId);
657
+ const result = await svc.listFriendRequests(caller.userId, {
658
+ direction: 'incoming',
659
+ page: 1,
660
+ limit: 20,
661
+ });
662
+ expect(result.total).toBe(2);
663
+ // newest first: senderB requested after senderA.
664
+ expect(result.items.map((i) => i.userId)).toEqual([senderB.userId, senderA.userId]);
665
+ for (const item of result.items) {
666
+ expect(item.direction).toBe('incoming');
667
+ expect(item.mutualFriendsCount).toBe(0);
668
+ }
669
+ });
670
+ it('lists outgoing pending requests with a null mutualFriendsCount', async () => {
671
+ const { svc } = makeService();
672
+ const caller = await seedPlayer({ displayName: 'Caller' });
673
+ const target = await seedPlayer({ displayName: 'Target' });
674
+ await svc.sendFriendRequest(caller.userId, target.userId);
675
+ const result = await svc.listFriendRequests(caller.userId, {
676
+ direction: 'outgoing',
677
+ page: 1,
678
+ limit: 20,
679
+ });
680
+ expect(result.total).toBe(1);
681
+ expect(result.items[0]).toMatchObject({
682
+ userId: target.userId,
683
+ displayName: 'Target',
684
+ direction: 'outgoing',
685
+ mutualFriendsCount: null,
686
+ });
687
+ });
688
+ it('excludes accepted, refused, and removed rows', async () => {
689
+ const { svc } = makeService();
690
+ const caller = await seedPlayer();
691
+ const accepted = await seedPlayer();
692
+ const refused = await seedPlayer();
693
+ const cancelled = await seedPlayer();
694
+ const pending = await seedPlayer({ displayName: 'StillPending' });
695
+ await makeFriends(svc, caller.userId, accepted.userId);
696
+ const refusedReq = await svc.sendFriendRequest(refused.userId, caller.userId);
697
+ await svc.declineFriendRequest(caller.userId, refusedReq.id);
698
+ const cancelledReq = await svc.sendFriendRequest(caller.userId, cancelled.userId);
699
+ await svc.cancelFriendRequest(caller.userId, cancelledReq.id);
700
+ await svc.sendFriendRequest(pending.userId, caller.userId);
701
+ const incoming = await svc.listFriendRequests(caller.userId, {
702
+ direction: 'incoming',
703
+ page: 1,
704
+ limit: 20,
705
+ });
706
+ expect(incoming.total).toBe(1);
707
+ expect(incoming.items[0]?.userId).toBe(pending.userId);
708
+ });
709
+ it.each(['suspended', 'closed'])('drops a pending sender whose account is %s', async (status) => {
710
+ const { svc } = makeService();
711
+ const caller = await seedPlayer();
712
+ const sender = await seedPlayer({ displayName: 'Sender' });
713
+ const unavailableSender = await seedPlayer({ displayName: 'Unavailable' });
714
+ await svc.sendFriendRequest(sender.userId, caller.userId);
715
+ await svc.sendFriendRequest(unavailableSender.userId, caller.userId);
716
+ await db.drizzle.db
717
+ .update(player)
718
+ .set({ status })
719
+ .where(eq(player.userId, unavailableSender.userId));
720
+ const result = await svc.listFriendRequests(caller.userId, {
721
+ direction: 'incoming',
722
+ page: 1,
723
+ limit: 20,
724
+ });
725
+ expect(result.items.map((i) => i.userId)).toEqual([sender.userId]);
726
+ });
727
+ it.each([
728
+ [
729
+ 'caller blocked the sender',
730
+ (callerId, senderId) => seedBlock(callerId, senderId),
731
+ ],
732
+ [
733
+ 'sender blocked the caller',
734
+ (callerId, senderId) => seedBlock(senderId, callerId),
735
+ ],
736
+ ])('drops an incoming pending sender who is blocked (%s)', async (_label, seedTheBlock) => {
737
+ const { svc } = makeService();
738
+ const caller = await seedPlayer();
739
+ const sender = await seedPlayer({ displayName: 'Sender' });
740
+ const blockedSender = await seedPlayer({ displayName: 'Blocked' });
741
+ await svc.sendFriendRequest(sender.userId, caller.userId);
742
+ await svc.sendFriendRequest(blockedSender.userId, caller.userId);
743
+ await seedTheBlock(caller.userId, blockedSender.userId);
744
+ const result = await svc.listFriendRequests(caller.userId, {
745
+ direction: 'incoming',
746
+ page: 1,
747
+ limit: 20,
748
+ });
749
+ expect(result.items.map((i) => i.userId)).toEqual([sender.userId]);
750
+ });
751
+ it('drops an outgoing pending target who is blocked', async () => {
752
+ const { svc } = makeService();
753
+ const caller = await seedPlayer();
754
+ const target = await seedPlayer({ displayName: 'Target' });
755
+ await svc.sendFriendRequest(caller.userId, target.userId);
756
+ await seedBlock(target.userId, caller.userId);
757
+ const result = await svc.listFriendRequests(caller.userId, {
758
+ direction: 'outgoing',
759
+ page: 1,
760
+ limit: 20,
761
+ });
762
+ expect(result.items).toEqual([]);
763
+ });
764
+ it('paginates results', async () => {
765
+ const { svc } = makeService();
766
+ const caller = await seedPlayer();
767
+ for (let i = 0; i < 3; i++) {
768
+ const sender = await seedPlayer({ displayName: `Sender${i}` });
769
+ await svc.sendFriendRequest(sender.userId, caller.userId);
770
+ }
771
+ const page1 = await svc.listFriendRequests(caller.userId, {
772
+ direction: 'incoming',
773
+ page: 1,
774
+ limit: 2,
775
+ });
776
+ const page2 = await svc.listFriendRequests(caller.userId, {
777
+ direction: 'incoming',
778
+ page: 2,
779
+ limit: 2,
780
+ });
781
+ expect(page1.total).toBe(3);
782
+ expect(page1.items).toHaveLength(2);
783
+ expect(page2.items).toHaveLength(1);
784
+ });
785
+ it('computes mutualFriendsCount via one batched query across exactly 2 shared accepted friends', async () => {
786
+ const { svc } = makeService();
787
+ const caller = await seedPlayer({ displayName: 'Caller' });
788
+ const requester = await seedPlayer({ displayName: 'Requester' });
789
+ const mutualA = await seedPlayer({ displayName: 'MutualA' });
790
+ const mutualB = await seedPlayer({ displayName: 'MutualB' });
791
+ const callerOnlyFriend = await seedPlayer({ displayName: 'CallerOnlyFriend' });
792
+ const requesterOnlyFriend = await seedPlayer({ displayName: 'RequesterOnlyFriend' });
793
+ // Shared friends: caller <-> mutualA/mutualB, requester <-> mutualA/mutualB.
794
+ await makeFriends(svc, caller.userId, mutualA.userId);
795
+ await makeFriends(svc, caller.userId, mutualB.userId);
796
+ await makeFriends(svc, requester.userId, mutualA.userId);
797
+ await makeFriends(svc, requester.userId, mutualB.userId);
798
+ // Non-shared friends on each side - must not inflate the count.
799
+ await makeFriends(svc, caller.userId, callerOnlyFriend.userId);
800
+ await makeFriends(svc, requester.userId, requesterOnlyFriend.userId);
801
+ await svc.sendFriendRequest(requester.userId, caller.userId);
802
+ const result = await svc.listFriendRequests(caller.userId, {
803
+ direction: 'incoming',
804
+ page: 1,
805
+ limit: 20,
806
+ });
807
+ expect(result.items).toHaveLength(1);
808
+ expect(result.items[0]).toMatchObject({
809
+ userId: requester.userId,
810
+ mutualFriendsCount: 2,
811
+ });
812
+ });
813
+ });
233
814
  //# sourceMappingURL=social.service.int.test.js.map