@open-mercato/enterprise 0.4.5-develop-2e9903a57a → 0.4.5-develop-754ef4d2f0

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 (32) hide show
  1. package/package.json +4 -4
  2. package/dist/modules/record_locks/__integration__/TC-LOCK-001.spec.js +0 -73
  3. package/dist/modules/record_locks/__integration__/TC-LOCK-001.spec.js.map +0 -7
  4. package/dist/modules/record_locks/__integration__/TC-LOCK-002.spec.js +0 -114
  5. package/dist/modules/record_locks/__integration__/TC-LOCK-002.spec.js.map +0 -7
  6. package/dist/modules/record_locks/__integration__/TC-LOCK-003.spec.js +0 -119
  7. package/dist/modules/record_locks/__integration__/TC-LOCK-003.spec.js.map +0 -7
  8. package/dist/modules/record_locks/__integration__/TC-LOCK-004.spec.js +0 -119
  9. package/dist/modules/record_locks/__integration__/TC-LOCK-004.spec.js.map +0 -7
  10. package/dist/modules/record_locks/__integration__/TC-LOCK-005.spec.js +0 -90
  11. package/dist/modules/record_locks/__integration__/TC-LOCK-005.spec.js.map +0 -7
  12. package/dist/modules/record_locks/__integration__/TC-LOCK-006.spec.js +0 -90
  13. package/dist/modules/record_locks/__integration__/TC-LOCK-006.spec.js.map +0 -7
  14. package/dist/modules/record_locks/__integration__/TC-LOCK-007.spec.js +0 -211
  15. package/dist/modules/record_locks/__integration__/TC-LOCK-007.spec.js.map +0 -7
  16. package/dist/modules/record_locks/__integration__/helpers/recordLocks.js +0 -219
  17. package/dist/modules/record_locks/__integration__/helpers/recordLocks.js.map +0 -7
  18. package/src/modules/record_locks/__integration__/TC-LOCK-001.spec.ts +0 -84
  19. package/src/modules/record_locks/__integration__/TC-LOCK-002.spec.ts +0 -129
  20. package/src/modules/record_locks/__integration__/TC-LOCK-003.spec.ts +0 -136
  21. package/src/modules/record_locks/__integration__/TC-LOCK-004.spec.ts +0 -136
  22. package/src/modules/record_locks/__integration__/TC-LOCK-005.spec.ts +0 -106
  23. package/src/modules/record_locks/__integration__/TC-LOCK-006.spec.ts +0 -113
  24. package/src/modules/record_locks/__integration__/TC-LOCK-007.spec.ts +0 -251
  25. package/src/modules/record_locks/__integration__/helpers/recordLocks.ts +0 -366
  26. package/src/modules/record_locks/__tests__/config.test.ts +0 -21
  27. package/src/modules/record_locks/__tests__/crudMutationGuardService.test.ts +0 -106
  28. package/src/modules/record_locks/__tests__/recordLockService.test.ts +0 -1226
  29. package/src/modules/record_locks/__tests__/recordLockWidgetHeaders.test.ts +0 -127
  30. package/src/modules/record_locks/api/__tests__/acquire.route.test.ts +0 -175
  31. package/src/modules/record_locks/api/__tests__/release.route.test.ts +0 -135
  32. package/src/modules/record_locks/api/__tests__/settings.route.test.ts +0 -85
@@ -1,1226 +0,0 @@
1
- import { RecordLockService } from '../lib/recordLockService'
2
- import type { RecordLockSettings } from '../lib/config'
3
- import { emitRecordLocksEvent } from '../events'
4
-
5
- jest.mock('../events', () => ({
6
- emitRecordLocksEvent: jest.fn().mockResolvedValue(undefined),
7
- }))
8
-
9
- const DEFAULT_SETTINGS: RecordLockSettings = {
10
- enabled: true,
11
- strategy: 'optimistic',
12
- timeoutSeconds: 300,
13
- heartbeatSeconds: 30,
14
- enabledResources: ['sales.quote'],
15
- allowForceUnlock: true,
16
- allowIncomingOverride: true,
17
- notifyOnConflict: true,
18
- }
19
-
20
- function createService(
21
- settings: RecordLockSettings = DEFAULT_SETTINGS,
22
- actionLogService?: { findById: (id: string) => Promise<unknown> } | null,
23
- options?: { canOverrideIncoming?: boolean },
24
- ) {
25
- const em = {
26
- findOne: jest.fn(),
27
- find: jest.fn(),
28
- create: jest.fn(),
29
- persist: jest.fn(),
30
- flush: jest.fn(),
31
- transactional: jest.fn(),
32
- } as any
33
- em.transactional.mockImplementation(async (cb: (tx: typeof em) => Promise<unknown>) => cb(em))
34
-
35
- const moduleConfigService = {
36
- getValue: jest.fn().mockResolvedValue(settings),
37
- setValue: jest.fn(),
38
- } as any
39
-
40
- const rbacService = {
41
- userHasAllFeatures: jest.fn().mockResolvedValue(options?.canOverrideIncoming ?? true),
42
- } as any
43
-
44
- return {
45
- service: new RecordLockService({
46
- em,
47
- moduleConfigService,
48
- actionLogService: (actionLogService as any) ?? null,
49
- rbacService,
50
- }),
51
- em,
52
- moduleConfigService,
53
- actionLogService,
54
- rbacService,
55
- }
56
- }
57
-
58
- function buildLock(overrides: Partial<Record<string, unknown>> = {}) {
59
- const now = new Date('2026-02-17T10:00:00.000Z')
60
- return {
61
- id: '10000000-0000-4000-8000-000000000001',
62
- resourceKind: 'sales.quote',
63
- resourceId: '20000000-0000-4000-8000-000000000001',
64
- token: '30000000-0000-4000-8000-000000000001',
65
- strategy: 'optimistic',
66
- status: 'active',
67
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
68
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
69
- lockedAt: now,
70
- lastHeartbeatAt: now,
71
- expiresAt: new Date(now.getTime() + 300000),
72
- tenantId: '60000000-0000-4000-8000-000000000001',
73
- organizationId: '70000000-0000-4000-8000-000000000001',
74
- ...overrides,
75
- }
76
- }
77
-
78
- describe('RecordLockService.validateMutation', () => {
79
- beforeEach(() => {
80
- jest.clearAllMocks()
81
- })
82
-
83
- test('returns resourceEnabled=false when locking is disabled for resource', async () => {
84
- const { service } = createService({
85
- ...DEFAULT_SETTINGS,
86
- enabled: false,
87
- enabledResources: [],
88
- })
89
-
90
- const result = await service.validateMutation({
91
- tenantId: '60000000-0000-4000-8000-000000000001',
92
- organizationId: '70000000-0000-4000-8000-000000000001',
93
- userId: '40000000-0000-4000-8000-000000000001',
94
- resourceKind: 'sales.quote',
95
- resourceId: '20000000-0000-4000-8000-000000000001',
96
- method: 'PUT',
97
- headers: {},
98
- })
99
-
100
- expect(result.ok).toBe(true)
101
- if (!result.ok) throw new Error('Expected successful validation')
102
- expect(result.resourceEnabled).toBe(false)
103
- expect(result.lock).toBeNull()
104
- })
105
-
106
- test('returns 409 conflict when optimistic base log is stale', async () => {
107
- const actionLogService = {
108
- findById: jest.fn(async (id: string) => {
109
- if (id === '50000000-0000-4000-8000-000000000001') {
110
- return {
111
- id,
112
- tenantId: '60000000-0000-4000-8000-000000000001',
113
- organizationId: '70000000-0000-4000-8000-000000000001',
114
- resourceKind: 'sales.quote',
115
- resourceId: '20000000-0000-4000-8000-000000000001',
116
- snapshotAfter: { entity: { displayName: 'Acme Before' } },
117
- snapshotBefore: null,
118
- changesJson: null,
119
- deletedAt: null,
120
- }
121
- }
122
-
123
- if (id === '80000000-0000-4000-8000-000000000001') {
124
- return {
125
- id,
126
- tenantId: '60000000-0000-4000-8000-000000000001',
127
- organizationId: '70000000-0000-4000-8000-000000000001',
128
- resourceKind: 'sales.quote',
129
- resourceId: '20000000-0000-4000-8000-000000000001',
130
- snapshotAfter: { entity: { displayName: 'Acme Incoming' } },
131
- snapshotBefore: { entity: { displayName: 'Acme Before' } },
132
- changesJson: {
133
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
134
- },
135
- deletedAt: null,
136
- }
137
- }
138
-
139
- return null
140
- }),
141
- }
142
-
143
- const { service } = createService(DEFAULT_SETTINGS, actionLogService)
144
- const serviceAny = service as any
145
-
146
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(
147
- buildLock({ lockedByUserId: '40000000-0000-4000-8000-000000000001' }),
148
- )
149
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
150
- id: '80000000-0000-4000-8000-000000000001',
151
- actorUserId: '90000000-0000-4000-8000-000000000001',
152
- })
153
- serviceAny.createConflict = jest.fn().mockResolvedValue({
154
- id: 'a0000000-0000-4000-8000-000000000001',
155
- resourceKind: 'sales.quote',
156
- resourceId: '20000000-0000-4000-8000-000000000001',
157
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
158
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
159
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
160
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
161
- tenantId: '60000000-0000-4000-8000-000000000001',
162
- organizationId: '70000000-0000-4000-8000-000000000001',
163
- })
164
-
165
- const result = await service.validateMutation({
166
- tenantId: '60000000-0000-4000-8000-000000000001',
167
- organizationId: '70000000-0000-4000-8000-000000000001',
168
- userId: '40000000-0000-4000-8000-000000000001',
169
- resourceKind: 'sales.quote',
170
- resourceId: '20000000-0000-4000-8000-000000000001',
171
- method: 'PUT',
172
- headers: {
173
- token: '30000000-0000-4000-8000-000000000001',
174
- baseLogId: '50000000-0000-4000-8000-000000000001',
175
- resolution: 'normal',
176
- },
177
- mutationPayload: {
178
- id: '20000000-0000-4000-8000-000000000001',
179
- displayName: 'Acme Mine',
180
- },
181
- })
182
-
183
- expect(result.ok).toBe(false)
184
- if (result.ok) throw new Error('Expected conflict result')
185
- expect(result.status).toBe(409)
186
- expect(result.code).toBe('record_lock_conflict')
187
- expect(result.conflict?.resolutionOptions).toEqual(['accept_mine'])
188
- expect(result.conflict?.changes).toEqual([
189
- {
190
- field: 'entity.displayName',
191
- displayValue: 'Acme Before',
192
- baseValue: 'Acme Before',
193
- incomingValue: 'Acme Incoming',
194
- mineValue: 'Acme Mine',
195
- },
196
- ])
197
- expect(serviceAny.createConflict).toHaveBeenCalledTimes(1)
198
- })
199
-
200
- test('returns 409 conflict when optimistic base log is stale from another session of same user', async () => {
201
- const actionLogService = {
202
- findById: jest.fn(async (id: string) => {
203
- if (id === '50000000-0000-4000-8000-000000000001') {
204
- return {
205
- id,
206
- tenantId: '60000000-0000-4000-8000-000000000001',
207
- organizationId: '70000000-0000-4000-8000-000000000001',
208
- resourceKind: 'sales.quote',
209
- resourceId: '20000000-0000-4000-8000-000000000001',
210
- snapshotAfter: { entity: { displayName: 'Acme Before' } },
211
- snapshotBefore: null,
212
- changesJson: null,
213
- deletedAt: null,
214
- }
215
- }
216
-
217
- if (id === '80000000-0000-4000-8000-000000000001') {
218
- return {
219
- id,
220
- tenantId: '60000000-0000-4000-8000-000000000001',
221
- organizationId: '70000000-0000-4000-8000-000000000001',
222
- resourceKind: 'sales.quote',
223
- resourceId: '20000000-0000-4000-8000-000000000001',
224
- snapshotAfter: { entity: { displayName: 'Acme Incoming' } },
225
- snapshotBefore: { entity: { displayName: 'Acme Before' } },
226
- changesJson: {
227
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
228
- },
229
- deletedAt: null,
230
- }
231
- }
232
-
233
- return null
234
- }),
235
- }
236
-
237
- const { service } = createService(DEFAULT_SETTINGS, actionLogService)
238
- const serviceAny = service as any
239
-
240
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(
241
- buildLock({ lockedByUserId: '40000000-0000-4000-8000-000000000001' }),
242
- )
243
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
244
- id: '80000000-0000-4000-8000-000000000001',
245
- actorUserId: '40000000-0000-4000-8000-000000000001',
246
- })
247
- serviceAny.createConflict = jest.fn().mockResolvedValue({
248
- id: 'a0000000-0000-4000-8000-000000000111',
249
- resourceKind: 'sales.quote',
250
- resourceId: '20000000-0000-4000-8000-000000000001',
251
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
252
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
253
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
254
- incomingActorUserId: '40000000-0000-4000-8000-000000000001',
255
- tenantId: '60000000-0000-4000-8000-000000000001',
256
- organizationId: '70000000-0000-4000-8000-000000000001',
257
- })
258
-
259
- const result = await service.validateMutation({
260
- tenantId: '60000000-0000-4000-8000-000000000001',
261
- organizationId: '70000000-0000-4000-8000-000000000001',
262
- userId: '40000000-0000-4000-8000-000000000001',
263
- resourceKind: 'sales.quote',
264
- resourceId: '20000000-0000-4000-8000-000000000001',
265
- method: 'PUT',
266
- headers: {
267
- token: '30000000-0000-4000-8000-000000000001',
268
- baseLogId: '50000000-0000-4000-8000-000000000001',
269
- resolution: 'normal',
270
- },
271
- mutationPayload: {
272
- id: '20000000-0000-4000-8000-000000000001',
273
- displayName: 'Acme Mine',
274
- },
275
- })
276
-
277
- expect(result.ok).toBe(false)
278
- if (result.ok) throw new Error('Expected conflict result')
279
- expect(result.status).toBe(409)
280
- expect(result.code).toBe('record_lock_conflict')
281
- expect(serviceAny.createConflict).toHaveBeenCalledTimes(1)
282
- })
283
-
284
- test('returns 409 conflict when base log is missing but newer incoming log exists after lock start', async () => {
285
- const { service } = createService()
286
- const serviceAny = service as any
287
-
288
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(
289
- buildLock({
290
- baseActionLogId: null,
291
- lockedAt: new Date('2026-02-17T10:00:00.000Z'),
292
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
293
- }),
294
- )
295
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
296
- id: '81000000-0000-4000-8000-000000000001',
297
- actorUserId: '90000000-0000-4000-8000-000000000001',
298
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
299
- })
300
- serviceAny.createConflict = jest.fn().mockResolvedValue({
301
- id: 'a0000000-0000-4000-8000-000000000010',
302
- resourceKind: 'sales.quote',
303
- resourceId: '20000000-0000-4000-8000-000000000001',
304
- baseActionLogId: null,
305
- incomingActionLogId: '81000000-0000-4000-8000-000000000001',
306
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
307
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
308
- tenantId: '60000000-0000-4000-8000-000000000001',
309
- organizationId: '70000000-0000-4000-8000-000000000001',
310
- })
311
-
312
- const result = await service.validateMutation({
313
- tenantId: '60000000-0000-4000-8000-000000000001',
314
- organizationId: '70000000-0000-4000-8000-000000000001',
315
- userId: '40000000-0000-4000-8000-000000000001',
316
- resourceKind: 'sales.quote',
317
- resourceId: '20000000-0000-4000-8000-000000000001',
318
- method: 'PUT',
319
- headers: {
320
- token: '30000000-0000-4000-8000-000000000001',
321
- resolution: 'normal',
322
- },
323
- mutationPayload: {
324
- id: '20000000-0000-4000-8000-000000000001',
325
- dimensions: { width: 10, height: 20 },
326
- },
327
- })
328
-
329
- expect(result.ok).toBe(false)
330
- if (result.ok) throw new Error('Expected conflict result')
331
- expect(result.status).toBe(409)
332
- expect(result.code).toBe('record_lock_conflict')
333
- expect(serviceAny.createConflict).toHaveBeenCalledTimes(1)
334
- })
335
-
336
- test('resolves existing conflict when resolution header is accept_mine', async () => {
337
- const { service } = createService()
338
- const serviceAny = service as any
339
-
340
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(null)
341
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
342
- id: '80000000-0000-4000-8000-000000000001',
343
- actorUserId: '90000000-0000-4000-8000-000000000001',
344
- })
345
- serviceAny.findConflictById = jest.fn().mockResolvedValue({
346
- id: 'a0000000-0000-4000-8000-000000000001',
347
- resourceKind: 'sales.quote',
348
- resourceId: '20000000-0000-4000-8000-000000000001',
349
- status: 'pending',
350
- resolution: null,
351
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
352
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
353
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
354
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
355
- tenantId: '60000000-0000-4000-8000-000000000001',
356
- organizationId: '70000000-0000-4000-8000-000000000001',
357
- })
358
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
359
-
360
- const result = await service.validateMutation({
361
- tenantId: '60000000-0000-4000-8000-000000000001',
362
- organizationId: '70000000-0000-4000-8000-000000000001',
363
- userId: '40000000-0000-4000-8000-000000000001',
364
- resourceKind: 'sales.quote',
365
- resourceId: '20000000-0000-4000-8000-000000000001',
366
- method: 'PUT',
367
- headers: {
368
- conflictId: 'a0000000-0000-4000-8000-000000000001',
369
- resolution: 'accept_mine',
370
- },
371
- })
372
-
373
- expect(result.ok).toBe(true)
374
- expect(serviceAny.resolveConflict).toHaveBeenCalledWith(
375
- expect.objectContaining({ id: 'a0000000-0000-4000-8000-000000000001' }),
376
- 'accept_mine',
377
- '40000000-0000-4000-8000-000000000001',
378
- )
379
- })
380
-
381
- test('auto-resolves detected conflict when keep-mine intent is provided without conflict id', async () => {
382
- const { service } = createService()
383
- const serviceAny = service as any
384
-
385
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(
386
- buildLock({ lockedByUserId: '40000000-0000-4000-8000-000000000001' }),
387
- )
388
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
389
- id: '81000000-0000-4000-8000-000000000001',
390
- actorUserId: '90000000-0000-4000-8000-000000000001',
391
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
392
- })
393
- serviceAny.findConflictById = jest.fn().mockResolvedValue(null)
394
- serviceAny.createConflict = jest.fn().mockResolvedValue({
395
- id: 'a0000000-0000-4000-8000-000000000011',
396
- resourceKind: 'sales.quote',
397
- resourceId: '20000000-0000-4000-8000-000000000001',
398
- status: 'pending',
399
- resolution: null,
400
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
401
- incomingActionLogId: '81000000-0000-4000-8000-000000000001',
402
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
403
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
404
- tenantId: '60000000-0000-4000-8000-000000000001',
405
- organizationId: '70000000-0000-4000-8000-000000000001',
406
- })
407
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
408
-
409
- const result = await service.validateMutation({
410
- tenantId: '60000000-0000-4000-8000-000000000001',
411
- organizationId: '70000000-0000-4000-8000-000000000001',
412
- userId: '40000000-0000-4000-8000-000000000001',
413
- resourceKind: 'sales.quote',
414
- resourceId: '20000000-0000-4000-8000-000000000001',
415
- method: 'PUT',
416
- headers: {
417
- token: '30000000-0000-4000-8000-000000000001',
418
- baseLogId: '50000000-0000-4000-8000-000000000001',
419
- resolution: 'accept_mine',
420
- },
421
- mutationPayload: {
422
- id: '20000000-0000-4000-8000-000000000001',
423
- displayName: 'Acme Mine',
424
- },
425
- })
426
-
427
- expect(result.ok).toBe(true)
428
- expect(serviceAny.createConflict).toHaveBeenCalledTimes(1)
429
- expect(serviceAny.resolveConflict).toHaveBeenCalledWith(
430
- expect.objectContaining({ id: 'a0000000-0000-4000-8000-000000000011' }),
431
- 'accept_mine',
432
- '40000000-0000-4000-8000-000000000001',
433
- )
434
- })
435
-
436
- test('returns 409 when conflict resolution is attempted by a different user', async () => {
437
- const { service } = createService()
438
- const serviceAny = service as any
439
-
440
- serviceAny.findActiveLock = jest.fn().mockResolvedValue(null)
441
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
442
- id: '80000000-0000-4000-8000-000000000001',
443
- actorUserId: '90000000-0000-4000-8000-000000000001',
444
- })
445
- serviceAny.findConflictById = jest.fn().mockResolvedValue({
446
- id: 'a0000000-0000-4000-8000-000000000002',
447
- resourceKind: 'sales.quote',
448
- resourceId: '20000000-0000-4000-8000-000000000001',
449
- status: 'pending',
450
- resolution: null,
451
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
452
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
453
- conflictActorUserId: '40000000-0000-4000-8000-000000000099',
454
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
455
- tenantId: '60000000-0000-4000-8000-000000000001',
456
- organizationId: '70000000-0000-4000-8000-000000000001',
457
- })
458
- serviceAny.toConflictPayload = jest.fn().mockResolvedValue({
459
- id: 'a0000000-0000-4000-8000-000000000002',
460
- resourceKind: 'sales.quote',
461
- resourceId: '20000000-0000-4000-8000-000000000001',
462
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
463
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
464
- resolutionOptions: ['accept_mine'],
465
- changes: [],
466
- })
467
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
468
-
469
- const result = await service.validateMutation({
470
- tenantId: '60000000-0000-4000-8000-000000000001',
471
- organizationId: '70000000-0000-4000-8000-000000000001',
472
- userId: '40000000-0000-4000-8000-000000000001',
473
- resourceKind: 'sales.quote',
474
- resourceId: '20000000-0000-4000-8000-000000000001',
475
- method: 'PUT',
476
- headers: {
477
- conflictId: 'a0000000-0000-4000-8000-000000000002',
478
- resolution: 'accept_mine',
479
- },
480
- mutationPayload: {
481
- id: '20000000-0000-4000-8000-000000000001',
482
- displayName: 'Acme Mine',
483
- },
484
- })
485
-
486
- expect(result.ok).toBe(false)
487
- if (result.ok) throw new Error('Expected conflict failure')
488
- expect(result.status).toBe(409)
489
- expect(result.code).toBe('record_lock_conflict')
490
- expect(serviceAny.resolveConflict).not.toHaveBeenCalled()
491
- expect(serviceAny.toConflictPayload).toHaveBeenCalledTimes(1)
492
- })
493
- })
494
-
495
- describe('RecordLockService.acquire', () => {
496
- beforeEach(() => {
497
- jest.clearAllMocks()
498
- })
499
-
500
- test('allows optimistic participant join when another user is already active', async () => {
501
- const { service, em } = createService({
502
- ...DEFAULT_SETTINGS,
503
- strategy: 'optimistic',
504
- })
505
- const serviceAny = service as any
506
-
507
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue(null)
508
- const competingLock = buildLock({
509
- strategy: 'optimistic',
510
- id: '10000000-0000-4000-8000-000000000099',
511
- lockedByUserId: '40000000-0000-4000-8000-000000000099',
512
- })
513
- const joinedLock = buildLock({
514
- strategy: 'optimistic',
515
- id: '10000000-0000-4000-8000-000000000088',
516
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
517
- token: '30000000-0000-4000-8000-000000000088',
518
- })
519
- em.find
520
- .mockResolvedValueOnce([competingLock])
521
- .mockResolvedValueOnce([competingLock, joinedLock])
522
- em.create.mockReturnValue(joinedLock)
523
- em.flush.mockResolvedValue(undefined)
524
-
525
- const result = await service.acquire({
526
- tenantId: '60000000-0000-4000-8000-000000000001',
527
- organizationId: '70000000-0000-4000-8000-000000000001',
528
- userId: '40000000-0000-4000-8000-000000000001',
529
- resourceKind: 'sales.quote',
530
- resourceId: '20000000-0000-4000-8000-000000000001',
531
- })
532
-
533
- expect(result.ok).toBe(true)
534
- if (!result.ok) throw new Error('Expected successful result')
535
- expect(result.acquired).toBe(true)
536
- expect(result.lock).toBeTruthy()
537
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
538
- 'record_locks.participant.joined',
539
- expect.objectContaining({
540
- joinedUserId: '40000000-0000-4000-8000-000000000001',
541
- }),
542
- )
543
- })
544
-
545
- test('returns competing lock when create races on active-scope unique index', async () => {
546
- const { service, em } = createService({
547
- ...DEFAULT_SETTINGS,
548
- strategy: 'pessimistic',
549
- })
550
- const serviceAny = service as any
551
-
552
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue(null)
553
- serviceAny.findActiveLock = jest.fn()
554
- .mockResolvedValueOnce(null)
555
- .mockResolvedValueOnce(buildLock({
556
- strategy: 'pessimistic',
557
- lockedByUserId: '40000000-0000-4000-8000-000000000099',
558
- token: '30000000-0000-4000-8000-000000000099',
559
- }))
560
-
561
- em.create.mockReturnValue(buildLock({
562
- strategy: 'pessimistic',
563
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
564
- }))
565
- em.flush.mockRejectedValueOnce(Object.assign(
566
- new Error('duplicate key value violates unique constraint "record_locks_active_scope_org_unique"'),
567
- {
568
- code: '23505',
569
- constraint: 'record_locks_active_scope_org_unique',
570
- },
571
- ))
572
-
573
- const result = await service.acquire({
574
- tenantId: '60000000-0000-4000-8000-000000000001',
575
- organizationId: '70000000-0000-4000-8000-000000000001',
576
- userId: '40000000-0000-4000-8000-000000000001',
577
- resourceKind: 'sales.quote',
578
- resourceId: '20000000-0000-4000-8000-000000000001',
579
- })
580
-
581
- expect(result.ok).toBe(false)
582
- if (result.ok) throw new Error('Expected lock collision result')
583
- expect(result.status).toBe(423)
584
- expect(result.code).toBe('record_locked')
585
- expect(result.lock?.lockedByUserId).toBe('40000000-0000-4000-8000-000000000099')
586
- expect(serviceAny.findActiveLock).toHaveBeenCalledTimes(2)
587
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
588
- 'record_locks.lock.contended',
589
- expect.objectContaining({
590
- lockedByUserId: '40000000-0000-4000-8000-000000000099',
591
- attemptedByUserId: '40000000-0000-4000-8000-000000000001',
592
- }),
593
- )
594
- })
595
- })
596
-
597
- describe('RecordLockService.release', () => {
598
- beforeEach(() => {
599
- jest.clearAllMocks()
600
- })
601
-
602
- test('resolves pending conflict as accept_incoming and returns conflictResolved=true', async () => {
603
- const { service } = createService(DEFAULT_SETTINGS)
604
- const serviceAny = service as any
605
-
606
- const lock = buildLock({
607
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
608
- token: '30000000-0000-4000-8000-000000000001',
609
- status: 'active',
610
- })
611
-
612
- const conflict = {
613
- id: 'a0000000-0000-4000-8000-000000000001',
614
- resourceKind: 'sales.quote',
615
- resourceId: '20000000-0000-4000-8000-000000000001',
616
- status: 'pending',
617
- resolution: null,
618
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
619
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
620
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
621
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
622
- tenantId: '60000000-0000-4000-8000-000000000001',
623
- organizationId: '70000000-0000-4000-8000-000000000001',
624
- }
625
-
626
- serviceAny.findOwnedLockByToken = jest.fn().mockResolvedValue(lock)
627
- serviceAny.findConflictById = jest.fn().mockResolvedValue(conflict)
628
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
629
-
630
- const result = await service.release({
631
- token: '30000000-0000-4000-8000-000000000001',
632
- reason: 'conflict_resolved',
633
- conflictId: 'a0000000-0000-4000-8000-000000000001',
634
- resolution: 'accept_incoming',
635
- resourceKind: 'sales.quote',
636
- resourceId: '20000000-0000-4000-8000-000000000001',
637
- tenantId: '60000000-0000-4000-8000-000000000001',
638
- organizationId: '70000000-0000-4000-8000-000000000001',
639
- userId: '40000000-0000-4000-8000-000000000001',
640
- })
641
-
642
- expect(result).toEqual({
643
- ok: true,
644
- released: true,
645
- conflictResolved: true,
646
- })
647
- expect(serviceAny.resolveConflict).toHaveBeenCalledWith(
648
- expect.objectContaining({ id: 'a0000000-0000-4000-8000-000000000001' }),
649
- 'accept_incoming',
650
- '40000000-0000-4000-8000-000000000001',
651
- )
652
- })
653
-
654
- test('resolves pending conflict as accept_incoming without lock token', async () => {
655
- const { service } = createService(DEFAULT_SETTINGS)
656
- const serviceAny = service as any
657
-
658
- const conflict = {
659
- id: 'a0000000-0000-4000-8000-000000000002',
660
- resourceKind: 'sales.quote',
661
- resourceId: '20000000-0000-4000-8000-000000000001',
662
- status: 'pending',
663
- resolution: null,
664
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
665
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
666
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
667
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
668
- tenantId: '60000000-0000-4000-8000-000000000001',
669
- organizationId: '70000000-0000-4000-8000-000000000001',
670
- }
671
-
672
- serviceAny.findConflictById = jest.fn().mockResolvedValue(conflict)
673
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
674
- serviceAny.findOwnedLockByToken = jest.fn()
675
-
676
- const result = await service.release({
677
- reason: 'conflict_resolved',
678
- conflictId: 'a0000000-0000-4000-8000-000000000002',
679
- resolution: 'accept_incoming',
680
- resourceKind: 'sales.quote',
681
- resourceId: '20000000-0000-4000-8000-000000000001',
682
- tenantId: '60000000-0000-4000-8000-000000000001',
683
- organizationId: '70000000-0000-4000-8000-000000000001',
684
- userId: '40000000-0000-4000-8000-000000000001',
685
- })
686
-
687
- expect(result).toEqual({
688
- ok: true,
689
- released: false,
690
- conflictResolved: true,
691
- })
692
- expect(serviceAny.resolveConflict).toHaveBeenCalledWith(
693
- expect.objectContaining({ id: 'a0000000-0000-4000-8000-000000000002' }),
694
- 'accept_incoming',
695
- '40000000-0000-4000-8000-000000000001',
696
- )
697
- expect(serviceAny.findOwnedLockByToken).not.toHaveBeenCalled()
698
- })
699
- })
700
-
701
- describe('RecordLockService.emitIncomingChangesNotificationAfterMutation', () => {
702
- beforeEach(() => {
703
- jest.clearAllMocks()
704
- })
705
-
706
- test('emits incoming-changes event using actor fallback log when latest log belongs to another actor', async () => {
707
- const { service, em } = createService(DEFAULT_SETTINGS)
708
- const serviceAny = service as any
709
- em.find.mockResolvedValue([
710
- buildLock({
711
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
712
- lockedAt: new Date('2026-02-17T10:00:00.000Z'),
713
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
714
- expiresAt: new Date('2099-02-17T10:05:00.000Z'),
715
- }),
716
- ])
717
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
718
- id: '80000000-0000-4000-8000-000000000099',
719
- actorUserId: '90000000-0000-4000-8000-000000000099',
720
- changesJson: null,
721
- createdAt: new Date('2026-02-17T10:02:00.000Z'),
722
- })
723
- serviceAny.findLatestActionLogByActor = jest.fn().mockResolvedValue({
724
- id: '80000000-0000-4000-8000-000000000001',
725
- actorUserId: '90000000-0000-4000-8000-000000000001',
726
- changesJson: null,
727
- snapshotBefore: { entity: { displayName: 'Acme Before' } },
728
- snapshotAfter: { entity: { displayName: 'Acme Incoming' } },
729
- createdAt: new Date('2026-02-17T10:01:00.000Z'),
730
- })
731
-
732
- await service.emitIncomingChangesNotificationAfterMutation({
733
- tenantId: '60000000-0000-4000-8000-000000000001',
734
- organizationId: '70000000-0000-4000-8000-000000000001',
735
- userId: '90000000-0000-4000-8000-000000000001',
736
- resourceKind: 'sales.quote',
737
- resourceId: '20000000-0000-4000-8000-000000000001',
738
- method: 'PUT',
739
- })
740
-
741
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
742
- 'record_locks.incoming_changes.available',
743
- expect.objectContaining({
744
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
745
- incomingActionLogId: '80000000-0000-4000-8000-000000000001',
746
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
747
- changedFields: expect.stringContaining('Display Name'),
748
- }),
749
- )
750
- })
751
-
752
- test('emits incoming-changes event using latest log when actor lookup is unavailable', async () => {
753
- const { service, em } = createService(DEFAULT_SETTINGS)
754
- const serviceAny = service as any
755
- em.find.mockResolvedValue([
756
- buildLock({
757
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
758
- lockedAt: new Date('2026-02-17T10:00:00.000Z'),
759
- baseActionLogId: '50000000-0000-4000-8000-000000000001',
760
- expiresAt: new Date('2099-02-17T10:05:00.000Z'),
761
- }),
762
- ])
763
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
764
- id: '80000000-0000-4000-8000-000000000777',
765
- actorUserId: '90000000-0000-4000-8000-000000000777',
766
- changesJson: {
767
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
768
- },
769
- createdAt: new Date('2026-02-17T10:03:00.000Z'),
770
- })
771
- serviceAny.findLatestActionLogByActor = jest.fn().mockResolvedValue(null)
772
-
773
- await service.emitIncomingChangesNotificationAfterMutation({
774
- tenantId: '60000000-0000-4000-8000-000000000001',
775
- organizationId: '70000000-0000-4000-8000-000000000001',
776
- userId: '90000000-0000-4000-8000-000000000001',
777
- resourceKind: 'sales.quote',
778
- resourceId: '20000000-0000-4000-8000-000000000001',
779
- method: 'PUT',
780
- })
781
-
782
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
783
- 'record_locks.incoming_changes.available',
784
- expect.objectContaining({
785
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
786
- incomingActionLogId: '80000000-0000-4000-8000-000000000777',
787
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
788
- }),
789
- )
790
- })
791
-
792
- test('emits incoming-changes event even when action log cannot be resolved', async () => {
793
- const { service, em } = createService(DEFAULT_SETTINGS)
794
- const serviceAny = service as any
795
-
796
- em.find.mockResolvedValue([
797
- buildLock({
798
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
799
- expiresAt: new Date('2099-02-17T10:05:00.000Z'),
800
- }),
801
- ])
802
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue(null)
803
- serviceAny.findLatestActionLogByActor = jest.fn().mockResolvedValue(null)
804
-
805
- await service.emitIncomingChangesNotificationAfterMutation({
806
- tenantId: '60000000-0000-4000-8000-000000000001',
807
- organizationId: '70000000-0000-4000-8000-000000000001',
808
- userId: '90000000-0000-4000-8000-000000000001',
809
- resourceKind: 'sales.quote',
810
- resourceId: '20000000-0000-4000-8000-000000000001',
811
- method: 'PUT',
812
- })
813
-
814
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
815
- 'record_locks.incoming_changes.available',
816
- expect.objectContaining({
817
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
818
- incomingActionLogId: null,
819
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
820
- changedFields: '-',
821
- }),
822
- )
823
- })
824
-
825
- test('emits incoming-changes event when org-scoped lock is found only via null-scope fallback', async () => {
826
- const { service, em } = createService(DEFAULT_SETTINGS)
827
- const serviceAny = service as any
828
-
829
- em.find
830
- .mockResolvedValueOnce([])
831
- .mockResolvedValueOnce([
832
- buildLock({
833
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
834
- organizationId: '70000000-0000-4000-8000-000000000099',
835
- expiresAt: new Date('2099-02-17T10:05:00.000Z'),
836
- }),
837
- ])
838
-
839
- serviceAny.findLatestActionLog = jest
840
- .fn()
841
- .mockResolvedValueOnce(null)
842
- .mockResolvedValueOnce({
843
- id: '80000000-0000-4000-8000-000000000555',
844
- actorUserId: '90000000-0000-4000-8000-000000000001',
845
- changesJson: {
846
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
847
- },
848
- createdAt: new Date('2026-02-17T10:04:00.000Z'),
849
- })
850
- serviceAny.findLatestActionLogByActor = jest
851
- .fn()
852
- .mockResolvedValueOnce(null)
853
- .mockResolvedValueOnce({
854
- id: '80000000-0000-4000-8000-000000000555',
855
- actorUserId: '90000000-0000-4000-8000-000000000001',
856
- changesJson: {
857
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
858
- },
859
- createdAt: new Date('2026-02-17T10:04:00.000Z'),
860
- })
861
-
862
- await service.emitIncomingChangesNotificationAfterMutation({
863
- tenantId: '60000000-0000-4000-8000-000000000001',
864
- organizationId: null,
865
- userId: '90000000-0000-4000-8000-000000000001',
866
- resourceKind: 'sales.quote',
867
- resourceId: '20000000-0000-4000-8000-000000000001',
868
- method: 'PUT',
869
- })
870
-
871
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
872
- 'record_locks.incoming_changes.available',
873
- expect.objectContaining({
874
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
875
- incomingActionLogId: '80000000-0000-4000-8000-000000000555',
876
- }),
877
- )
878
- })
879
-
880
- test('emits incoming-changes event when scoped query misses lock but tenant-scope fallback finds it', async () => {
881
- const { service, em } = createService(DEFAULT_SETTINGS)
882
- const serviceAny = service as any
883
-
884
- em.find
885
- .mockResolvedValueOnce([])
886
- .mockResolvedValueOnce([
887
- buildLock({
888
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
889
- organizationId: '70000000-0000-4000-8000-000000000001',
890
- expiresAt: new Date('2099-02-17T10:05:00.000Z'),
891
- }),
892
- ])
893
-
894
- serviceAny.findLatestActionLog = jest
895
- .fn()
896
- .mockResolvedValueOnce(null)
897
- .mockResolvedValueOnce({
898
- id: '80000000-0000-4000-8000-000000000556',
899
- actorUserId: '90000000-0000-4000-8000-000000000001',
900
- changesJson: {
901
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
902
- },
903
- createdAt: new Date('2026-02-17T10:04:00.000Z'),
904
- })
905
- serviceAny.findLatestActionLogByActor = jest
906
- .fn()
907
- .mockResolvedValueOnce(null)
908
- .mockResolvedValueOnce({
909
- id: '80000000-0000-4000-8000-000000000556',
910
- actorUserId: '90000000-0000-4000-8000-000000000001',
911
- changesJson: {
912
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
913
- },
914
- createdAt: new Date('2026-02-17T10:04:00.000Z'),
915
- })
916
-
917
- await service.emitIncomingChangesNotificationAfterMutation({
918
- tenantId: '60000000-0000-4000-8000-000000000001',
919
- organizationId: '70000000-0000-4000-8000-000000000777',
920
- userId: '90000000-0000-4000-8000-000000000001',
921
- resourceKind: 'sales.quote',
922
- resourceId: '20000000-0000-4000-8000-000000000001',
923
- method: 'PUT',
924
- })
925
-
926
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
927
- 'record_locks.incoming_changes.available',
928
- expect.objectContaining({
929
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
930
- incomingActionLogId: '80000000-0000-4000-8000-000000000556',
931
- }),
932
- )
933
- })
934
-
935
- test('emits incoming-changes event for recent lock owner when active lock list is empty', async () => {
936
- const { service, em } = createService(DEFAULT_SETTINGS)
937
- const serviceAny = service as any
938
-
939
- em.find
940
- .mockResolvedValueOnce([])
941
- .mockResolvedValueOnce([])
942
- .mockResolvedValueOnce([
943
- buildLock({
944
- status: 'released',
945
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
946
- updatedAt: new Date('2026-02-17T10:04:30.000Z'),
947
- expiresAt: new Date('2099-02-17T10:04:00.000Z'),
948
- }),
949
- ])
950
-
951
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
952
- id: '80000000-0000-4000-8000-000000000557',
953
- actorUserId: '90000000-0000-4000-8000-000000000001',
954
- changesJson: {
955
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
956
- },
957
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
958
- })
959
- serviceAny.findLatestActionLogByActor = jest.fn().mockResolvedValue({
960
- id: '80000000-0000-4000-8000-000000000557',
961
- actorUserId: '90000000-0000-4000-8000-000000000001',
962
- changesJson: {
963
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
964
- },
965
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
966
- })
967
-
968
- await service.emitIncomingChangesNotificationAfterMutation({
969
- tenantId: '60000000-0000-4000-8000-000000000001',
970
- organizationId: '70000000-0000-4000-8000-000000000001',
971
- userId: '90000000-0000-4000-8000-000000000001',
972
- resourceKind: 'sales.quote',
973
- resourceId: '20000000-0000-4000-8000-000000000001',
974
- method: 'PUT',
975
- })
976
-
977
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
978
- 'record_locks.incoming_changes.available',
979
- expect.objectContaining({
980
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
981
- incomingActionLogId: '80000000-0000-4000-8000-000000000557',
982
- }),
983
- )
984
- })
985
-
986
- test('emits incoming-changes event for previous owner when active lock belongs to mutation actor', async () => {
987
- const { service, em } = createService({
988
- ...DEFAULT_SETTINGS,
989
- strategy: 'pessimistic',
990
- })
991
- const serviceAny = service as any
992
-
993
- serviceAny.findActiveLocks = jest.fn().mockResolvedValue([
994
- buildLock({
995
- lockedByUserId: '90000000-0000-4000-8000-000000000001',
996
- status: 'active',
997
- }),
998
- ])
999
- em.find.mockResolvedValue([
1000
- buildLock({
1001
- status: 'force_released',
1002
- lockedByUserId: '40000000-0000-4000-8000-000000000001',
1003
- updatedAt: new Date('2026-02-17T10:04:30.000Z'),
1004
- expiresAt: new Date('2099-02-17T10:04:00.000Z'),
1005
- }),
1006
- ])
1007
- serviceAny.findLatestActionLog = jest.fn().mockResolvedValue({
1008
- id: '80000000-0000-4000-8000-000000000558',
1009
- actorUserId: '90000000-0000-4000-8000-000000000001',
1010
- changesJson: {
1011
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
1012
- },
1013
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
1014
- })
1015
- serviceAny.findLatestActionLogByActor = jest.fn().mockResolvedValue({
1016
- id: '80000000-0000-4000-8000-000000000558',
1017
- actorUserId: '90000000-0000-4000-8000-000000000001',
1018
- changesJson: {
1019
- 'entity.displayName': { from: 'Acme Before', to: 'Acme Incoming' },
1020
- },
1021
- createdAt: new Date('2026-02-17T10:05:00.000Z'),
1022
- })
1023
-
1024
- await service.emitIncomingChangesNotificationAfterMutation({
1025
- tenantId: '60000000-0000-4000-8000-000000000001',
1026
- organizationId: '70000000-0000-4000-8000-000000000001',
1027
- userId: '90000000-0000-4000-8000-000000000001',
1028
- resourceKind: 'sales.quote',
1029
- resourceId: '20000000-0000-4000-8000-000000000001',
1030
- method: 'PUT',
1031
- })
1032
-
1033
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
1034
- 'record_locks.incoming_changes.available',
1035
- expect.objectContaining({
1036
- recipientUserIds: ['40000000-0000-4000-8000-000000000001'],
1037
- incomingActorUserId: '90000000-0000-4000-8000-000000000001',
1038
- incomingActionLogId: '80000000-0000-4000-8000-000000000558',
1039
- }),
1040
- )
1041
- })
1042
- })
1043
-
1044
- describe('RecordLockService.heartbeat', () => {
1045
- beforeEach(() => {
1046
- jest.clearAllMocks()
1047
- })
1048
-
1049
- test('refreshes heartbeat and expiration for active owned lock', async () => {
1050
- const { service, em } = createService(DEFAULT_SETTINGS)
1051
- const serviceAny = service as any
1052
- const lock = buildLock({
1053
- expiresAt: new Date(Date.now() + 60_000),
1054
- lastHeartbeatAt: new Date(Date.now() - 60_000),
1055
- })
1056
- serviceAny.findOwnedLockByToken = jest.fn().mockResolvedValue(lock)
1057
-
1058
- const result = await service.heartbeat({
1059
- tenantId: '60000000-0000-4000-8000-000000000001',
1060
- organizationId: '70000000-0000-4000-8000-000000000001',
1061
- userId: '40000000-0000-4000-8000-000000000001',
1062
- resourceKind: 'sales.quote',
1063
- resourceId: '20000000-0000-4000-8000-000000000001',
1064
- token: '30000000-0000-4000-8000-000000000001',
1065
- })
1066
-
1067
- expect(result.ok).toBe(true)
1068
- expect(result.expiresAt).toEqual(expect.any(String))
1069
- expect(em.flush).toHaveBeenCalledTimes(1)
1070
- })
1071
-
1072
- test('expires and releases stale lock on heartbeat', async () => {
1073
- const { service, em } = createService(DEFAULT_SETTINGS)
1074
- const serviceAny = service as any
1075
- const lock = buildLock({
1076
- expiresAt: new Date(Date.now() - 60_000),
1077
- releaseReason: null,
1078
- releasedAt: null,
1079
- releasedByUserId: null,
1080
- status: 'active',
1081
- })
1082
- serviceAny.findOwnedLockByToken = jest.fn().mockResolvedValue(lock)
1083
-
1084
- const result = await service.heartbeat({
1085
- tenantId: '60000000-0000-4000-8000-000000000001',
1086
- organizationId: '70000000-0000-4000-8000-000000000001',
1087
- userId: '40000000-0000-4000-8000-000000000001',
1088
- resourceKind: 'sales.quote',
1089
- resourceId: '20000000-0000-4000-8000-000000000001',
1090
- token: '30000000-0000-4000-8000-000000000001',
1091
- })
1092
-
1093
- expect(result.ok).toBe(true)
1094
- expect(result.expiresAt).toBeNull()
1095
- expect(lock.status).toBe('expired')
1096
- expect(lock.releaseReason).toBe('expired')
1097
- expect(em.flush).toHaveBeenCalledTimes(1)
1098
- })
1099
- })
1100
-
1101
- describe('RecordLockService.forceRelease', () => {
1102
- beforeEach(() => {
1103
- jest.clearAllMocks()
1104
- })
1105
-
1106
- test('returns unreleased when user does not have force release feature', async () => {
1107
- const { service, rbacService } = createService(DEFAULT_SETTINGS)
1108
- const serviceAny = service as any
1109
- rbacService.userHasAllFeatures.mockResolvedValue(false)
1110
- serviceAny.findActiveLocks = jest.fn()
1111
-
1112
- const result = await service.forceRelease({
1113
- tenantId: '60000000-0000-4000-8000-000000000001',
1114
- organizationId: '70000000-0000-4000-8000-000000000001',
1115
- userId: '40000000-0000-4000-8000-000000000001',
1116
- resourceKind: 'sales.quote',
1117
- resourceId: '20000000-0000-4000-8000-000000000001',
1118
- reason: 'manual',
1119
- })
1120
-
1121
- expect(result).toEqual({ ok: true, released: false, lock: null })
1122
- expect(serviceAny.findActiveLocks).not.toHaveBeenCalled()
1123
- })
1124
-
1125
- test('force releases oldest active lock when user has permission', async () => {
1126
- const { service, em } = createService(DEFAULT_SETTINGS)
1127
- const serviceAny = service as any
1128
- const oldest = buildLock({
1129
- id: '10000000-0000-4000-8000-000000000001',
1130
- lockedByUserId: '50000000-0000-4000-8000-000000000001',
1131
- lockedAt: new Date('2026-02-17T09:50:00.000Z'),
1132
- createdAt: new Date('2026-02-17T09:50:00.000Z'),
1133
- expiresAt: new Date(Date.now() + 300_000),
1134
- status: 'active',
1135
- releaseReason: null,
1136
- releasedAt: null,
1137
- releasedByUserId: null,
1138
- })
1139
- const newer = buildLock({
1140
- id: '10000000-0000-4000-8000-000000000002',
1141
- token: '30000000-0000-4000-8000-000000000002',
1142
- lockedByUserId: '60000000-0000-4000-8000-000000000001',
1143
- lockedAt: new Date('2026-02-17T09:55:00.000Z'),
1144
- createdAt: new Date('2026-02-17T09:55:00.000Z'),
1145
- expiresAt: new Date(Date.now() + 300_000),
1146
- status: 'active',
1147
- })
1148
- serviceAny.findActiveLocks = jest.fn().mockResolvedValue([newer, oldest])
1149
-
1150
- const result = await service.forceRelease({
1151
- tenantId: '60000000-0000-4000-8000-000000000001',
1152
- organizationId: '70000000-0000-4000-8000-000000000001',
1153
- userId: '40000000-0000-4000-8000-000000000001',
1154
- resourceKind: 'sales.quote',
1155
- resourceId: '20000000-0000-4000-8000-000000000001',
1156
- reason: 'manual',
1157
- })
1158
-
1159
- expect(result.ok).toBe(true)
1160
- expect(result.released).toBe(true)
1161
- expect(oldest.status).toBe('force_released')
1162
- expect(em.flush).toHaveBeenCalledTimes(1)
1163
- expect(emitRecordLocksEvent).toHaveBeenCalledWith(
1164
- 'record_locks.lock.force_released',
1165
- expect.objectContaining({
1166
- lockId: oldest.id,
1167
- releasedByUserId: '40000000-0000-4000-8000-000000000001',
1168
- }),
1169
- )
1170
- })
1171
- })
1172
-
1173
- describe('RecordLockService.resolveConflictById', () => {
1174
- beforeEach(() => {
1175
- jest.clearAllMocks()
1176
- })
1177
-
1178
- test('does not resolve keep-mine conflict without override feature', async () => {
1179
- const { service, em } = createService(DEFAULT_SETTINGS, null, { canOverrideIncoming: false })
1180
- const serviceAny = service as any
1181
- em.findOne.mockResolvedValue({
1182
- id: 'a0000000-0000-4000-8000-000000000001',
1183
- status: 'pending',
1184
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
1185
- })
1186
- serviceAny.resolveConflict = jest.fn()
1187
-
1188
- const resolved = await service.resolveConflictById({
1189
- conflictId: 'a0000000-0000-4000-8000-000000000001',
1190
- tenantId: '60000000-0000-4000-8000-000000000001',
1191
- organizationId: '70000000-0000-4000-8000-000000000001',
1192
- userId: '40000000-0000-4000-8000-000000000001',
1193
- resolution: 'accept_mine',
1194
- })
1195
-
1196
- expect(resolved).toBe(false)
1197
- expect(serviceAny.resolveConflict).not.toHaveBeenCalled()
1198
- })
1199
-
1200
- test('resolves accept_incoming conflict for actor', async () => {
1201
- const { service, em } = createService(DEFAULT_SETTINGS, null, { canOverrideIncoming: false })
1202
- const serviceAny = service as any
1203
- const conflict = {
1204
- id: 'a0000000-0000-4000-8000-000000000002',
1205
- status: 'pending',
1206
- conflictActorUserId: '40000000-0000-4000-8000-000000000001',
1207
- }
1208
- em.findOne.mockResolvedValue(conflict)
1209
- serviceAny.resolveConflict = jest.fn().mockResolvedValue(undefined)
1210
-
1211
- const resolved = await service.resolveConflictById({
1212
- conflictId: conflict.id,
1213
- tenantId: '60000000-0000-4000-8000-000000000001',
1214
- organizationId: '70000000-0000-4000-8000-000000000001',
1215
- userId: '40000000-0000-4000-8000-000000000001',
1216
- resolution: 'accept_incoming',
1217
- })
1218
-
1219
- expect(resolved).toBe(true)
1220
- expect(serviceAny.resolveConflict).toHaveBeenCalledWith(
1221
- conflict,
1222
- 'accept_incoming',
1223
- '40000000-0000-4000-8000-000000000001',
1224
- )
1225
- })
1226
- })