@declaro/data 2.0.0-beta.14 → 2.0.0-beta.140

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 (142) hide show
  1. package/{LICENSE → LICENSE.md} +1 -1
  2. package/README.md +0 -0
  3. package/dist/browser/index.js +26 -0
  4. package/dist/browser/index.js.map +93 -0
  5. package/dist/node/index.cjs +13372 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13351 -0
  8. package/dist/node/index.js.map +93 -0
  9. package/dist/ts/application/model-controller.d.ts +60 -0
  10. package/dist/ts/application/model-controller.d.ts.map +1 -0
  11. package/dist/ts/application/model-controller.test.d.ts +2 -0
  12. package/dist/ts/application/model-controller.test.d.ts.map +1 -0
  13. package/dist/ts/application/read-only-model-controller.d.ts +24 -0
  14. package/dist/ts/application/read-only-model-controller.d.ts.map +1 -0
  15. package/dist/ts/application/read-only-model-controller.test.d.ts +2 -0
  16. package/dist/ts/application/read-only-model-controller.test.d.ts.map +1 -0
  17. package/dist/ts/domain/events/domain-event.d.ts +41 -0
  18. package/dist/ts/domain/events/domain-event.d.ts.map +1 -0
  19. package/dist/ts/domain/events/domain-event.test.d.ts +2 -0
  20. package/dist/ts/domain/events/domain-event.test.d.ts.map +1 -0
  21. package/dist/ts/domain/events/event-types.d.ts +37 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +41 -0
  24. package/dist/ts/domain/events/mutation-event.d.ts.map +1 -0
  25. package/dist/ts/domain/events/mutation-event.test.d.ts +2 -0
  26. package/dist/ts/domain/events/mutation-event.test.d.ts.map +1 -0
  27. package/dist/ts/domain/events/query-event.d.ts +8 -0
  28. package/dist/ts/domain/events/query-event.d.ts.map +1 -0
  29. package/dist/ts/domain/events/query-event.test.d.ts +2 -0
  30. package/dist/ts/domain/events/query-event.test.d.ts.map +1 -0
  31. package/dist/ts/domain/events/request-event.d.ts +26 -0
  32. package/dist/ts/domain/events/request-event.d.ts.map +1 -0
  33. package/dist/ts/domain/events/request-event.test.d.ts +2 -0
  34. package/dist/ts/domain/events/request-event.test.d.ts.map +1 -0
  35. package/dist/ts/domain/interfaces/repository.d.ts +110 -0
  36. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
  37. package/dist/ts/domain/models/pagination.d.ts +28 -0
  38. package/dist/ts/domain/models/pagination.d.ts.map +1 -0
  39. package/dist/ts/domain/services/base-model-service.d.ts +23 -0
  40. package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
  41. package/dist/ts/domain/services/model-service-args.d.ts +9 -0
  42. package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
  43. package/dist/ts/domain/services/model-service.d.ts +99 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.normalization.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.normalization.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  48. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.d.ts +90 -0
  50. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  51. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  52. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  53. package/dist/ts/index.d.ts +18 -0
  54. package/dist/ts/index.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  56. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  57. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  58. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  59. package/dist/ts/shared/utils/schema-inheritance.test.d.ts +2 -0
  60. package/dist/ts/shared/utils/schema-inheritance.test.d.ts.map +1 -0
  61. package/dist/ts/shared/utils/test/animal-schema.d.ts +57 -0
  62. package/dist/ts/shared/utils/test/animal-schema.d.ts.map +1 -0
  63. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts +55 -0
  64. package/dist/ts/shared/utils/test/animal-trait-schema.d.ts.map +1 -0
  65. package/dist/ts/shared/utils/test/elephant-schema.d.ts +30 -0
  66. package/dist/ts/shared/utils/test/elephant-schema.d.ts.map +1 -0
  67. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts +26 -0
  68. package/dist/ts/shared/utils/test/elephant-trait-schema.d.ts.map +1 -0
  69. package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
  70. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  71. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts +2 -0
  72. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
  73. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
  74. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
  75. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
  77. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
  78. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
  79. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +62 -0
  80. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  81. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
  82. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
  83. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts +2 -0
  84. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  85. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  86. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  87. package/package.json +46 -42
  88. package/src/application/model-controller.test.ts +694 -0
  89. package/src/application/model-controller.ts +186 -0
  90. package/src/application/read-only-model-controller.test.ts +335 -0
  91. package/src/application/read-only-model-controller.ts +79 -0
  92. package/src/domain/events/domain-event.test.ts +82 -0
  93. package/src/domain/events/domain-event.ts +69 -0
  94. package/src/domain/events/event-types.ts +37 -0
  95. package/src/domain/events/mutation-event.test.ts +390 -0
  96. package/src/domain/events/mutation-event.ts +53 -0
  97. package/src/domain/events/query-event.test.ts +228 -0
  98. package/src/domain/events/query-event.ts +14 -0
  99. package/src/domain/events/request-event.test.ts +38 -0
  100. package/src/domain/events/request-event.ts +47 -0
  101. package/src/domain/interfaces/repository.ts +136 -0
  102. package/src/domain/models/pagination.ts +28 -0
  103. package/src/domain/services/base-model-service.ts +54 -0
  104. package/src/domain/services/model-service-args.ts +9 -0
  105. package/src/domain/services/model-service.normalization.test.ts +704 -0
  106. package/src/domain/services/model-service.test.ts +1616 -0
  107. package/src/domain/services/model-service.ts +597 -0
  108. package/src/domain/services/read-only-model-service.test.ts +1130 -0
  109. package/src/domain/services/read-only-model-service.ts +211 -0
  110. package/src/index.ts +17 -4
  111. package/src/shared/utils/schema-inference.ts +26 -0
  112. package/src/shared/utils/schema-inheritance.test.ts +295 -0
  113. package/src/shared/utils/schema-inheritance.ts +28 -0
  114. package/src/shared/utils/test/animal-schema.ts +46 -0
  115. package/src/shared/utils/test/animal-trait-schema.ts +45 -0
  116. package/src/shared/utils/test/elephant-schema.ts +58 -0
  117. package/src/shared/utils/test/elephant-trait-schema.ts +53 -0
  118. package/src/test/mock/models/mock-book-models.ts +78 -0
  119. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  120. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  121. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  122. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  123. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  124. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  125. package/src/test/mock/repositories/mock-memory-repository.ts +401 -0
  126. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  127. package/dist/databaseConnection.d.ts +0 -24
  128. package/dist/datastoreAbstract.d.ts +0 -37
  129. package/dist/declaro-data.cjs +0 -1
  130. package/dist/declaro-data.mjs +0 -250
  131. package/dist/hydrateEntity.d.ts +0 -8
  132. package/dist/index.d.ts +0 -4
  133. package/dist/serverConnection.d.ts +0 -15
  134. package/dist/trackedStatus.d.ts +0 -15
  135. package/src/databaseConnection.ts +0 -137
  136. package/src/datastoreAbstract.ts +0 -190
  137. package/src/hydrateEntity.ts +0 -36
  138. package/src/placeholder.test.ts +0 -7
  139. package/src/serverConnection.ts +0 -74
  140. package/src/trackedStatus.ts +0 -35
  141. package/tsconfig.json +0 -10
  142. package/vite.config.ts +0 -23
@@ -0,0 +1,1616 @@
1
+ import { EventManager } from '@declaro/core'
2
+ import { beforeEach, describe, expect, it, mock } from 'bun:test'
3
+ import { MockBookSchema } from '../../test/mock/models/mock-book-models'
4
+ import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
5
+ import { ModelService } from './model-service'
6
+
7
+ describe('ModelService', () => {
8
+ const namespace = 'books'
9
+ const mockSchema = MockBookSchema
10
+
11
+ let repository: MockMemoryRepository<typeof mockSchema>
12
+ let emitter: EventManager
13
+ let service: ModelService<typeof mockSchema>
14
+
15
+ beforeEach(() => {
16
+ repository = new MockMemoryRepository({ schema: mockSchema })
17
+ emitter = new EventManager()
18
+ service = new ModelService({ repository, emitter, schema: mockSchema, namespace })
19
+ })
20
+
21
+ const beforeCreateSpy = mock((event) => {})
22
+ const afterCreateSpy = mock((event) => {})
23
+ const beforeUpdateSpy = mock((event) => {})
24
+ const afterUpdateSpy = mock((event) => {})
25
+ const beforeRemoveSpy = mock((event) => {})
26
+ const afterRemoveSpy = mock((event) => {})
27
+ const beforeRestoreSpy = mock((event) => {})
28
+ const afterRestoreSpy = mock((event) => {})
29
+ const beforeDuplicateSpy = mock((event) => {})
30
+ const afterDuplicateSpy = mock((event) => {})
31
+
32
+ beforeEach(() => {
33
+ emitter.on('books::book.beforeCreate', beforeCreateSpy)
34
+ emitter.on('books::book.afterCreate', afterCreateSpy)
35
+ emitter.on('books::book.beforeUpdate', beforeUpdateSpy)
36
+ emitter.on('books::book.afterUpdate', afterUpdateSpy)
37
+ emitter.on('books::book.beforeRemove', beforeRemoveSpy)
38
+ emitter.on('books::book.afterRemove', afterRemoveSpy)
39
+ emitter.on('books::book.beforeRestore', beforeRestoreSpy)
40
+ emitter.on('books::book.afterRestore', afterRestoreSpy)
41
+ emitter.on('books::book.beforeDuplicate', beforeDuplicateSpy)
42
+ emitter.on('books::book.afterDuplicate', afterDuplicateSpy)
43
+
44
+ beforeCreateSpy.mockClear()
45
+ afterCreateSpy.mockClear()
46
+ beforeUpdateSpy.mockClear()
47
+ afterUpdateSpy.mockClear()
48
+ beforeRemoveSpy.mockClear()
49
+ afterRemoveSpy.mockClear()
50
+ beforeRestoreSpy.mockClear()
51
+ afterRestoreSpy.mockClear()
52
+ beforeDuplicateSpy.mockClear()
53
+ afterDuplicateSpy.mockClear()
54
+ })
55
+
56
+ it('should create a record', async () => {
57
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
58
+ const createdRecord = await service.create(input)
59
+
60
+ expect(createdRecord).toEqual(input)
61
+ })
62
+
63
+ it('should update a record', async () => {
64
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
65
+ await repository.create(input)
66
+
67
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
68
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
69
+
70
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
71
+ })
72
+
73
+ it('should remove a record', async () => {
74
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
75
+ await repository.create(input)
76
+
77
+ const removedRecord = await service.remove({ id: 42 })
78
+
79
+ expect(removedRecord).toEqual(input)
80
+ expect(await repository.load({ id: 42 })).toBeNull()
81
+ })
82
+
83
+ it('should restore a record', async () => {
84
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
85
+ await repository.create(input)
86
+ await repository.remove({ id: 42 })
87
+
88
+ const restoredRecord = await service.restore({ id: 42 })
89
+
90
+ expect(restoredRecord).toEqual(input)
91
+ expect(await repository.load({ id: 42 })).toEqual(input)
92
+ })
93
+
94
+ it('should trigger before and after events for create', async () => {
95
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
96
+ const createdRecord = await service.create(input)
97
+
98
+ expect(createdRecord).toEqual(input)
99
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
100
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
101
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
102
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
103
+ })
104
+
105
+ it('should trigger before and after events for update', async () => {
106
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
107
+ await repository.create(input)
108
+
109
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
110
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
111
+
112
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
113
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
114
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
115
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
116
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
117
+ })
118
+
119
+ it('should trigger before and after events for remove', async () => {
120
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
121
+ await repository.create(input)
122
+
123
+ const removedRecord = await service.remove({ id: 42 })
124
+
125
+ expect(removedRecord).toEqual(input)
126
+ expect(beforeRemoveSpy).toHaveBeenCalledTimes(1)
127
+ expect(beforeRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRemove' }))
128
+ expect(afterRemoveSpy).toHaveBeenCalledTimes(1)
129
+ expect(afterRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRemove' }))
130
+ })
131
+
132
+ it('should trigger before and after events for restore', async () => {
133
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
134
+ await repository.create(input)
135
+ await repository.remove({ id: 42 })
136
+
137
+ const restoredRecord = await service.restore({ id: 42 })
138
+
139
+ expect(restoredRecord).toEqual(input)
140
+ expect(beforeRestoreSpy).toHaveBeenCalledTimes(1)
141
+ expect(beforeRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRestore' }))
142
+ expect(afterRestoreSpy).toHaveBeenCalledTimes(1)
143
+ expect(afterRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRestore' }))
144
+ })
145
+
146
+ it('should throw an error when attempting to remove a non-existent record', async () => {
147
+ await expect(service.remove({ id: 999 })).rejects.toThrow('Item not found')
148
+ })
149
+
150
+ it('should throw an error when attempting to update a non-existent record', async () => {
151
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
152
+ await expect(service.update({ id: 999 }, updatedInput)).rejects.toThrow('Item not found')
153
+ })
154
+
155
+ describe('upsert functionality', () => {
156
+ it('should create a new record when no existing record with primary key exists', async () => {
157
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
158
+
159
+ const upsertedRecord = await service.upsert(input)
160
+
161
+ expect(upsertedRecord).toEqual(input)
162
+ expect(await repository.load({ id: 42 })).toEqual(input)
163
+ })
164
+
165
+ it('should update an existing record when primary key matches', async () => {
166
+ // Create initial record
167
+ const initial = {
168
+ id: 42,
169
+ title: 'Original Book',
170
+ author: 'Original Author',
171
+ publishedDate: new Date('2023-01-01'),
172
+ }
173
+ await repository.create(initial)
174
+
175
+ // Upsert with same ID but different data
176
+ const update = {
177
+ id: 42,
178
+ title: 'Updated Book',
179
+ author: 'Updated Author',
180
+ publishedDate: new Date('2023-12-01'),
181
+ }
182
+ const upsertedRecord = await service.upsert(update)
183
+
184
+ expect(upsertedRecord).toEqual(update)
185
+ expect(await repository.load({ id: 42 })).toEqual(update)
186
+ })
187
+
188
+ it('should create a new record when upserting without primary key', async () => {
189
+ const input = { title: 'Book Without ID', author: 'Author Name', publishedDate: new Date() }
190
+
191
+ const upsertedRecord = await service.upsert(input)
192
+
193
+ expect(upsertedRecord.id).toBeDefined()
194
+ expect(upsertedRecord.title).toBe(input.title)
195
+ expect(upsertedRecord.author).toBe(input.author)
196
+ expect(await repository.load({ id: upsertedRecord.id })).toEqual(upsertedRecord)
197
+ })
198
+
199
+ it('should create a new record when primary key is null', async () => {
200
+ const input = {
201
+ id: undefined,
202
+ title: 'Book With Undefined ID',
203
+ author: 'Author Name',
204
+ publishedDate: new Date(),
205
+ }
206
+
207
+ const upsertedRecord = await service.upsert(input)
208
+
209
+ expect(upsertedRecord.id).toBeDefined()
210
+ expect(upsertedRecord.id).not.toBeNull()
211
+ expect(upsertedRecord.title).toBe(input.title)
212
+ expect(upsertedRecord.author).toBe(input.author)
213
+ })
214
+
215
+ it('should trigger create events when upserting a new record', async () => {
216
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
217
+
218
+ const upsertedRecord = await service.upsert(input)
219
+
220
+ expect(upsertedRecord).toEqual(input)
221
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
222
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
223
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
224
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
225
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
226
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
227
+ })
228
+
229
+ it('should trigger update events when upserting an existing record', async () => {
230
+ // Create initial record
231
+ const initial = { id: 42, title: 'Original Book', author: 'Original Author', publishedDate: new Date() }
232
+ await repository.create(initial)
233
+
234
+ // Clear create spies since they were called during setup
235
+ beforeCreateSpy.mockClear()
236
+ afterCreateSpy.mockClear()
237
+
238
+ // Upsert existing record
239
+ const update = { id: 42, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
240
+ const upsertedRecord = await service.upsert(update)
241
+
242
+ expect(upsertedRecord).toEqual(update)
243
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
244
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
245
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
246
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
247
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
248
+ expect(afterCreateSpy).not.toHaveBeenCalled()
249
+ })
250
+
251
+ it('should trigger create events when upserting with non-existent primary key', async () => {
252
+ // Try to upsert with an ID that doesn't exist
253
+ const input = { id: 999, title: 'Non-existent Book', author: 'Author Name', publishedDate: new Date() }
254
+
255
+ const upsertedRecord = await service.upsert(input)
256
+
257
+ expect(upsertedRecord).toEqual(input)
258
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
259
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
260
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
261
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
262
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
263
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
264
+ })
265
+
266
+ it('should handle multiple upserts correctly', async () => {
267
+ // First upsert (create)
268
+ const input1 = { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() }
269
+ const result1 = await service.upsert(input1)
270
+ expect(result1).toEqual(input1)
271
+ expect(await repository.load({ id: 1 })).toEqual(input1)
272
+
273
+ // Second upsert (update)
274
+ const input2 = { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() }
275
+ const result2 = await service.upsert(input2)
276
+ expect(result2).toEqual(input2)
277
+ expect(await repository.load({ id: 1 })).toEqual(input2)
278
+
279
+ // Third upsert (create new)
280
+ const input3 = { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() }
281
+ const result3 = await service.upsert(input3)
282
+ expect(result3).toEqual(input3)
283
+ expect(await repository.load({ id: 2 })).toEqual(input3)
284
+ })
285
+
286
+ it('should work with auto-generated IDs', async () => {
287
+ const input1 = { title: 'Auto ID Book 1', author: 'Author 1', publishedDate: new Date() }
288
+ const input2 = { title: 'Auto ID Book 2', author: 'Author 2', publishedDate: new Date() }
289
+
290
+ const result1 = await service.upsert(input1)
291
+ const result2 = await service.upsert(input2)
292
+
293
+ expect(result1.id).toBeDefined()
294
+ expect(result2.id).toBeDefined()
295
+ expect(result1.id).not.toBe(result2.id)
296
+ expect(result1.title).toBe(input1.title)
297
+ expect(result2.title).toBe(input2.title)
298
+ })
299
+ })
300
+
301
+ describe('bulkUpsert functionality', () => {
302
+ it('should handle empty input array', async () => {
303
+ const results = await service.bulkUpsert([])
304
+ expect(results).toEqual([])
305
+ })
306
+
307
+ it('should create multiple new records with explicit IDs', async () => {
308
+ const inputs = [
309
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
310
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
311
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
312
+ ]
313
+
314
+ const results = await service.bulkUpsert(inputs)
315
+
316
+ expect(results).toEqual(inputs)
317
+ expect(results).toHaveLength(3)
318
+
319
+ // Verify all records were created
320
+ for (const input of inputs) {
321
+ const loaded = await repository.load({ id: input.id })
322
+ expect(loaded).toEqual(input)
323
+ }
324
+ })
325
+
326
+ it('should update multiple existing records', async () => {
327
+ // Create initial records
328
+ const initialRecords = [
329
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date('2023-01-01') },
330
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date('2023-02-01') },
331
+ { id: 3, title: 'Original Book 3', author: 'Original Author 3', publishedDate: new Date('2023-03-01') },
332
+ ]
333
+
334
+ for (const record of initialRecords) {
335
+ await repository.create(record)
336
+ }
337
+
338
+ // Clear spies from creation
339
+ beforeCreateSpy.mockClear()
340
+ afterCreateSpy.mockClear()
341
+
342
+ // Update all records
343
+ const updateInputs = [
344
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') },
345
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date('2023-07-01') },
346
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') },
347
+ ]
348
+
349
+ const results = await service.bulkUpsert(updateInputs)
350
+
351
+ expect(results).toEqual(updateInputs)
352
+ expect(results).toHaveLength(3)
353
+
354
+ // Verify all records were updated
355
+ for (const input of updateInputs) {
356
+ const loaded = await repository.load({ id: input.id })
357
+ expect(loaded).toEqual(input)
358
+ }
359
+ })
360
+
361
+ it('should handle mixed create and update operations', async () => {
362
+ // Create some initial records
363
+ const initialRecords = [
364
+ { id: 1, title: 'Existing Book 1', author: 'Existing Author 1', publishedDate: new Date('2023-01-01') },
365
+ { id: 3, title: 'Existing Book 3', author: 'Existing Author 3', publishedDate: new Date('2023-03-01') },
366
+ ]
367
+
368
+ for (const record of initialRecords) {
369
+ await repository.create(record)
370
+ }
371
+
372
+ // Clear spies from creation
373
+ beforeCreateSpy.mockClear()
374
+ afterCreateSpy.mockClear()
375
+
376
+ // Mix of updates and creates
377
+ const mixedInputs = [
378
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') }, // Update
379
+ { id: 2, title: 'New Book 2', author: 'New Author 2', publishedDate: new Date('2023-07-01') }, // Create
380
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') }, // Update
381
+ { id: 4, title: 'New Book 4', author: 'New Author 4', publishedDate: new Date('2023-09-01') }, // Create
382
+ ]
383
+
384
+ const results = await service.bulkUpsert(mixedInputs)
385
+
386
+ expect(results).toEqual(mixedInputs)
387
+ expect(results).toHaveLength(4)
388
+
389
+ // Verify all records exist with correct data
390
+ for (const input of mixedInputs) {
391
+ const loaded = await repository.load({ id: input.id })
392
+ expect(loaded).toEqual(input)
393
+ }
394
+ })
395
+
396
+ it('should handle records without primary keys (auto-generated IDs)', async () => {
397
+ const inputsWithoutIds = [
398
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-01-01') },
399
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-02-01') },
400
+ ]
401
+
402
+ const results = await service.bulkUpsert(inputsWithoutIds)
403
+
404
+ expect(results).toHaveLength(2)
405
+ expect(results[0].id).toBeDefined()
406
+ expect(results[1].id).toBeDefined()
407
+ expect(results[0].id).not.toBe(results[1].id)
408
+ expect(results[0].title).toBe(inputsWithoutIds[0].title)
409
+ expect(results[1].title).toBe(inputsWithoutIds[1].title)
410
+
411
+ // Verify records were created
412
+ for (const result of results) {
413
+ const loaded = await repository.load({ id: result.id })
414
+ expect(loaded).toEqual(result)
415
+ }
416
+ })
417
+
418
+ it('should handle mixed records with and without primary keys', async () => {
419
+ // Create an existing record with a higher ID to avoid conflicts with auto-generated IDs
420
+ const existingRecord = {
421
+ id: 100,
422
+ title: 'Existing Book',
423
+ author: 'Existing Author',
424
+ publishedDate: new Date('2023-01-01'),
425
+ }
426
+ await repository.create(existingRecord)
427
+
428
+ // Clear spies from creation
429
+ beforeCreateSpy.mockClear()
430
+ afterCreateSpy.mockClear()
431
+
432
+ const mixedInputs = [
433
+ {
434
+ id: 100,
435
+ title: 'Updated Existing Book',
436
+ author: 'Updated Author',
437
+ publishedDate: new Date('2023-06-01'),
438
+ }, // Update
439
+ { id: 200, title: 'New Book with ID', author: 'New Author', publishedDate: new Date('2023-07-01') }, // Create with ID
440
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-08-01') }, // Create without ID
441
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-09-01') }, // Create without ID
442
+ ]
443
+
444
+ const results = await service.bulkUpsert(mixedInputs)
445
+
446
+ expect(results).toHaveLength(4)
447
+
448
+ // Results should be in the same order as inputs (repository preserves order)
449
+ expect(results[0].id).toBe(100)
450
+ expect(results[0].title).toBe('Updated Existing Book')
451
+
452
+ expect(results[1].id).toBe(200)
453
+ expect(results[1].title).toBe('New Book with ID')
454
+
455
+ expect(results[2].id).toBeDefined()
456
+ expect(results[2].title).toBe('Auto Book 1')
457
+
458
+ expect(results[3].id).toBeDefined()
459
+ expect(results[3].title).toBe('Auto Book 2')
460
+
461
+ // Verify all records exist in repository
462
+ const loadedRecord1 = await repository.load({ id: 100 })
463
+ expect(loadedRecord1?.title).toBe('Updated Existing Book')
464
+
465
+ const loadedRecord2 = await repository.load({ id: 200 })
466
+ expect(loadedRecord2?.title).toBe('New Book with ID')
467
+
468
+ const loadedRecord3 = await repository.load({ id: results[2].id })
469
+ expect(loadedRecord3?.title).toBe('Auto Book 1')
470
+
471
+ const loadedRecord4 = await repository.load({ id: results[3].id })
472
+ expect(loadedRecord4?.title).toBe('Auto Book 2')
473
+ })
474
+
475
+ it('should trigger correct before and after events for bulk create operations', async () => {
476
+ const inputs = [
477
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
478
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
479
+ ]
480
+
481
+ await service.bulkUpsert(inputs)
482
+
483
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
484
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
485
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
486
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
487
+
488
+ // Verify event details
489
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
490
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
491
+ })
492
+
493
+ it('should trigger correct before and after events for bulk update operations', async () => {
494
+ // Create initial records
495
+ const initialRecords = [
496
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date() },
497
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date() },
498
+ ]
499
+
500
+ for (const record of initialRecords) {
501
+ await repository.create(record)
502
+ }
503
+
504
+ // Clear spies from creation
505
+ beforeCreateSpy.mockClear()
506
+ afterCreateSpy.mockClear()
507
+
508
+ // Update records
509
+ const updateInputs = [
510
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() },
511
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date() },
512
+ ]
513
+
514
+ await service.bulkUpsert(updateInputs)
515
+
516
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
517
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
518
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
519
+ expect(afterCreateSpy).not.toHaveBeenCalled()
520
+
521
+ // Verify event details
522
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
523
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
524
+ })
525
+
526
+ it('should trigger correct before and after events for mixed operations', async () => {
527
+ // Create one existing record
528
+ const existingRecord = {
529
+ id: 1,
530
+ title: 'Existing Book',
531
+ author: 'Existing Author',
532
+ publishedDate: new Date(),
533
+ }
534
+ await repository.create(existingRecord)
535
+
536
+ // Clear spies from creation
537
+ beforeCreateSpy.mockClear()
538
+ afterCreateSpy.mockClear()
539
+
540
+ // Mix of update and create
541
+ const mixedInputs = [
542
+ { id: 1, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }, // Update
543
+ { id: 2, title: 'New Book', author: 'New Author', publishedDate: new Date() }, // Create
544
+ ]
545
+
546
+ await service.bulkUpsert(mixedInputs)
547
+
548
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
549
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
550
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
551
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
552
+ })
553
+
554
+ it('should trigger events for records without primary keys', async () => {
555
+ const inputsWithoutIds = [
556
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date() },
557
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date() },
558
+ ]
559
+
560
+ await service.bulkUpsert(inputsWithoutIds)
561
+
562
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
563
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
564
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
565
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
566
+ })
567
+
568
+ it('should handle large batches efficiently', async () => {
569
+ const batchSize = 100
570
+ const inputs = Array.from({ length: batchSize }, (_, index) => ({
571
+ id: index + 1,
572
+ title: `Book ${index + 1}`,
573
+ author: `Author ${index + 1}`,
574
+ publishedDate: new Date(`2023-${String((index % 12) + 1).padStart(2, '0')}-01`),
575
+ }))
576
+
577
+ const startTime = Date.now()
578
+ const results = await service.bulkUpsert(inputs)
579
+ const endTime = Date.now()
580
+
581
+ expect(results).toHaveLength(batchSize)
582
+ expect(results).toEqual(inputs)
583
+
584
+ // Verify events were triggered for all items
585
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(batchSize)
586
+ expect(afterCreateSpy).toHaveBeenCalledTimes(batchSize)
587
+
588
+ // Basic performance check - should complete in reasonable time
589
+ expect(endTime - startTime).toBeLessThan(5000) // Less than 5 seconds
590
+
591
+ // Verify a few random records
592
+ const randomIndexes = [0, Math.floor(batchSize / 2), batchSize - 1]
593
+ for (const index of randomIndexes) {
594
+ const loaded = await repository.load({ id: inputs[index].id })
595
+ expect(loaded).toEqual(inputs[index])
596
+ }
597
+ })
598
+
599
+ it('should maintain data integrity when bulk upserting duplicate primary keys in input', async () => {
600
+ // Input with duplicate IDs - last one should win
601
+ const inputsWithDuplicates = [
602
+ { id: 1, title: 'First Book 1', author: 'First Author 1', publishedDate: new Date('2023-01-01') },
603
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
604
+ { id: 1, title: 'Last Book 1', author: 'Last Author 1', publishedDate: new Date('2023-03-01') }, // Duplicate ID
605
+ ]
606
+
607
+ const results = await service.bulkUpsert(inputsWithDuplicates)
608
+
609
+ expect(results).toHaveLength(3)
610
+
611
+ // The repository should handle duplicates according to its implementation
612
+ // In our mock implementation, it processes them sequentially
613
+ const finalRecord1 = await repository.load({ id: 1 })
614
+ expect(finalRecord1?.title).toBe('Last Book 1') // Last write wins
615
+
616
+ const record2 = await repository.load({ id: 2 })
617
+ expect(record2?.title).toBe('Book 2')
618
+ })
619
+
620
+ it('should preserve order of results matching input order', async () => {
621
+ const inputs = [
622
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
623
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
624
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
625
+ ]
626
+
627
+ const results = await service.bulkUpsert(inputs)
628
+
629
+ expect(results).toHaveLength(3)
630
+ expect(results[0].id).toBe(3)
631
+ expect(results[1].id).toBe(1)
632
+ expect(results[2].id).toBe(2)
633
+ expect(results).toEqual(inputs)
634
+ })
635
+ })
636
+
637
+ describe('Trash Functionality', () => {
638
+ const beforeEmptyTrashSpy = mock((event) => {})
639
+ const afterEmptyTrashSpy = mock((event) => {})
640
+ const beforePermanentlyDeleteFromTrashSpy = mock((event) => {})
641
+ const afterPermanentlyDeleteFromTrashSpy = mock((event) => {})
642
+ const beforePermanentlyDeleteSpy = mock((event) => {})
643
+ const afterPermanentlyDeleteSpy = mock((event) => {})
644
+
645
+ beforeEach(() => {
646
+ emitter.on('books::book.beforeEmptyTrash', beforeEmptyTrashSpy)
647
+ emitter.on('books::book.afterEmptyTrash', afterEmptyTrashSpy)
648
+ emitter.on('books::book.beforePermanentlyDeleteFromTrash', beforePermanentlyDeleteFromTrashSpy)
649
+ emitter.on('books::book.afterPermanentlyDeleteFromTrash', afterPermanentlyDeleteFromTrashSpy)
650
+ emitter.on('books::book.beforePermanentlyDelete', beforePermanentlyDeleteSpy)
651
+ emitter.on('books::book.afterPermanentlyDelete', afterPermanentlyDeleteSpy)
652
+
653
+ beforeEmptyTrashSpy.mockClear()
654
+ afterEmptyTrashSpy.mockClear()
655
+ beforePermanentlyDeleteFromTrashSpy.mockClear()
656
+ afterPermanentlyDeleteFromTrashSpy.mockClear()
657
+ beforePermanentlyDeleteSpy.mockClear()
658
+ afterPermanentlyDeleteSpy.mockClear()
659
+ })
660
+
661
+ describe('emptyTrash', () => {
662
+ it('should permanently delete all items from trash', async () => {
663
+ // Create and remove multiple items
664
+ const book1 = await repository.create({
665
+ title: 'Book 1',
666
+ author: 'Author 1',
667
+ publishedDate: new Date(),
668
+ })
669
+ const book2 = await repository.create({
670
+ title: 'Book 2',
671
+ author: 'Author 2',
672
+ publishedDate: new Date(),
673
+ })
674
+ const book3 = await repository.create({
675
+ title: 'Book 3',
676
+ author: 'Author 3',
677
+ publishedDate: new Date(),
678
+ })
679
+
680
+ await repository.remove({ id: book1.id })
681
+ await repository.remove({ id: book2.id })
682
+ await repository.remove({ id: book3.id })
683
+
684
+ // Empty trash
685
+ const count = await service.emptyTrash()
686
+
687
+ expect(count).toBe(3)
688
+
689
+ // Verify items are no longer in trash
690
+ const inTrash1 = await repository.load({ id: book1.id }, { removedOnly: true })
691
+ const inTrash2 = await repository.load({ id: book2.id }, { removedOnly: true })
692
+ const inTrash3 = await repository.load({ id: book3.id }, { removedOnly: true })
693
+
694
+ expect(inTrash1).toBeNull()
695
+ expect(inTrash2).toBeNull()
696
+ expect(inTrash3).toBeNull()
697
+ })
698
+
699
+ it('should permanently delete filtered items from trash', async () => {
700
+ const repositoryWithFilter = new MockMemoryRepository({
701
+ schema: mockSchema,
702
+ filter: (data, filters) => {
703
+ if (filters.text) {
704
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
705
+ }
706
+ return true
707
+ },
708
+ })
709
+
710
+ const serviceWithFilter = new ModelService({
711
+ repository: repositoryWithFilter,
712
+ emitter,
713
+ schema: mockSchema,
714
+ namespace,
715
+ })
716
+
717
+ const book1 = await repositoryWithFilter.create({
718
+ title: 'Test Book 1',
719
+ author: 'Author 1',
720
+ publishedDate: new Date(),
721
+ })
722
+ const book2 = await repositoryWithFilter.create({
723
+ title: 'Test Book 2',
724
+ author: 'Author 2',
725
+ publishedDate: new Date(),
726
+ })
727
+ const book3 = await repositoryWithFilter.create({
728
+ title: 'Other Book',
729
+ author: 'Author 3',
730
+ publishedDate: new Date(),
731
+ })
732
+
733
+ await repositoryWithFilter.remove({ id: book1.id })
734
+ await repositoryWithFilter.remove({ id: book2.id })
735
+ await repositoryWithFilter.remove({ id: book3.id })
736
+
737
+ // Empty trash with filter
738
+ const count = await serviceWithFilter.emptyTrash({ text: 'Test' })
739
+
740
+ expect(count).toBe(2)
741
+
742
+ // Verify only filtered items were deleted
743
+ const inTrash1 = await repositoryWithFilter.load({ id: book1.id }, { removedOnly: true })
744
+ const inTrash2 = await repositoryWithFilter.load({ id: book2.id }, { removedOnly: true })
745
+ const inTrash3 = await repositoryWithFilter.load({ id: book3.id }, { removedOnly: true })
746
+
747
+ expect(inTrash1).toBeNull()
748
+ expect(inTrash2).toBeNull()
749
+ expect(inTrash3).not.toBeNull()
750
+ expect(inTrash3?.title).toBe('Other Book')
751
+ })
752
+
753
+ it('should return 0 when trash is empty', async () => {
754
+ const count = await service.emptyTrash()
755
+ expect(count).toBe(0)
756
+ })
757
+
758
+ it('should trigger before and after events for emptyTrash', async () => {
759
+ const book = await repository.create({
760
+ title: 'Book to Remove',
761
+ author: 'Author',
762
+ publishedDate: new Date(),
763
+ })
764
+ await repository.remove({ id: book.id })
765
+
766
+ await service.emptyTrash()
767
+
768
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledTimes(1)
769
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledWith(
770
+ expect.objectContaining({ type: 'books::book.beforeEmptyTrash' }),
771
+ )
772
+ expect(afterEmptyTrashSpy).toHaveBeenCalledTimes(1)
773
+ expect(afterEmptyTrashSpy).toHaveBeenCalledWith(
774
+ expect.objectContaining({ type: 'books::book.afterEmptyTrash' }),
775
+ )
776
+ })
777
+
778
+ it('should not affect non-removed items', async () => {
779
+ await repository.create({ title: 'Active Book 1', author: 'Author 1', publishedDate: new Date() })
780
+ await repository.create({ title: 'Active Book 2', author: 'Author 2', publishedDate: new Date() })
781
+
782
+ const book3 = await repository.create({
783
+ title: 'Book to Remove',
784
+ author: 'Author 3',
785
+ publishedDate: new Date(),
786
+ })
787
+ await repository.remove({ id: book3.id })
788
+
789
+ // Empty trash
790
+ await service.emptyTrash()
791
+
792
+ // Verify active items are still there
793
+ const results = await repository.search({})
794
+ expect(results.results).toHaveLength(2)
795
+ })
796
+ })
797
+
798
+ describe('permanentlyDeleteFromTrash', () => {
799
+ it('should permanently delete a removed item from trash', async () => {
800
+ const book = await repository.create({
801
+ title: 'Book to Delete',
802
+ author: 'Author',
803
+ publishedDate: new Date(),
804
+ })
805
+
806
+ // Remove the book
807
+ await repository.remove({ id: book.id })
808
+
809
+ // Verify it's in trash
810
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
811
+ expect(inTrash).not.toBeNull()
812
+
813
+ // Permanently delete from trash
814
+ const deleted = await service.permanentlyDeleteFromTrash({ id: book.id })
815
+
816
+ expect(deleted.id).toBe(book.id)
817
+ expect(deleted.title).toBe('Book to Delete')
818
+
819
+ // Verify it's no longer in trash
820
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
821
+ expect(afterDelete).toBeNull()
822
+
823
+ // Verify it's not in main data either
824
+ const inMain = await repository.load({ id: book.id })
825
+ expect(inMain).toBeNull()
826
+ })
827
+
828
+ it('should throw error when trying to delete non-existent item from trash', async () => {
829
+ await expect(service.permanentlyDeleteFromTrash({ id: 999 })).rejects.toThrow()
830
+ })
831
+
832
+ it('should throw error when trying to delete active item from trash', async () => {
833
+ const book = await repository.create({
834
+ title: 'Active Book',
835
+ author: 'Author',
836
+ publishedDate: new Date(),
837
+ })
838
+
839
+ // Should fail because item is not in trash
840
+ await expect(service.permanentlyDeleteFromTrash({ id: book.id })).rejects.toThrow()
841
+ })
842
+
843
+ it('should trigger before and after events for permanentlyDeleteFromTrash', async () => {
844
+ const book = await repository.create({
845
+ title: 'Book to Delete',
846
+ author: 'Author',
847
+ publishedDate: new Date(),
848
+ })
849
+ await repository.remove({ id: book.id })
850
+
851
+ await service.permanentlyDeleteFromTrash({ id: book.id })
852
+
853
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
854
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
855
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDeleteFromTrash' }),
856
+ )
857
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
858
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
859
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDeleteFromTrash' }),
860
+ )
861
+ })
862
+ })
863
+
864
+ describe('permanentlyDelete', () => {
865
+ it('should permanently delete a removed item', async () => {
866
+ const book = await repository.create({
867
+ title: 'Book to Delete',
868
+ author: 'Author',
869
+ publishedDate: new Date(),
870
+ })
871
+
872
+ // Remove the book first
873
+ await repository.remove({ id: book.id })
874
+
875
+ // Verify it's in trash
876
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
877
+ expect(inTrash).not.toBeNull()
878
+
879
+ // Permanently delete
880
+ const deleted = await service.permanentlyDelete({ id: book.id })
881
+
882
+ expect(deleted.id).toBe(book.id)
883
+ expect(deleted.title).toBe('Book to Delete')
884
+
885
+ // Verify it's no longer anywhere
886
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
887
+ expect(afterDelete).toBeNull()
888
+
889
+ const inMain = await repository.load({ id: book.id })
890
+ expect(inMain).toBeNull()
891
+ })
892
+
893
+ it('should permanently delete an active item', async () => {
894
+ const book = await repository.create({
895
+ title: 'Active Book to Delete',
896
+ author: 'Author',
897
+ publishedDate: new Date(),
898
+ })
899
+
900
+ // Verify it exists
901
+ const exists = await repository.load({ id: book.id })
902
+ expect(exists).not.toBeNull()
903
+
904
+ // Permanently delete without removing first
905
+ const deleted = await service.permanentlyDelete({ id: book.id })
906
+
907
+ expect(deleted.id).toBe(book.id)
908
+ expect(deleted.title).toBe('Active Book to Delete')
909
+
910
+ // Verify it's no longer in main data
911
+ const afterDelete = await repository.load({ id: book.id })
912
+ expect(afterDelete).toBeNull()
913
+
914
+ // Verify it's not in trash either
915
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
916
+ expect(inTrash).toBeNull()
917
+ })
918
+
919
+ it('should throw error when trying to delete non-existent item', async () => {
920
+ await expect(service.permanentlyDelete({ id: 999 })).rejects.toThrow()
921
+ })
922
+
923
+ it('should trigger before and after events for permanentlyDelete', async () => {
924
+ const book = await repository.create({
925
+ title: 'Book to Delete',
926
+ author: 'Author',
927
+ publishedDate: new Date(),
928
+ })
929
+
930
+ await service.permanentlyDelete({ id: book.id })
931
+
932
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
933
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledWith(
934
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDelete' }),
935
+ )
936
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
937
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledWith(
938
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDelete' }),
939
+ )
940
+ })
941
+ })
942
+ })
943
+
944
+ describe('detailsToInput', () => {
945
+ it('should convert a detail object to an input by picking only input fields', async () => {
946
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
947
+ const input = await service.detailsToInput(detail)
948
+
949
+ expect(input.title).toBe('Test Book')
950
+ expect(input.author).toBe('Author Name')
951
+ expect(input.publishedDate).toEqual(new Date('2023-06-15'))
952
+ })
953
+
954
+ it('should exclude fields not present in the input model', async () => {
955
+ const detailWithExtras = {
956
+ id: 42,
957
+ title: 'Test Book',
958
+ author: 'Author Name',
959
+ publishedDate: new Date('2023-06-15'),
960
+ extraField: 'should be excluded',
961
+ anotherExtra: 123,
962
+ }
963
+ const input = await service.detailsToInput(detailWithExtras)
964
+
965
+ expect(input.title).toBe('Test Book')
966
+ expect(input.author).toBe('Author Name')
967
+ expect((input as any).extraField).toBeUndefined()
968
+ expect((input as any).anotherExtra).toBeUndefined()
969
+ })
970
+
971
+ it('should coerce values through the input schema', async () => {
972
+ // publishedDate uses z.coerce.date(), so a string should be coerced to a Date
973
+ const detailWithStringDate = {
974
+ id: 42,
975
+ title: 'Test Book',
976
+ author: 'Author Name',
977
+ publishedDate: '2023-06-15T00:00:00.000Z',
978
+ }
979
+ const input = await service.detailsToInput(detailWithStringDate)
980
+
981
+ expect(input.publishedDate).toBeInstanceOf(Date)
982
+ expect(input.publishedDate).toEqual(new Date('2023-06-15T00:00:00.000Z'))
983
+ })
984
+
985
+ it('should include the primary key field when present in the source', async () => {
986
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
987
+ const input = await service.detailsToInput(detail)
988
+
989
+ // id is optional in the input model but should be included if present
990
+ expect(input.id).toBe(42)
991
+ })
992
+
993
+ it('should work with partial source data that satisfies required input fields', async () => {
994
+ const minimalDetail = {
995
+ title: 'Minimal Book',
996
+ author: 'Some Author',
997
+ publishedDate: new Date(),
998
+ }
999
+ const input = await service.detailsToInput(minimalDetail)
1000
+
1001
+ expect(input.title).toBe('Minimal Book')
1002
+ expect(input.author).toBe('Some Author')
1003
+ expect(input.id).toBeUndefined()
1004
+ })
1005
+
1006
+ it('should produce a valid input that can be used with create', async () => {
1007
+ const detail = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1008
+ const input = await service.detailsToInput(detail)
1009
+
1010
+ // Remove the primary key and create a new record
1011
+ delete (input as any).id
1012
+ const created = await service.create(input)
1013
+
1014
+ expect(created.title).toBe('Test Book')
1015
+ expect(created.author).toBe('Author Name')
1016
+ expect(created.id).toBeDefined()
1017
+ expect(created.id).not.toBe(42)
1018
+ })
1019
+ })
1020
+
1021
+ describe('duplicate', () => {
1022
+ it('should create a duplicate of an existing record with a new primary key', async () => {
1023
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1024
+ await repository.create(original)
1025
+
1026
+ const duplicate = await service.duplicate({ id: 42 })
1027
+
1028
+ expect(duplicate.title).toBe('Original Book')
1029
+ expect(duplicate.author).toBe('Author Name')
1030
+ expect(duplicate.publishedDate).toEqual(new Date('2023-06-15'))
1031
+ expect(duplicate.id).toBeDefined()
1032
+ expect(duplicate.id).not.toBe(42)
1033
+ })
1034
+
1035
+ it('should apply overrides to the duplicated record', async () => {
1036
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1037
+ await repository.create(original)
1038
+
1039
+ const duplicate = await service.duplicate({ id: 42 }, { title: 'Duplicated Book' })
1040
+
1041
+ expect(duplicate.title).toBe('Duplicated Book')
1042
+ expect(duplicate.author).toBe('Author Name')
1043
+ expect(duplicate.id).toBeDefined()
1044
+ expect(duplicate.id).not.toBe(42)
1045
+ })
1046
+
1047
+ it('should throw an error when trying to duplicate a non-existent record', async () => {
1048
+ await expect(service.duplicate({ id: 999 })).rejects.toThrow()
1049
+ })
1050
+
1051
+ it('should trigger beforeDuplicate and afterDuplicate events alongside create events', async () => {
1052
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date() }
1053
+ await repository.create(original)
1054
+
1055
+ await service.duplicate({ id: 42 })
1056
+
1057
+ expect(beforeDuplicateSpy).toHaveBeenCalledTimes(1)
1058
+ expect(beforeDuplicateSpy).toHaveBeenCalledWith(
1059
+ expect.objectContaining({ type: 'books::book.beforeDuplicate' }),
1060
+ )
1061
+ expect(afterDuplicateSpy).toHaveBeenCalledTimes(1)
1062
+ expect(afterDuplicateSpy).toHaveBeenCalledWith(
1063
+ expect.objectContaining({ type: 'books::book.afterDuplicate' }),
1064
+ )
1065
+ // create events also fire since a record is genuinely being created
1066
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
1067
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
1068
+ })
1069
+
1070
+ it('should not modify the original record', async () => {
1071
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1072
+ await repository.create(original)
1073
+
1074
+ await service.duplicate({ id: 42 }, { title: 'Modified Title' })
1075
+
1076
+ const loaded = await repository.load({ id: 42 })
1077
+ expect(loaded?.title).toBe('Original Book')
1078
+ expect(loaded?.author).toBe('Author Name')
1079
+ })
1080
+
1081
+ it('should allow overriding multiple fields', async () => {
1082
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1083
+ await repository.create(original)
1084
+
1085
+ const newDate = new Date('2024-01-01')
1086
+ const duplicate = await service.duplicate({ id: 42 }, {
1087
+ title: 'New Title',
1088
+ author: 'New Author',
1089
+ publishedDate: newDate,
1090
+ })
1091
+
1092
+ expect(duplicate.title).toBe('New Title')
1093
+ expect(duplicate.author).toBe('New Author')
1094
+ expect(duplicate.publishedDate).toEqual(newDate)
1095
+ expect(duplicate.id).not.toBe(42)
1096
+ })
1097
+
1098
+ it('should create independent records that can be modified separately', async () => {
1099
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date('2023-06-15') }
1100
+ await repository.create(original)
1101
+
1102
+ const duplicate = await service.duplicate({ id: 42 })
1103
+
1104
+ // Update the duplicate
1105
+ await service.update({ id: duplicate.id }, { title: 'Updated Duplicate', author: 'Updated Author', publishedDate: new Date() })
1106
+
1107
+ // Verify original is unchanged
1108
+ const loadedOriginal = await repository.load({ id: 42 })
1109
+ expect(loadedOriginal?.title).toBe('Original Book')
1110
+
1111
+ // Verify duplicate was updated
1112
+ const loadedDuplicate = await repository.load({ id: duplicate.id })
1113
+ expect(loadedDuplicate?.title).toBe('Updated Duplicate')
1114
+ })
1115
+
1116
+ it('should respect doNotDispatchEvents option', async () => {
1117
+ const original = { id: 42, title: 'Original Book', author: 'Author Name', publishedDate: new Date() }
1118
+ await repository.create(original)
1119
+
1120
+ await service.duplicate({ id: 42 }, undefined, { doNotDispatchEvents: true })
1121
+
1122
+ expect(beforeDuplicateSpy).not.toHaveBeenCalled()
1123
+ expect(afterDuplicateSpy).not.toHaveBeenCalled()
1124
+ // create events are also suppressed when doNotDispatchEvents is set
1125
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
1126
+ expect(afterCreateSpy).not.toHaveBeenCalled()
1127
+ })
1128
+ })
1129
+
1130
+ describe('doNotDispatchEvents option', () => {
1131
+ const beforeLoadSpy = mock(() => {})
1132
+ const afterLoadSpy = mock(() => {})
1133
+ const beforeLoadManySpy = mock(() => {})
1134
+ const afterLoadManySpy = mock(() => {})
1135
+
1136
+ beforeEach(() => {
1137
+ repository = new MockMemoryRepository({ schema: mockSchema })
1138
+ emitter = new EventManager()
1139
+
1140
+ beforeLoadSpy.mockClear()
1141
+ afterLoadSpy.mockClear()
1142
+ beforeLoadManySpy.mockClear()
1143
+ afterLoadManySpy.mockClear()
1144
+ beforeCreateSpy.mockClear()
1145
+ afterCreateSpy.mockClear()
1146
+ beforeUpdateSpy.mockClear()
1147
+ afterUpdateSpy.mockClear()
1148
+
1149
+ emitter.on('books::book.beforeLoad', beforeLoadSpy)
1150
+ emitter.on('books::book.afterLoad', afterLoadSpy)
1151
+ emitter.on('books::book.beforeLoadMany', beforeLoadManySpy)
1152
+ emitter.on('books::book.afterLoadMany', afterLoadManySpy)
1153
+ emitter.on('books::book.beforeCreate', beforeCreateSpy)
1154
+ emitter.on('books::book.afterCreate', afterCreateSpy)
1155
+ emitter.on('books::book.beforeUpdate', beforeUpdateSpy)
1156
+ emitter.on('books::book.afterUpdate', afterUpdateSpy)
1157
+
1158
+ service = new ModelService({ repository, emitter, schema: mockSchema, namespace })
1159
+ })
1160
+
1161
+ describe('upsert', () => {
1162
+ it('should not dispatch any events when doNotDispatchEvents is true', async () => {
1163
+ const input = {
1164
+ id: 1,
1165
+ title: 'Existing Book',
1166
+ author: 'Author',
1167
+ publishedDate: new Date(),
1168
+ }
1169
+ await repository.create(input)
1170
+
1171
+ const updatedInput = {
1172
+ id: 1,
1173
+ title: 'Updated Book',
1174
+ author: 'Updated Author',
1175
+ publishedDate: new Date(),
1176
+ }
1177
+
1178
+ await service.upsert(updatedInput, { doNotDispatchEvents: true })
1179
+
1180
+ // Load events should not be dispatched due to propagation
1181
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1182
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1183
+
1184
+ // Update events should not be dispatched either
1185
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1186
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1187
+ })
1188
+
1189
+ it('should not dispatch load events even when doNotDispatchEvents is false', async () => {
1190
+ const input = {
1191
+ id: 2,
1192
+ title: 'Existing Book',
1193
+ author: 'Author',
1194
+ publishedDate: new Date(),
1195
+ }
1196
+ await repository.create(input)
1197
+
1198
+ const updatedInput = {
1199
+ id: 2,
1200
+ title: 'Updated Book',
1201
+ author: 'Updated Author',
1202
+ publishedDate: new Date(),
1203
+ }
1204
+
1205
+ await service.upsert(updatedInput, { doNotDispatchEvents: false })
1206
+
1207
+ // Load events should NOT be dispatched (forced internally)
1208
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1209
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1210
+
1211
+ // Update events should be dispatched
1212
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
1213
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
1214
+ })
1215
+
1216
+ it('should not dispatch load events even when doNotDispatchEvents is not specified', async () => {
1217
+ const input = {
1218
+ id: 3,
1219
+ title: 'Existing Book',
1220
+ author: 'Author',
1221
+ publishedDate: new Date(),
1222
+ }
1223
+ await repository.create(input)
1224
+
1225
+ const updatedInput = {
1226
+ id: 3,
1227
+ title: 'Updated Book',
1228
+ author: 'Updated Author',
1229
+ publishedDate: new Date(),
1230
+ }
1231
+
1232
+ await service.upsert(updatedInput)
1233
+
1234
+ // Load events should NOT be dispatched (forced internally)
1235
+ expect(beforeLoadSpy).not.toHaveBeenCalled()
1236
+ expect(afterLoadSpy).not.toHaveBeenCalled()
1237
+
1238
+ // Update events should be dispatched
1239
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
1240
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
1241
+ })
1242
+ })
1243
+
1244
+ describe('bulkUpsert', () => {
1245
+ it('should not dispatch any events when doNotDispatchEvents is true', async () => {
1246
+ const input1 = {
1247
+ id: 1,
1248
+ title: 'Existing Book 1',
1249
+ author: 'Author 1',
1250
+ publishedDate: new Date(),
1251
+ }
1252
+ const input2 = {
1253
+ id: 2,
1254
+ title: 'Existing Book 2',
1255
+ author: 'Author 2',
1256
+ publishedDate: new Date(),
1257
+ }
1258
+ await repository.create(input1)
1259
+ await repository.create(input2)
1260
+
1261
+ const updatedInputs = [
1262
+ {
1263
+ id: 1,
1264
+ title: 'Updated Book 1',
1265
+ author: 'Updated Author 1',
1266
+ publishedDate: new Date(),
1267
+ },
1268
+ {
1269
+ id: 2,
1270
+ title: 'Updated Book 2',
1271
+ author: 'Updated Author 2',
1272
+ publishedDate: new Date(),
1273
+ },
1274
+ ]
1275
+
1276
+ await service.bulkUpsert(updatedInputs, { doNotDispatchEvents: true })
1277
+
1278
+ // LoadMany events should not be dispatched due to propagation
1279
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1280
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1281
+
1282
+ // Update events should not be dispatched either
1283
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1284
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1285
+ })
1286
+
1287
+ it('should not dispatch loadMany events even when doNotDispatchEvents is false', async () => {
1288
+ const input1 = {
1289
+ id: 3,
1290
+ title: 'Existing Book 3',
1291
+ author: 'Author 3',
1292
+ publishedDate: new Date(),
1293
+ }
1294
+ const input2 = {
1295
+ id: 4,
1296
+ title: 'Existing Book 4',
1297
+ author: 'Author 4',
1298
+ publishedDate: new Date(),
1299
+ }
1300
+ await repository.create(input1)
1301
+ await repository.create(input2)
1302
+
1303
+ const updatedInputs = [
1304
+ {
1305
+ id: 3,
1306
+ title: 'Updated Book 3',
1307
+ author: 'Updated Author 3',
1308
+ publishedDate: new Date(),
1309
+ },
1310
+ {
1311
+ id: 4,
1312
+ title: 'Updated Book 4',
1313
+ author: 'Updated Author 4',
1314
+ publishedDate: new Date(),
1315
+ },
1316
+ ]
1317
+
1318
+ await service.bulkUpsert(updatedInputs, { doNotDispatchEvents: false })
1319
+
1320
+ // LoadMany events should NOT be dispatched (forced internally)
1321
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1322
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1323
+
1324
+ // Update events should be dispatched (2 updates)
1325
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
1326
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
1327
+ })
1328
+
1329
+ it('should not dispatch loadMany events even when doNotDispatchEvents is not specified', async () => {
1330
+ const input1 = {
1331
+ id: 5,
1332
+ title: 'Existing Book 5',
1333
+ author: 'Author 5',
1334
+ publishedDate: new Date(),
1335
+ }
1336
+ const input2 = {
1337
+ id: 6,
1338
+ title: 'Existing Book 6',
1339
+ author: 'Author 6',
1340
+ publishedDate: new Date(),
1341
+ }
1342
+ await repository.create(input1)
1343
+ await repository.create(input2)
1344
+
1345
+ const updatedInputs = [
1346
+ {
1347
+ id: 5,
1348
+ title: 'Updated Book 5',
1349
+ author: 'Updated Author 5',
1350
+ publishedDate: new Date(),
1351
+ },
1352
+ {
1353
+ id: 6,
1354
+ title: 'Updated Book 6',
1355
+ author: 'Updated Author 6',
1356
+ publishedDate: new Date(),
1357
+ },
1358
+ ]
1359
+
1360
+ await service.bulkUpsert(updatedInputs)
1361
+
1362
+ // LoadMany events should NOT be dispatched (forced internally)
1363
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1364
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1365
+
1366
+ // Update events should be dispatched (2 updates)
1367
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
1368
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
1369
+ })
1370
+
1371
+ it('should not dispatch any events with doNotDispatchEvents for mixed create and update operations', async () => {
1372
+ const existingInput = {
1373
+ id: 7,
1374
+ title: 'Existing Book',
1375
+ author: 'Author',
1376
+ publishedDate: new Date(),
1377
+ }
1378
+ await repository.create(existingInput)
1379
+
1380
+ const inputs = [
1381
+ {
1382
+ id: 7,
1383
+ title: 'Updated Book',
1384
+ author: 'Updated Author',
1385
+ publishedDate: new Date(),
1386
+ },
1387
+ {
1388
+ id: 8,
1389
+ title: 'New Book',
1390
+ author: 'New Author',
1391
+ publishedDate: new Date(),
1392
+ },
1393
+ ]
1394
+
1395
+ await service.bulkUpsert(inputs, { doNotDispatchEvents: true })
1396
+
1397
+ // LoadMany events should not be dispatched
1398
+ expect(beforeLoadManySpy).not.toHaveBeenCalled()
1399
+ expect(afterLoadManySpy).not.toHaveBeenCalled()
1400
+
1401
+ // Neither create nor update events should be dispatched
1402
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
1403
+ expect(afterCreateSpy).not.toHaveBeenCalled()
1404
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
1405
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
1406
+ })
1407
+ })
1408
+ })
1409
+
1410
+ describe('event meta (existing and args)', () => {
1411
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date('2024-01-01') }
1412
+
1413
+ describe('create events', () => {
1414
+ it('beforeCreate event includes args with input and options', async () => {
1415
+ let capturedEvent: any
1416
+
1417
+ emitter.on('books::book.beforeCreate', (event) => {
1418
+ capturedEvent = event
1419
+ })
1420
+
1421
+ const options = { doNotDispatchEvents: false }
1422
+ await service.create(input, options)
1423
+
1424
+ expect(capturedEvent.meta.args.input).toEqual(input)
1425
+ expect(capturedEvent.meta.args.options).toEqual(options)
1426
+ })
1427
+
1428
+ it('afterCreate event includes args and result', async () => {
1429
+ let capturedEvent: any
1430
+
1431
+ emitter.on('books::book.afterCreate', (event) => {
1432
+ capturedEvent = event
1433
+ })
1434
+
1435
+ await service.create(input)
1436
+
1437
+ expect(capturedEvent.meta.args.input).toEqual(input)
1438
+ expect(capturedEvent.data).toBeDefined()
1439
+ expect(capturedEvent.data.id).toBe(42)
1440
+ })
1441
+ })
1442
+
1443
+ describe('update events', () => {
1444
+ beforeEach(async () => {
1445
+ await repository.create(input)
1446
+ })
1447
+
1448
+ it('beforeUpdate event includes existing record and args', async () => {
1449
+ let capturedEvent: any
1450
+
1451
+ emitter.on('books::book.beforeUpdate', (event) => {
1452
+ capturedEvent = event
1453
+ })
1454
+
1455
+ const updateInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
1456
+ await service.update({ id: 42 }, updateInput)
1457
+
1458
+ expect(capturedEvent.meta.existing).toBeDefined()
1459
+ expect(capturedEvent.meta.existing.id).toBe(42)
1460
+ expect(capturedEvent.meta.existing.title).toBe('Test Book')
1461
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1462
+ expect(capturedEvent.meta.args.input).toEqual(updateInput)
1463
+ })
1464
+
1465
+ it('afterUpdate event includes existing record, args, and result', async () => {
1466
+ let capturedEvent: any
1467
+
1468
+ emitter.on('books::book.afterUpdate', (event) => {
1469
+ capturedEvent = event
1470
+ })
1471
+
1472
+ const updateInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
1473
+ await service.update({ id: 42 }, updateInput)
1474
+
1475
+ expect(capturedEvent.meta.existing).toBeDefined()
1476
+ expect(capturedEvent.meta.existing.id).toBe(42)
1477
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1478
+ expect(capturedEvent.data).toBeDefined()
1479
+ expect(capturedEvent.data.title).toBe('Updated Book')
1480
+ })
1481
+ })
1482
+
1483
+ describe('remove events', () => {
1484
+ beforeEach(async () => {
1485
+ await repository.create(input)
1486
+ })
1487
+
1488
+ it('beforeRemove event includes args with lookup and options', async () => {
1489
+ let capturedEvent: any
1490
+
1491
+ emitter.on('books::book.beforeRemove', (event) => {
1492
+ capturedEvent = event
1493
+ })
1494
+
1495
+ await service.remove({ id: 42 })
1496
+
1497
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1498
+ })
1499
+
1500
+ it('afterRemove event includes args and result', async () => {
1501
+ let capturedEvent: any
1502
+
1503
+ emitter.on('books::book.afterRemove', (event) => {
1504
+ capturedEvent = event
1505
+ })
1506
+
1507
+ await service.remove({ id: 42 })
1508
+
1509
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1510
+ expect(capturedEvent.data).toBeDefined()
1511
+ expect(capturedEvent.data.id).toBe(42)
1512
+ })
1513
+ })
1514
+
1515
+ describe('restore events', () => {
1516
+ beforeEach(async () => {
1517
+ await repository.create(input)
1518
+ await repository.remove({ id: 42 })
1519
+ })
1520
+
1521
+ it('beforeRestore event includes args with lookup', async () => {
1522
+ let capturedEvent: any
1523
+
1524
+ emitter.on('books::book.beforeRestore', (event) => {
1525
+ capturedEvent = event
1526
+ })
1527
+
1528
+ await service.restore({ id: 42 })
1529
+
1530
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1531
+ })
1532
+
1533
+ it('afterRestore event includes args and result', async () => {
1534
+ let capturedEvent: any
1535
+
1536
+ emitter.on('books::book.afterRestore', (event) => {
1537
+ capturedEvent = event
1538
+ })
1539
+
1540
+ await service.restore({ id: 42 })
1541
+
1542
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1543
+ expect(capturedEvent.data).toBeDefined()
1544
+ expect(capturedEvent.data.id).toBe(42)
1545
+ })
1546
+ })
1547
+
1548
+ describe('duplicate events', () => {
1549
+ beforeEach(async () => {
1550
+ await repository.create(input)
1551
+ })
1552
+
1553
+ it('beforeDuplicate event includes existing record and args', async () => {
1554
+ let capturedEvent: any
1555
+
1556
+ emitter.on('books::book.beforeDuplicate', (event) => {
1557
+ capturedEvent = event
1558
+ })
1559
+
1560
+ const overrides = { title: 'Duplicate Title' }
1561
+ await service.duplicate({ id: 42 }, overrides)
1562
+
1563
+ expect(capturedEvent.meta.existing).toBeDefined()
1564
+ expect(capturedEvent.meta.existing.id).toBe(42)
1565
+ expect(capturedEvent.meta.existing.title).toBe('Test Book')
1566
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1567
+ expect(capturedEvent.meta.args.overrides).toEqual(overrides)
1568
+ })
1569
+
1570
+ it('afterDuplicate event includes existing, args, and the new record as result', async () => {
1571
+ let capturedEvent: any
1572
+
1573
+ emitter.on('books::book.afterDuplicate', (event) => {
1574
+ capturedEvent = event
1575
+ })
1576
+
1577
+ const overrides = { title: 'Duplicate Title' }
1578
+ await service.duplicate({ id: 42 }, overrides)
1579
+
1580
+ expect(capturedEvent.meta.existing).toBeDefined()
1581
+ expect(capturedEvent.meta.existing.id).toBe(42)
1582
+ expect(capturedEvent.meta.args.lookup).toEqual({ id: 42 })
1583
+ expect(capturedEvent.meta.args.overrides).toEqual(overrides)
1584
+ expect(capturedEvent.data).toBeDefined()
1585
+ expect(capturedEvent.data.title).toBe('Duplicate Title')
1586
+ expect(capturedEvent.data.id).not.toBe(42)
1587
+ })
1588
+
1589
+ it('beforeDuplicate input is the finalInput (with overrides applied, without primary key)', async () => {
1590
+ let capturedEvent: any
1591
+
1592
+ emitter.on('books::book.beforeDuplicate', (event) => {
1593
+ capturedEvent = event
1594
+ })
1595
+
1596
+ await service.duplicate({ id: 42 }, { title: 'Override Title' })
1597
+
1598
+ expect(capturedEvent.input.title).toBe('Override Title')
1599
+ expect(capturedEvent.input.id).toBeUndefined()
1600
+ })
1601
+
1602
+ it('duplicate without overrides has undefined overrides in args', async () => {
1603
+ let capturedEvent: any
1604
+
1605
+ emitter.on('books::book.beforeDuplicate', (event) => {
1606
+ capturedEvent = event
1607
+ })
1608
+
1609
+ await service.duplicate({ id: 42 })
1610
+
1611
+ expect(capturedEvent.meta.args.overrides).toBeUndefined()
1612
+ expect(capturedEvent.meta.existing.title).toBe('Test Book')
1613
+ })
1614
+ })
1615
+ })
1616
+ })