@open-mercato/shared 0.6.4-develop.4210.1.d412061cfe → 0.6.4-develop.4236.1.9fa6806b34

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.
@@ -2,17 +2,19 @@ import { withAtomicFlush } from '../flush'
2
2
 
3
3
  type FakeEntityManager = {
4
4
  flush: jest.Mock<Promise<void>, []>
5
- begin: jest.Mock<Promise<void>, []>
5
+ begin: jest.Mock<Promise<void>, [unknown?]>
6
6
  commit: jest.Mock<Promise<void>, []>
7
7
  rollback: jest.Mock<Promise<void>, []>
8
+ isInTransaction: jest.Mock<boolean, []>
8
9
  }
9
10
 
10
- function createFakeEm(): FakeEntityManager {
11
+ function createFakeEm(overrides?: { inTransaction?: boolean }): FakeEntityManager {
11
12
  return {
12
13
  flush: jest.fn().mockResolvedValue(undefined),
13
14
  begin: jest.fn().mockResolvedValue(undefined),
14
15
  commit: jest.fn().mockResolvedValue(undefined),
15
16
  rollback: jest.fn().mockResolvedValue(undefined),
17
+ isInTransaction: jest.fn().mockReturnValue(overrides?.inTransaction ?? false),
16
18
  }
17
19
  }
18
20
 
@@ -163,4 +165,78 @@ describe('withAtomicFlush', () => {
163
165
 
164
166
  expect(em.rollback).toHaveBeenCalledTimes(1)
165
167
  })
168
+
169
+ it('joins an ambient transaction instead of clobbering it (re-entrancy)', async () => {
170
+ const em = createFakeEm({ inTransaction: true })
171
+ const phase = jest.fn()
172
+
173
+ await withAtomicFlush(em as any, [phase], { transaction: true })
174
+
175
+ // Must NOT open/commit a nested transaction — the outermost caller owns it.
176
+ expect(em.begin).not.toHaveBeenCalled()
177
+ expect(em.commit).not.toHaveBeenCalled()
178
+ expect(em.rollback).not.toHaveBeenCalled()
179
+ expect(phase).toHaveBeenCalledTimes(1)
180
+ expect(em.flush).toHaveBeenCalledTimes(1)
181
+ })
182
+
183
+ it('propagates a phase error when joining an ambient transaction (no local rollback)', async () => {
184
+ const em = createFakeEm({ inTransaction: true })
185
+ const failure = new Error('nested-phase-failure')
186
+
187
+ await expect(
188
+ withAtomicFlush(em as any, [
189
+ () => {
190
+ throw failure
191
+ },
192
+ ], { transaction: true }),
193
+ ).rejects.toBe(failure)
194
+
195
+ // The enclosing transaction owns rollback; this call must not commit or rollback.
196
+ expect(em.begin).not.toHaveBeenCalled()
197
+ expect(em.commit).not.toHaveBeenCalled()
198
+ expect(em.rollback).not.toHaveBeenCalled()
199
+ expect(em.flush).not.toHaveBeenCalled()
200
+ })
201
+
202
+ it('forwards isolationLevel to begin when opening a top-level transaction', async () => {
203
+ const em = createFakeEm()
204
+
205
+ await withAtomicFlush(em as any, [() => {}], {
206
+ transaction: true,
207
+ isolationLevel: 'serializable' as any,
208
+ })
209
+
210
+ expect(em.begin).toHaveBeenCalledTimes(1)
211
+ expect(em.begin).toHaveBeenCalledWith({ isolationLevel: 'serializable' })
212
+ expect(em.commit).toHaveBeenCalledTimes(1)
213
+ })
214
+
215
+ it('does not pass options to begin when no isolationLevel is set', async () => {
216
+ const em = createFakeEm()
217
+
218
+ await withAtomicFlush(em as any, [() => {}], { transaction: true })
219
+
220
+ expect(em.begin).toHaveBeenCalledWith(undefined)
221
+ })
222
+
223
+ it('opens its own transaction when the EM does not implement isInTransaction (partial/mock EM)', async () => {
224
+ // Many command unit tests mock an EntityManager with begin/commit/rollback/flush
225
+ // but no isInTransaction. The re-entrancy probe must not throw on such EMs — it
226
+ // treats the missing method as "not in a transaction" and opens its own.
227
+ const begin = jest.fn().mockResolvedValue(undefined)
228
+ const commit = jest.fn().mockResolvedValue(undefined)
229
+ const flush = jest.fn().mockResolvedValue(undefined)
230
+ const partialEm = { begin, commit, rollback: jest.fn(), flush }
231
+ const phase = jest.fn()
232
+
233
+ await expect(
234
+ withAtomicFlush(partialEm as any, [phase], { transaction: true }),
235
+ ).resolves.toBeUndefined()
236
+
237
+ expect(begin).toHaveBeenCalledTimes(1)
238
+ expect(phase).toHaveBeenCalledTimes(1)
239
+ expect(flush).toHaveBeenCalledTimes(1)
240
+ expect(commit).toHaveBeenCalledTimes(1)
241
+ })
166
242
  })
@@ -1,4 +1,28 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { IsolationLevel } from '@mikro-orm/core'
3
+
4
+ /**
5
+ * Options controlling how {@link withAtomicFlush} executes its phases.
6
+ */
7
+ export type AtomicFlushOptions = {
8
+ /**
9
+ * When true, the whole sequence runs inside a database transaction for
10
+ * all-or-nothing semantics. Default: false (a single `em.flush()` commits
11
+ * all phases at the end — no transaction).
12
+ */
13
+ transaction?: boolean
14
+ /**
15
+ * Optional transaction isolation level, forwarded to `em.begin()`. Only
16
+ * honoured when this call opens a new top-level transaction (i.e.
17
+ * `transaction: true` and the EntityManager is not already inside a
18
+ * transaction). Ignored when joining an ambient transaction.
19
+ */
20
+ isolationLevel?: IsolationLevel
21
+ /**
22
+ * Optional label for diagnostics. Currently informational only.
23
+ */
24
+ label?: string
25
+ }
2
26
 
3
27
  /**
4
28
  * Wraps multiple mutation phases in a single atomic flush.
@@ -10,10 +34,24 @@ import type { EntityManager } from '@mikro-orm/postgresql'
10
34
  * phases mutate, so closures over `em` stay valid.
11
35
  *
12
36
  * When `options.transaction` is true, the whole sequence runs
13
- * inside a database transaction (`em.begin()` / `em.commit()` /
14
- * `em.rollback()`) for all-or-nothing semantics. The outer `em`
15
- * stays bound to the transaction, so phases that close over `em`
16
- * participate in the same transaction.
37
+ * inside a database transaction for all-or-nothing semantics.
38
+ *
39
+ * ## Re-entrancy / composability
40
+ *
41
+ * `withAtomicFlush({ transaction: true })` is safe to nest. If the
42
+ * supplied `EntityManager` is **already inside a transaction**, this
43
+ * call does NOT open a second one (raw `em.begin()` would clobber the
44
+ * active `#transactionContext` and orphan the outer transaction — unlike
45
+ * `em.transactional()`, MikroORM's `em.begin()` does not check
46
+ * `isInTransaction()`). Instead it joins the ambient transaction: the
47
+ * phases run and flush within it, and the outermost caller owns the final
48
+ * `commit()` / `rollback()`. A phase error therefore rolls back the entire
49
+ * enclosing transaction (all-or-nothing across the whole nest).
50
+ *
51
+ * This mirrors the contract every command relies on: each command forks
52
+ * the request `EntityManager` first, so the common case opens a fresh
53
+ * top-level transaction; nesting only happens when one transactional unit
54
+ * is composed inside another on the same `em`.
17
55
  *
18
56
  * When `phases` is empty the call is a true no-op — no flush,
19
57
  * no transaction. Callers that need an explicit commit should
@@ -25,7 +63,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
25
63
  export async function withAtomicFlush(
26
64
  em: EntityManager,
27
65
  phases: Array<() => void | Promise<void>>,
28
- options?: { transaction?: boolean },
66
+ options?: AtomicFlushOptions,
29
67
  ): Promise<void> {
30
68
  if (phases.length === 0) return
31
69
 
@@ -36,21 +74,36 @@ export async function withAtomicFlush(
36
74
  await em.flush()
37
75
  }
38
76
 
39
- if (options?.transaction) {
40
- await em.begin()
41
- try {
42
- await runPhasesAndFlush()
43
- await em.commit()
44
- } catch (err) {
45
- try {
46
- await em.rollback()
47
- } catch {
48
- // rollback failure should not mask the original error; intentionally swallowed
49
- }
50
- throw err
51
- }
77
+ if (!options?.transaction) {
78
+ await runPhasesAndFlush()
79
+ return
80
+ }
81
+
82
+ // Re-entrancy guard: never open a nested transaction with raw begin/commit.
83
+ // If a transaction is already active on this EntityManager, join it — the
84
+ // outermost caller owns commit/rollback. A phase error propagates and rolls
85
+ // back the whole enclosing transaction.
86
+ //
87
+ // Guard the probe: real MikroORM EntityManagers always implement
88
+ // `isInTransaction()`, but partial / mock EMs may not. A missing method is
89
+ // treated as "not in a transaction", so this call opens its own top-level
90
+ // transaction via the begin/commit path below (which those EMs do support).
91
+ const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction
92
+ if (typeof isInTransaction === 'function' && isInTransaction.call(em)) {
93
+ await runPhasesAndFlush()
52
94
  return
53
95
  }
54
96
 
55
- await runPhasesAndFlush()
97
+ await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)
98
+ try {
99
+ await runPhasesAndFlush()
100
+ await em.commit()
101
+ } catch (err) {
102
+ try {
103
+ await em.rollback()
104
+ } catch {
105
+ // rollback failure should not mask the original error; intentionally swallowed
106
+ }
107
+ throw err
108
+ }
56
109
  }
@@ -4,8 +4,17 @@ jest.mock('@open-mercato/cache', () => ({
4
4
 
5
5
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
6
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
7
+ import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
7
8
  import { z } from 'zod'
8
9
 
10
+ // Keep the real custom-field helpers but spy on the definition loader so we can
11
+ // assert the factory skips the second DB round-trip when the query engine has
12
+ // already resolved definitions (issue #2133).
13
+ jest.mock('@open-mercato/shared/lib/crud/custom-fields', () => {
14
+ const actual = jest.requireActual('@open-mercato/shared/lib/crud/custom-fields')
15
+ return { ...actual, loadCustomFieldDefinitionIndex: jest.fn(async () => new Map()) }
16
+ })
17
+
9
18
  // ---- Mocks ----
10
19
  const mockEventBus = { emitEvent: jest.fn() }
11
20
  const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
@@ -25,6 +34,16 @@ let crudMutationGuardService: { validateMutation: jest.Mock; afterMutationSucces
25
34
  let mockOrganizationScopeOverride: MockOrganizationScope | null
26
35
 
27
36
  const em = {
37
+ transactional: async (cb: () => any) => {
38
+ const snapshot = Object.fromEntries(Object.entries(db).map(([key, value]) => [key, { ...value }]))
39
+ try {
40
+ return await cb()
41
+ } catch (error) {
42
+ for (const key of Object.keys(db)) delete db[key]
43
+ Object.assign(db, snapshot)
44
+ throw error
45
+ }
46
+ },
28
47
  create: (_cls: any, data: any) => ({ ...data, id: `id-${idSeq++}` }),
29
48
  persist(entity: Rec) {
30
49
  db[entity.id] = { ...(db[entity.id] || {} as any), ...entity }
@@ -241,6 +260,64 @@ describe('CRUD Factory', () => {
241
260
  }))
242
261
  })
243
262
 
263
+ const makeDecoratedRoute = () => makeCrudRoute({
264
+ metadata: { GET: { requireAuth: true } },
265
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
266
+ indexer: { entityType: 'example.todo' },
267
+ list: {
268
+ schema: querySchema,
269
+ entityId: 'example.todo',
270
+ fields: ['id', 'title'],
271
+ buildFilters: () => ({} as any),
272
+ decorateCustomFields: { entityIds: 'example.todo' },
273
+ },
274
+ })
275
+
276
+ const colorDefinitionIndex = () => new Map([
277
+ ['color', [{ key: 'color', label: 'Color', kind: 'text', multi: false, dictionaryId: null, organizationId: null, tenantId: null, priority: 0, updatedAt: 0 }]],
278
+ ])
279
+
280
+ it('reuses query engine custom-field definitions and skips the second DB load (#2133)', async () => {
281
+ const loadIndexMock = loadCustomFieldDefinitionIndex as unknown as jest.Mock
282
+ const cfRoute = makeDecoratedRoute()
283
+ queryEngine.query.mockResolvedValueOnce({
284
+ items: [{ id: 'id-1', title: 'A', cf_color: 'blue', organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
285
+ total: 1,
286
+ customFieldDefinitions: {
287
+ index: colorDefinitionIndex(),
288
+ entityIds: ['example.todo'],
289
+ tenantId: defaultTenantId,
290
+ organizationIds: [defaultOrganizationId],
291
+ },
292
+ })
293
+
294
+ const res = await cfRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
295
+ expect(res.status).toBe(200)
296
+ const body = await res.json()
297
+ expect(loadIndexMock).not.toHaveBeenCalled()
298
+ expect(body.items[0].customValues).toEqual({ color: 'blue' })
299
+ })
300
+
301
+ it('falls back to loading definitions when the engine index does not cover the scope', async () => {
302
+ const loadIndexMock = loadCustomFieldDefinitionIndex as unknown as jest.Mock
303
+ loadIndexMock.mockResolvedValueOnce(colorDefinitionIndex())
304
+ const cfRoute = makeDecoratedRoute()
305
+ queryEngine.query.mockResolvedValueOnce({
306
+ items: [{ id: 'id-1', title: 'A', cf_color: 'blue', organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
307
+ total: 1,
308
+ customFieldDefinitions: {
309
+ index: new Map(),
310
+ entityIds: ['example.todo'],
311
+ tenantId: defaultTenantId,
312
+ organizationIds: ['some-other-org'],
313
+ },
314
+ })
315
+
316
+ const res = await cfRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
317
+ expect(res.status).toBe(200)
318
+ expect(loadIndexMock).toHaveBeenCalledTimes(1)
319
+ })
320
+
244
321
  it('GET applies ids query filter in query engine path', async () => {
245
322
  const idA = '550e8400-e29b-41d4-a716-446655440001'
246
323
  const idB = '550e8400-e29b-41d4-a716-446655440002'
@@ -472,6 +549,28 @@ describe('CRUD Factory', () => {
472
549
  expect(db[created.id].title).toBe('X2')
473
550
  })
474
551
 
552
+ it('POST rolls back the created entity when the custom field write fails', async () => {
553
+ setRecordCustomFields.mockImplementationOnce(async () => { throw new Error('cf write failed') })
554
+ const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Atomic', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
555
+ expect(res.status).toBe(500)
556
+ // Entity write was rolled back together with the failed custom field write
557
+ expect(Object.values(db)).toHaveLength(0)
558
+ // No created event/index is emitted for a rolled-back create
559
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
560
+ })
561
+
562
+ it('PUT rolls back the entity update when the custom field write fails', async () => {
563
+ const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
564
+ created.id = '123e4567-e89b-12d3-a456-426614174003'
565
+ await em.persist(created).flush()
566
+ setRecordCustomFields.mockImplementationOnce(async () => { throw new Error('cf write failed') })
567
+ const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'After', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
568
+ expect(res.status).toBe(500)
569
+ // The scalar update was rolled back together with the failed custom field write
570
+ expect(db[created.id].title).toBe('Before')
571
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
572
+ })
573
+
475
574
  it('DELETE soft-deletes entity and emits deleted event', async () => {
476
575
  const created = em.create(Todo, { title: 'Y', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
477
576
  created.id = '123e4567-e89b-12d3-a456-426614174002'
@@ -0,0 +1,136 @@
1
+ import {
2
+ buildCustomFieldDefinitionIndexFromRows,
3
+ canReuseCustomFieldDefinitions,
4
+ resolveCfDefIndexOrgCandidates,
5
+ type CustomFieldDefinitionRow,
6
+ } from '../custom-field-definition-index'
7
+
8
+ const row = (overrides: Partial<CustomFieldDefinitionRow> & Pick<CustomFieldDefinitionRow, 'key' | 'entityId'>): CustomFieldDefinitionRow => ({
9
+ kind: 'text',
10
+ configJson: {},
11
+ organizationId: null,
12
+ tenantId: null,
13
+ deletedAt: null,
14
+ updatedAt: null,
15
+ ...overrides,
16
+ })
17
+
18
+ describe('buildCustomFieldDefinitionIndexFromRows', () => {
19
+ it('groups summaries by normalized key and summarizes config', () => {
20
+ const index = buildCustomFieldDefinitionIndexFromRows([
21
+ row({ key: 'Color', entityId: 'demo:entity', configJson: { label: 'Colour', multi: true, priority: 2 }, kind: 'select' }),
22
+ ])
23
+ expect(Array.from(index.keys())).toEqual(['color'])
24
+ const summaries = index.get('color')!
25
+ expect(summaries).toHaveLength(1)
26
+ expect(summaries[0]).toMatchObject({ key: 'Color', label: 'Colour', kind: 'select', multi: true, priority: 2 })
27
+ })
28
+
29
+ it('sorts summaries within a key by priority, then recency, then key', () => {
30
+ const index = buildCustomFieldDefinitionIndexFromRows([
31
+ row({ key: 'color', entityId: 'demo:entity', tenantId: 't1', organizationId: 'o1', configJson: { priority: 5 } }),
32
+ row({ key: 'color', entityId: 'demo:entity', tenantId: 't1', organizationId: 'o2', configJson: { priority: 1 } }),
33
+ ], { organizationIds: ['o1', 'o2'] })
34
+ const summaries = index.get('color')!
35
+ expect(summaries.map((s) => s.organizationId)).toEqual(['o2', 'o1'])
36
+ })
37
+
38
+ it('excludes soft-deleted rows', () => {
39
+ const index = buildCustomFieldDefinitionIndexFromRows([
40
+ row({ key: 'color', entityId: 'demo:entity', deletedAt: new Date('2026-01-01T00:00:00Z') }),
41
+ row({ key: 'size', entityId: 'demo:entity' }),
42
+ ])
43
+ expect(Array.from(index.keys()).sort()).toEqual(['size'])
44
+ })
45
+
46
+ it('keeps null-org rows and rows whose org is a candidate, drops foreign-org rows', () => {
47
+ const index = buildCustomFieldDefinitionIndexFromRows([
48
+ row({ key: 'global_field', entityId: 'demo:entity', organizationId: null }),
49
+ row({ key: 'scoped_field', entityId: 'demo:entity', organizationId: 'org-allowed' }),
50
+ row({ key: 'foreign_field', entityId: 'demo:entity', organizationId: 'org-other' }),
51
+ ], { organizationIds: ['org-allowed'] })
52
+ expect(Array.from(index.keys()).sort()).toEqual(['global_field', 'scoped_field'])
53
+ })
54
+
55
+ it('drops all explicit-org rows when no candidates are supplied', () => {
56
+ const index = buildCustomFieldDefinitionIndexFromRows([
57
+ row({ key: 'global_field', entityId: 'demo:entity', organizationId: null }),
58
+ row({ key: 'scoped_field', entityId: 'demo:entity', organizationId: 'org-allowed' }),
59
+ ], { organizationIds: [] })
60
+ expect(Array.from(index.keys()).sort()).toEqual(['global_field'])
61
+ })
62
+
63
+ it('filters by fieldset membership', () => {
64
+ const index = buildCustomFieldDefinitionIndexFromRows([
65
+ row({ key: 'a', entityId: 'demo:entity', configJson: { fieldset: 'pack' } }),
66
+ row({ key: 'b', entityId: 'demo:entity', configJson: { fieldsets: ['pack', 'other'] } }),
67
+ row({ key: 'c', entityId: 'demo:entity', configJson: { fieldset: 'other' } }),
68
+ ], { fieldset: 'pack' })
69
+ expect(Array.from(index.keys()).sort()).toEqual(['a', 'b'])
70
+ })
71
+ })
72
+
73
+ describe('resolveCfDefIndexOrgCandidates', () => {
74
+ it('prefers explicit organization ids and drops blanks', () => {
75
+ expect(resolveCfDefIndexOrgCandidates(['o1', '', null, 'o2'], 'fallback')).toEqual(['o1', 'o2'])
76
+ })
77
+
78
+ it('falls back to the singleton when no explicit ids are given', () => {
79
+ expect(resolveCfDefIndexOrgCandidates(null, 'fallback')).toEqual(['fallback'])
80
+ expect(resolveCfDefIndexOrgCandidates([], 'fallback')).toEqual(['fallback'])
81
+ })
82
+
83
+ it('returns an empty list when neither ids nor fallback resolve', () => {
84
+ expect(resolveCfDefIndexOrgCandidates(null, null)).toEqual([])
85
+ expect(resolveCfDefIndexOrgCandidates([], undefined)).toEqual([])
86
+ })
87
+ })
88
+
89
+ describe('canReuseCustomFieldDefinitions', () => {
90
+ const resolved = {
91
+ index: new Map(),
92
+ entityIds: ['demo:a', 'demo:b'],
93
+ tenantId: 't1',
94
+ organizationIds: ['o1'],
95
+ }
96
+
97
+ it('reuses when entity set, tenant, and org candidates all match (order-insensitive)', () => {
98
+ expect(canReuseCustomFieldDefinitions(resolved, {
99
+ entityIds: ['demo:b', 'demo:a'],
100
+ tenantId: 't1',
101
+ organizationIds: ['o1'],
102
+ })).toBe(true)
103
+ })
104
+
105
+ it('does not reuse when tenant differs', () => {
106
+ expect(canReuseCustomFieldDefinitions(resolved, {
107
+ entityIds: ['demo:a', 'demo:b'],
108
+ tenantId: 't2',
109
+ organizationIds: ['o1'],
110
+ })).toBe(false)
111
+ })
112
+
113
+ it('does not reuse when entity set differs', () => {
114
+ expect(canReuseCustomFieldDefinitions(resolved, {
115
+ entityIds: ['demo:a'],
116
+ tenantId: 't1',
117
+ organizationIds: ['o1'],
118
+ })).toBe(false)
119
+ })
120
+
121
+ it('does not reuse when org candidates differ', () => {
122
+ expect(canReuseCustomFieldDefinitions(resolved, {
123
+ entityIds: ['demo:a', 'demo:b'],
124
+ tenantId: 't1',
125
+ organizationIds: ['o2'],
126
+ })).toBe(false)
127
+ })
128
+
129
+ it('does not reuse when nothing was precomputed', () => {
130
+ expect(canReuseCustomFieldDefinitions(undefined, {
131
+ entityIds: ['demo:a'],
132
+ tenantId: 't1',
133
+ organizationIds: ['o1'],
134
+ })).toBe(false)
135
+ })
136
+ })