@declaro/data 2.0.0-beta.12 → 2.0.0-beta.121

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 (130) 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 +13226 -0
  6. package/dist/node/index.cjs.map +93 -0
  7. package/dist/node/index.js +13205 -0
  8. package/dist/node/index.js.map +93 -0
  9. package/dist/ts/application/model-controller.d.ts +50 -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 +20 -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 +34 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +6 -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 +6 -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 +11 -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 +72 -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 +76 -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/test/mock/models/mock-book-models.d.ts +42 -0
  60. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  61. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts +2 -0
  62. package/dist/ts/test/mock/repositories/mock-memory-repository.assign.test.d.ts.map +1 -0
  63. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts +2 -0
  64. package/dist/ts/test/mock/repositories/mock-memory-repository.basic.test.d.ts.map +1 -0
  65. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts +2 -0
  66. package/dist/ts/test/mock/repositories/mock-memory-repository.bulk-upsert.test.d.ts.map +1 -0
  67. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts +2 -0
  68. package/dist/ts/test/mock/repositories/mock-memory-repository.count.test.d.ts.map +1 -0
  69. package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts +1 -0
  70. package/dist/ts/test/mock/repositories/mock-memory-repository.custom-lookup.test.d.ts.map +1 -0
  71. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +62 -0
  72. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  73. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts +2 -0
  74. package/dist/ts/test/mock/repositories/mock-memory-repository.search.test.d.ts.map +1 -0
  75. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts +2 -0
  76. package/dist/ts/test/mock/repositories/mock-memory-repository.trash.test.d.ts.map +1 -0
  77. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts +2 -0
  78. package/dist/ts/test/mock/repositories/mock-memory-repository.upsert.test.d.ts.map +1 -0
  79. package/package.json +45 -42
  80. package/src/application/model-controller.test.ts +694 -0
  81. package/src/application/model-controller.ts +135 -0
  82. package/src/application/read-only-model-controller.test.ts +335 -0
  83. package/src/application/read-only-model-controller.ts +61 -0
  84. package/src/domain/events/domain-event.test.ts +82 -0
  85. package/src/domain/events/domain-event.ts +69 -0
  86. package/src/domain/events/event-types.ts +34 -0
  87. package/src/domain/events/mutation-event.test.ts +38 -0
  88. package/src/domain/events/mutation-event.ts +8 -0
  89. package/src/domain/events/query-event.test.ts +28 -0
  90. package/src/domain/events/query-event.ts +8 -0
  91. package/src/domain/events/request-event.test.ts +38 -0
  92. package/src/domain/events/request-event.ts +32 -0
  93. package/src/domain/interfaces/repository.ts +136 -0
  94. package/src/domain/models/pagination.ts +28 -0
  95. package/src/domain/services/base-model-service.ts +54 -0
  96. package/src/domain/services/model-service-args.ts +9 -0
  97. package/src/domain/services/model-service.normalization.test.ts +704 -0
  98. package/src/domain/services/model-service.test.ts +940 -0
  99. package/src/domain/services/model-service.ts +432 -0
  100. package/src/domain/services/read-only-model-service.test.ts +828 -0
  101. package/src/domain/services/read-only-model-service.ts +178 -0
  102. package/src/index.ts +17 -4
  103. package/src/shared/utils/schema-inference.ts +26 -0
  104. package/src/shared/utils/schema-inheritance.ts +28 -0
  105. package/src/test/mock/models/mock-book-models.ts +78 -0
  106. package/src/test/mock/repositories/mock-memory-repository.assign.test.ts +215 -0
  107. package/src/test/mock/repositories/mock-memory-repository.basic.test.ts +129 -0
  108. package/src/test/mock/repositories/mock-memory-repository.bulk-upsert.test.ts +159 -0
  109. package/src/test/mock/repositories/mock-memory-repository.count.test.ts +98 -0
  110. package/src/test/mock/repositories/mock-memory-repository.custom-lookup.test.ts +0 -0
  111. package/src/test/mock/repositories/mock-memory-repository.search.test.ts +265 -0
  112. package/src/test/mock/repositories/mock-memory-repository.trash.test.ts +736 -0
  113. package/src/test/mock/repositories/mock-memory-repository.ts +401 -0
  114. package/src/test/mock/repositories/mock-memory-repository.upsert.test.ts +108 -0
  115. package/dist/databaseConnection.d.ts +0 -24
  116. package/dist/datastoreAbstract.d.ts +0 -37
  117. package/dist/declaro-data.cjs +0 -1
  118. package/dist/declaro-data.mjs +0 -250
  119. package/dist/hydrateEntity.d.ts +0 -8
  120. package/dist/index.d.ts +0 -4
  121. package/dist/serverConnection.d.ts +0 -15
  122. package/dist/trackedStatus.d.ts +0 -15
  123. package/src/databaseConnection.ts +0 -137
  124. package/src/datastoreAbstract.ts +0 -190
  125. package/src/hydrateEntity.ts +0 -36
  126. package/src/placeholder.test.ts +0 -7
  127. package/src/serverConnection.ts +0 -74
  128. package/src/trackedStatus.ts +0 -35
  129. package/tsconfig.json +0 -10
  130. package/vite.config.ts +0 -23
@@ -0,0 +1,940 @@
1
+ import { describe, it, expect, beforeEach } from 'bun:test'
2
+ import { ModelService } from './model-service'
3
+ import { MockMemoryRepository } from '../../test/mock/repositories/mock-memory-repository'
4
+ import { MockBookSchema, type MockBookInput } from '../../test/mock/models/mock-book-models'
5
+ import { EventManager } from '@declaro/core'
6
+ import { mock } from 'bun:test'
7
+ import type { InferDetail } from '../../shared/utils/schema-inference'
8
+ import { ModelMutationAction } from '../events/event-types'
9
+
10
+ describe('ModelService', () => {
11
+ const namespace = 'books'
12
+ const mockSchema = MockBookSchema
13
+
14
+ let repository: MockMemoryRepository<typeof mockSchema>
15
+ let emitter: EventManager
16
+ let service: ModelService<typeof mockSchema>
17
+
18
+ beforeEach(() => {
19
+ repository = new MockMemoryRepository({ schema: mockSchema })
20
+ emitter = new EventManager()
21
+ service = new ModelService({ repository, emitter, schema: mockSchema, namespace })
22
+ })
23
+
24
+ const beforeCreateSpy = mock((event) => {})
25
+ const afterCreateSpy = mock((event) => {})
26
+ const beforeUpdateSpy = mock((event) => {})
27
+ const afterUpdateSpy = mock((event) => {})
28
+ const beforeRemoveSpy = mock((event) => {})
29
+ const afterRemoveSpy = mock((event) => {})
30
+ const beforeRestoreSpy = mock((event) => {})
31
+ const afterRestoreSpy = mock((event) => {})
32
+
33
+ beforeEach(() => {
34
+ emitter.on('books::book.beforeCreate', beforeCreateSpy)
35
+ emitter.on('books::book.afterCreate', afterCreateSpy)
36
+ emitter.on('books::book.beforeUpdate', beforeUpdateSpy)
37
+ emitter.on('books::book.afterUpdate', afterUpdateSpy)
38
+ emitter.on('books::book.beforeRemove', beforeRemoveSpy)
39
+ emitter.on('books::book.afterRemove', afterRemoveSpy)
40
+ emitter.on('books::book.beforeRestore', beforeRestoreSpy)
41
+ emitter.on('books::book.afterRestore', afterRestoreSpy)
42
+
43
+ beforeCreateSpy.mockClear()
44
+ afterCreateSpy.mockClear()
45
+ beforeUpdateSpy.mockClear()
46
+ afterUpdateSpy.mockClear()
47
+ beforeRemoveSpy.mockClear()
48
+ afterRemoveSpy.mockClear()
49
+ beforeRestoreSpy.mockClear()
50
+ afterRestoreSpy.mockClear()
51
+ })
52
+
53
+ it('should create a record', async () => {
54
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
55
+ const createdRecord = await service.create(input)
56
+
57
+ expect(createdRecord).toEqual(input)
58
+ })
59
+
60
+ it('should update a record', async () => {
61
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
62
+ await repository.create(input)
63
+
64
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
65
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
66
+
67
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
68
+ })
69
+
70
+ it('should remove a record', async () => {
71
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
72
+ await repository.create(input)
73
+
74
+ const removedRecord = await service.remove({ id: 42 })
75
+
76
+ expect(removedRecord).toEqual(input)
77
+ expect(await repository.load({ id: 42 })).toBeNull()
78
+ })
79
+
80
+ it('should restore a record', async () => {
81
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
82
+ await repository.create(input)
83
+ await repository.remove({ id: 42 })
84
+
85
+ const restoredRecord = await service.restore({ id: 42 })
86
+
87
+ expect(restoredRecord).toEqual(input)
88
+ expect(await repository.load({ id: 42 })).toEqual(input)
89
+ })
90
+
91
+ it('should trigger before and after events for create', async () => {
92
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
93
+ const createdRecord = await service.create(input)
94
+
95
+ expect(createdRecord).toEqual(input)
96
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
97
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
98
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
99
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
100
+ })
101
+
102
+ it('should trigger before and after events for update', async () => {
103
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
104
+ await repository.create(input)
105
+
106
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
107
+ const updatedRecord = await service.update({ id: 42 }, updatedInput)
108
+
109
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
110
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
111
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
112
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
113
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
114
+ })
115
+
116
+ it('should trigger before and after events for remove', async () => {
117
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
118
+ await repository.create(input)
119
+
120
+ const removedRecord = await service.remove({ id: 42 })
121
+
122
+ expect(removedRecord).toEqual(input)
123
+ expect(beforeRemoveSpy).toHaveBeenCalledTimes(1)
124
+ expect(beforeRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRemove' }))
125
+ expect(afterRemoveSpy).toHaveBeenCalledTimes(1)
126
+ expect(afterRemoveSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRemove' }))
127
+ })
128
+
129
+ it('should trigger before and after events for restore', async () => {
130
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
131
+ await repository.create(input)
132
+ await repository.remove({ id: 42 })
133
+
134
+ const restoredRecord = await service.restore({ id: 42 })
135
+
136
+ expect(restoredRecord).toEqual(input)
137
+ expect(beforeRestoreSpy).toHaveBeenCalledTimes(1)
138
+ expect(beforeRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeRestore' }))
139
+ expect(afterRestoreSpy).toHaveBeenCalledTimes(1)
140
+ expect(afterRestoreSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterRestore' }))
141
+ })
142
+
143
+ it('should throw an error when attempting to remove a non-existent record', async () => {
144
+ await expect(service.remove({ id: 999 })).rejects.toThrow('Item not found')
145
+ })
146
+
147
+ it('should throw an error when attempting to update a non-existent record', async () => {
148
+ const updatedInput = { title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
149
+ await expect(service.update({ id: 999 }, updatedInput)).rejects.toThrow('Item not found')
150
+ })
151
+
152
+ describe('upsert functionality', () => {
153
+ it('should create a new record when no existing record with primary key exists', async () => {
154
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
155
+
156
+ const upsertedRecord = await service.upsert(input)
157
+
158
+ expect(upsertedRecord).toEqual(input)
159
+ expect(await repository.load({ id: 42 })).toEqual(input)
160
+ })
161
+
162
+ it('should update an existing record when primary key matches', async () => {
163
+ // Create initial record
164
+ const initial = {
165
+ id: 42,
166
+ title: 'Original Book',
167
+ author: 'Original Author',
168
+ publishedDate: new Date('2023-01-01'),
169
+ }
170
+ await repository.create(initial)
171
+
172
+ // Upsert with same ID but different data
173
+ const update = {
174
+ id: 42,
175
+ title: 'Updated Book',
176
+ author: 'Updated Author',
177
+ publishedDate: new Date('2023-12-01'),
178
+ }
179
+ const upsertedRecord = await service.upsert(update)
180
+
181
+ expect(upsertedRecord).toEqual(update)
182
+ expect(await repository.load({ id: 42 })).toEqual(update)
183
+ })
184
+
185
+ it('should create a new record when upserting without primary key', async () => {
186
+ const input = { title: 'Book Without ID', author: 'Author Name', publishedDate: new Date() }
187
+
188
+ const upsertedRecord = await service.upsert(input)
189
+
190
+ expect(upsertedRecord.id).toBeDefined()
191
+ expect(upsertedRecord.title).toBe(input.title)
192
+ expect(upsertedRecord.author).toBe(input.author)
193
+ expect(await repository.load({ id: upsertedRecord.id })).toEqual(upsertedRecord)
194
+ })
195
+
196
+ it('should create a new record when primary key is null', async () => {
197
+ const input = {
198
+ id: undefined,
199
+ title: 'Book With Undefined ID',
200
+ author: 'Author Name',
201
+ publishedDate: new Date(),
202
+ }
203
+
204
+ const upsertedRecord = await service.upsert(input)
205
+
206
+ expect(upsertedRecord.id).toBeDefined()
207
+ expect(upsertedRecord.id).not.toBeNull()
208
+ expect(upsertedRecord.title).toBe(input.title)
209
+ expect(upsertedRecord.author).toBe(input.author)
210
+ })
211
+
212
+ it('should trigger create events when upserting a new record', async () => {
213
+ const input = { id: 42, title: 'New Book', author: 'Author Name', publishedDate: new Date() }
214
+
215
+ const upsertedRecord = await service.upsert(input)
216
+
217
+ expect(upsertedRecord).toEqual(input)
218
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
219
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
220
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
221
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
222
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
223
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
224
+ })
225
+
226
+ it('should trigger update events when upserting an existing record', async () => {
227
+ // Create initial record
228
+ const initial = { id: 42, title: 'Original Book', author: 'Original Author', publishedDate: new Date() }
229
+ await repository.create(initial)
230
+
231
+ // Clear create spies since they were called during setup
232
+ beforeCreateSpy.mockClear()
233
+ afterCreateSpy.mockClear()
234
+
235
+ // Upsert existing record
236
+ const update = { id: 42, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
237
+ const upsertedRecord = await service.upsert(update)
238
+
239
+ expect(upsertedRecord).toEqual(update)
240
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
241
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
242
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
243
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
244
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
245
+ expect(afterCreateSpy).not.toHaveBeenCalled()
246
+ })
247
+
248
+ it('should trigger create events when upserting with non-existent primary key', async () => {
249
+ // Try to upsert with an ID that doesn't exist
250
+ const input = { id: 999, title: 'Non-existent Book', author: 'Author Name', publishedDate: new Date() }
251
+
252
+ const upsertedRecord = await service.upsert(input)
253
+
254
+ expect(upsertedRecord).toEqual(input)
255
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
256
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
257
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
258
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
259
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
260
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
261
+ })
262
+
263
+ it('should handle multiple upserts correctly', async () => {
264
+ // First upsert (create)
265
+ const input1 = { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() }
266
+ const result1 = await service.upsert(input1)
267
+ expect(result1).toEqual(input1)
268
+ expect(await repository.load({ id: 1 })).toEqual(input1)
269
+
270
+ // Second upsert (update)
271
+ const input2 = { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() }
272
+ const result2 = await service.upsert(input2)
273
+ expect(result2).toEqual(input2)
274
+ expect(await repository.load({ id: 1 })).toEqual(input2)
275
+
276
+ // Third upsert (create new)
277
+ const input3 = { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() }
278
+ const result3 = await service.upsert(input3)
279
+ expect(result3).toEqual(input3)
280
+ expect(await repository.load({ id: 2 })).toEqual(input3)
281
+ })
282
+
283
+ it('should work with auto-generated IDs', async () => {
284
+ const input1 = { title: 'Auto ID Book 1', author: 'Author 1', publishedDate: new Date() }
285
+ const input2 = { title: 'Auto ID Book 2', author: 'Author 2', publishedDate: new Date() }
286
+
287
+ const result1 = await service.upsert(input1)
288
+ const result2 = await service.upsert(input2)
289
+
290
+ expect(result1.id).toBeDefined()
291
+ expect(result2.id).toBeDefined()
292
+ expect(result1.id).not.toBe(result2.id)
293
+ expect(result1.title).toBe(input1.title)
294
+ expect(result2.title).toBe(input2.title)
295
+ })
296
+ })
297
+
298
+ describe('bulkUpsert functionality', () => {
299
+ it('should handle empty input array', async () => {
300
+ const results = await service.bulkUpsert([])
301
+ expect(results).toEqual([])
302
+ })
303
+
304
+ it('should create multiple new records with explicit IDs', async () => {
305
+ const inputs = [
306
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
307
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
308
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
309
+ ]
310
+
311
+ const results = await service.bulkUpsert(inputs)
312
+
313
+ expect(results).toEqual(inputs)
314
+ expect(results).toHaveLength(3)
315
+
316
+ // Verify all records were created
317
+ for (const input of inputs) {
318
+ const loaded = await repository.load({ id: input.id })
319
+ expect(loaded).toEqual(input)
320
+ }
321
+ })
322
+
323
+ it('should update multiple existing records', async () => {
324
+ // Create initial records
325
+ const initialRecords = [
326
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date('2023-01-01') },
327
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date('2023-02-01') },
328
+ { id: 3, title: 'Original Book 3', author: 'Original Author 3', publishedDate: new Date('2023-03-01') },
329
+ ]
330
+
331
+ for (const record of initialRecords) {
332
+ await repository.create(record)
333
+ }
334
+
335
+ // Clear spies from creation
336
+ beforeCreateSpy.mockClear()
337
+ afterCreateSpy.mockClear()
338
+
339
+ // Update all records
340
+ const updateInputs = [
341
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') },
342
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date('2023-07-01') },
343
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') },
344
+ ]
345
+
346
+ const results = await service.bulkUpsert(updateInputs)
347
+
348
+ expect(results).toEqual(updateInputs)
349
+ expect(results).toHaveLength(3)
350
+
351
+ // Verify all records were updated
352
+ for (const input of updateInputs) {
353
+ const loaded = await repository.load({ id: input.id })
354
+ expect(loaded).toEqual(input)
355
+ }
356
+ })
357
+
358
+ it('should handle mixed create and update operations', async () => {
359
+ // Create some initial records
360
+ const initialRecords = [
361
+ { id: 1, title: 'Existing Book 1', author: 'Existing Author 1', publishedDate: new Date('2023-01-01') },
362
+ { id: 3, title: 'Existing Book 3', author: 'Existing Author 3', publishedDate: new Date('2023-03-01') },
363
+ ]
364
+
365
+ for (const record of initialRecords) {
366
+ await repository.create(record)
367
+ }
368
+
369
+ // Clear spies from creation
370
+ beforeCreateSpy.mockClear()
371
+ afterCreateSpy.mockClear()
372
+
373
+ // Mix of updates and creates
374
+ const mixedInputs = [
375
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date('2023-06-01') }, // Update
376
+ { id: 2, title: 'New Book 2', author: 'New Author 2', publishedDate: new Date('2023-07-01') }, // Create
377
+ { id: 3, title: 'Updated Book 3', author: 'Updated Author 3', publishedDate: new Date('2023-08-01') }, // Update
378
+ { id: 4, title: 'New Book 4', author: 'New Author 4', publishedDate: new Date('2023-09-01') }, // Create
379
+ ]
380
+
381
+ const results = await service.bulkUpsert(mixedInputs)
382
+
383
+ expect(results).toEqual(mixedInputs)
384
+ expect(results).toHaveLength(4)
385
+
386
+ // Verify all records exist with correct data
387
+ for (const input of mixedInputs) {
388
+ const loaded = await repository.load({ id: input.id })
389
+ expect(loaded).toEqual(input)
390
+ }
391
+ })
392
+
393
+ it('should handle records without primary keys (auto-generated IDs)', async () => {
394
+ const inputsWithoutIds = [
395
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-01-01') },
396
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-02-01') },
397
+ ]
398
+
399
+ const results = await service.bulkUpsert(inputsWithoutIds)
400
+
401
+ expect(results).toHaveLength(2)
402
+ expect(results[0].id).toBeDefined()
403
+ expect(results[1].id).toBeDefined()
404
+ expect(results[0].id).not.toBe(results[1].id)
405
+ expect(results[0].title).toBe(inputsWithoutIds[0].title)
406
+ expect(results[1].title).toBe(inputsWithoutIds[1].title)
407
+
408
+ // Verify records were created
409
+ for (const result of results) {
410
+ const loaded = await repository.load({ id: result.id })
411
+ expect(loaded).toEqual(result)
412
+ }
413
+ })
414
+
415
+ it('should handle mixed records with and without primary keys', async () => {
416
+ // Create an existing record with a higher ID to avoid conflicts with auto-generated IDs
417
+ const existingRecord = {
418
+ id: 100,
419
+ title: 'Existing Book',
420
+ author: 'Existing Author',
421
+ publishedDate: new Date('2023-01-01'),
422
+ }
423
+ await repository.create(existingRecord)
424
+
425
+ // Clear spies from creation
426
+ beforeCreateSpy.mockClear()
427
+ afterCreateSpy.mockClear()
428
+
429
+ const mixedInputs = [
430
+ {
431
+ id: 100,
432
+ title: 'Updated Existing Book',
433
+ author: 'Updated Author',
434
+ publishedDate: new Date('2023-06-01'),
435
+ }, // Update
436
+ { id: 200, title: 'New Book with ID', author: 'New Author', publishedDate: new Date('2023-07-01') }, // Create with ID
437
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date('2023-08-01') }, // Create without ID
438
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date('2023-09-01') }, // Create without ID
439
+ ]
440
+
441
+ const results = await service.bulkUpsert(mixedInputs)
442
+
443
+ expect(results).toHaveLength(4)
444
+
445
+ // Results should be in the same order as inputs (repository preserves order)
446
+ expect(results[0].id).toBe(100)
447
+ expect(results[0].title).toBe('Updated Existing Book')
448
+
449
+ expect(results[1].id).toBe(200)
450
+ expect(results[1].title).toBe('New Book with ID')
451
+
452
+ expect(results[2].id).toBeDefined()
453
+ expect(results[2].title).toBe('Auto Book 1')
454
+
455
+ expect(results[3].id).toBeDefined()
456
+ expect(results[3].title).toBe('Auto Book 2')
457
+
458
+ // Verify all records exist in repository
459
+ const loadedRecord1 = await repository.load({ id: 100 })
460
+ expect(loadedRecord1?.title).toBe('Updated Existing Book')
461
+
462
+ const loadedRecord2 = await repository.load({ id: 200 })
463
+ expect(loadedRecord2?.title).toBe('New Book with ID')
464
+
465
+ const loadedRecord3 = await repository.load({ id: results[2].id })
466
+ expect(loadedRecord3?.title).toBe('Auto Book 1')
467
+
468
+ const loadedRecord4 = await repository.load({ id: results[3].id })
469
+ expect(loadedRecord4?.title).toBe('Auto Book 2')
470
+ })
471
+
472
+ it('should trigger correct before and after events for bulk create operations', async () => {
473
+ const inputs = [
474
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
475
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
476
+ ]
477
+
478
+ await service.bulkUpsert(inputs)
479
+
480
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
481
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
482
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
483
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
484
+
485
+ // Verify event details
486
+ expect(beforeCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeCreate' }))
487
+ expect(afterCreateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterCreate' }))
488
+ })
489
+
490
+ it('should trigger correct before and after events for bulk update operations', async () => {
491
+ // Create initial records
492
+ const initialRecords = [
493
+ { id: 1, title: 'Original Book 1', author: 'Original Author 1', publishedDate: new Date() },
494
+ { id: 2, title: 'Original Book 2', author: 'Original Author 2', publishedDate: new Date() },
495
+ ]
496
+
497
+ for (const record of initialRecords) {
498
+ await repository.create(record)
499
+ }
500
+
501
+ // Clear spies from creation
502
+ beforeCreateSpy.mockClear()
503
+ afterCreateSpy.mockClear()
504
+
505
+ // Update records
506
+ const updateInputs = [
507
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() },
508
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date() },
509
+ ]
510
+
511
+ await service.bulkUpsert(updateInputs)
512
+
513
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(2)
514
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(2)
515
+ expect(beforeCreateSpy).not.toHaveBeenCalled()
516
+ expect(afterCreateSpy).not.toHaveBeenCalled()
517
+
518
+ // Verify event details
519
+ expect(beforeUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.beforeUpdate' }))
520
+ expect(afterUpdateSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'books::book.afterUpdate' }))
521
+ })
522
+
523
+ it('should trigger correct before and after events for mixed operations', async () => {
524
+ // Create one existing record
525
+ const existingRecord = {
526
+ id: 1,
527
+ title: 'Existing Book',
528
+ author: 'Existing Author',
529
+ publishedDate: new Date(),
530
+ }
531
+ await repository.create(existingRecord)
532
+
533
+ // Clear spies from creation
534
+ beforeCreateSpy.mockClear()
535
+ afterCreateSpy.mockClear()
536
+
537
+ // Mix of update and create
538
+ const mixedInputs = [
539
+ { id: 1, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }, // Update
540
+ { id: 2, title: 'New Book', author: 'New Author', publishedDate: new Date() }, // Create
541
+ ]
542
+
543
+ await service.bulkUpsert(mixedInputs)
544
+
545
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(1)
546
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1)
547
+ expect(beforeUpdateSpy).toHaveBeenCalledTimes(1)
548
+ expect(afterUpdateSpy).toHaveBeenCalledTimes(1)
549
+ })
550
+
551
+ it('should trigger events for records without primary keys', async () => {
552
+ const inputsWithoutIds = [
553
+ { title: 'Auto Book 1', author: 'Auto Author 1', publishedDate: new Date() },
554
+ { title: 'Auto Book 2', author: 'Auto Author 2', publishedDate: new Date() },
555
+ ]
556
+
557
+ await service.bulkUpsert(inputsWithoutIds)
558
+
559
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(2)
560
+ expect(afterCreateSpy).toHaveBeenCalledTimes(2)
561
+ expect(beforeUpdateSpy).not.toHaveBeenCalled()
562
+ expect(afterUpdateSpy).not.toHaveBeenCalled()
563
+ })
564
+
565
+ it('should handle large batches efficiently', async () => {
566
+ const batchSize = 100
567
+ const inputs = Array.from({ length: batchSize }, (_, index) => ({
568
+ id: index + 1,
569
+ title: `Book ${index + 1}`,
570
+ author: `Author ${index + 1}`,
571
+ publishedDate: new Date(`2023-${String((index % 12) + 1).padStart(2, '0')}-01`),
572
+ }))
573
+
574
+ const startTime = Date.now()
575
+ const results = await service.bulkUpsert(inputs)
576
+ const endTime = Date.now()
577
+
578
+ expect(results).toHaveLength(batchSize)
579
+ expect(results).toEqual(inputs)
580
+
581
+ // Verify events were triggered for all items
582
+ expect(beforeCreateSpy).toHaveBeenCalledTimes(batchSize)
583
+ expect(afterCreateSpy).toHaveBeenCalledTimes(batchSize)
584
+
585
+ // Basic performance check - should complete in reasonable time
586
+ expect(endTime - startTime).toBeLessThan(5000) // Less than 5 seconds
587
+
588
+ // Verify a few random records
589
+ const randomIndexes = [0, Math.floor(batchSize / 2), batchSize - 1]
590
+ for (const index of randomIndexes) {
591
+ const loaded = await repository.load({ id: inputs[index].id })
592
+ expect(loaded).toEqual(inputs[index])
593
+ }
594
+ })
595
+
596
+ it('should maintain data integrity when bulk upserting duplicate primary keys in input', async () => {
597
+ // Input with duplicate IDs - last one should win
598
+ const inputsWithDuplicates = [
599
+ { id: 1, title: 'First Book 1', author: 'First Author 1', publishedDate: new Date('2023-01-01') },
600
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
601
+ { id: 1, title: 'Last Book 1', author: 'Last Author 1', publishedDate: new Date('2023-03-01') }, // Duplicate ID
602
+ ]
603
+
604
+ const results = await service.bulkUpsert(inputsWithDuplicates)
605
+
606
+ expect(results).toHaveLength(3)
607
+
608
+ // The repository should handle duplicates according to its implementation
609
+ // In our mock implementation, it processes them sequentially
610
+ const finalRecord1 = await repository.load({ id: 1 })
611
+ expect(finalRecord1?.title).toBe('Last Book 1') // Last write wins
612
+
613
+ const record2 = await repository.load({ id: 2 })
614
+ expect(record2?.title).toBe('Book 2')
615
+ })
616
+
617
+ it('should preserve order of results matching input order', async () => {
618
+ const inputs = [
619
+ { id: 3, title: 'Book 3', author: 'Author 3', publishedDate: new Date('2023-03-01') },
620
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date('2023-01-01') },
621
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date('2023-02-01') },
622
+ ]
623
+
624
+ const results = await service.bulkUpsert(inputs)
625
+
626
+ expect(results).toHaveLength(3)
627
+ expect(results[0].id).toBe(3)
628
+ expect(results[1].id).toBe(1)
629
+ expect(results[2].id).toBe(2)
630
+ expect(results).toEqual(inputs)
631
+ })
632
+ })
633
+
634
+ describe('Trash Functionality', () => {
635
+ const beforeEmptyTrashSpy = mock((event) => {})
636
+ const afterEmptyTrashSpy = mock((event) => {})
637
+ const beforePermanentlyDeleteFromTrashSpy = mock((event) => {})
638
+ const afterPermanentlyDeleteFromTrashSpy = mock((event) => {})
639
+ const beforePermanentlyDeleteSpy = mock((event) => {})
640
+ const afterPermanentlyDeleteSpy = mock((event) => {})
641
+
642
+ beforeEach(() => {
643
+ emitter.on('books::book.beforeEmptyTrash', beforeEmptyTrashSpy)
644
+ emitter.on('books::book.afterEmptyTrash', afterEmptyTrashSpy)
645
+ emitter.on('books::book.beforePermanentlyDeleteFromTrash', beforePermanentlyDeleteFromTrashSpy)
646
+ emitter.on('books::book.afterPermanentlyDeleteFromTrash', afterPermanentlyDeleteFromTrashSpy)
647
+ emitter.on('books::book.beforePermanentlyDelete', beforePermanentlyDeleteSpy)
648
+ emitter.on('books::book.afterPermanentlyDelete', afterPermanentlyDeleteSpy)
649
+
650
+ beforeEmptyTrashSpy.mockClear()
651
+ afterEmptyTrashSpy.mockClear()
652
+ beforePermanentlyDeleteFromTrashSpy.mockClear()
653
+ afterPermanentlyDeleteFromTrashSpy.mockClear()
654
+ beforePermanentlyDeleteSpy.mockClear()
655
+ afterPermanentlyDeleteSpy.mockClear()
656
+ })
657
+
658
+ describe('emptyTrash', () => {
659
+ it('should permanently delete all items from trash', async () => {
660
+ // Create and remove multiple items
661
+ const book1 = await repository.create({
662
+ title: 'Book 1',
663
+ author: 'Author 1',
664
+ publishedDate: new Date(),
665
+ })
666
+ const book2 = await repository.create({
667
+ title: 'Book 2',
668
+ author: 'Author 2',
669
+ publishedDate: new Date(),
670
+ })
671
+ const book3 = await repository.create({
672
+ title: 'Book 3',
673
+ author: 'Author 3',
674
+ publishedDate: new Date(),
675
+ })
676
+
677
+ await repository.remove({ id: book1.id })
678
+ await repository.remove({ id: book2.id })
679
+ await repository.remove({ id: book3.id })
680
+
681
+ // Empty trash
682
+ const count = await service.emptyTrash()
683
+
684
+ expect(count).toBe(3)
685
+
686
+ // Verify items are no longer in trash
687
+ const inTrash1 = await repository.load({ id: book1.id }, { removedOnly: true })
688
+ const inTrash2 = await repository.load({ id: book2.id }, { removedOnly: true })
689
+ const inTrash3 = await repository.load({ id: book3.id }, { removedOnly: true })
690
+
691
+ expect(inTrash1).toBeNull()
692
+ expect(inTrash2).toBeNull()
693
+ expect(inTrash3).toBeNull()
694
+ })
695
+
696
+ it('should permanently delete filtered items from trash', async () => {
697
+ const repositoryWithFilter = new MockMemoryRepository({
698
+ schema: mockSchema,
699
+ filter: (data, filters) => {
700
+ if (filters.text) {
701
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
702
+ }
703
+ return true
704
+ },
705
+ })
706
+
707
+ const serviceWithFilter = new ModelService({
708
+ repository: repositoryWithFilter,
709
+ emitter,
710
+ schema: mockSchema,
711
+ namespace,
712
+ })
713
+
714
+ const book1 = await repositoryWithFilter.create({
715
+ title: 'Test Book 1',
716
+ author: 'Author 1',
717
+ publishedDate: new Date(),
718
+ })
719
+ const book2 = await repositoryWithFilter.create({
720
+ title: 'Test Book 2',
721
+ author: 'Author 2',
722
+ publishedDate: new Date(),
723
+ })
724
+ const book3 = await repositoryWithFilter.create({
725
+ title: 'Other Book',
726
+ author: 'Author 3',
727
+ publishedDate: new Date(),
728
+ })
729
+
730
+ await repositoryWithFilter.remove({ id: book1.id })
731
+ await repositoryWithFilter.remove({ id: book2.id })
732
+ await repositoryWithFilter.remove({ id: book3.id })
733
+
734
+ // Empty trash with filter
735
+ const count = await serviceWithFilter.emptyTrash({ text: 'Test' })
736
+
737
+ expect(count).toBe(2)
738
+
739
+ // Verify only filtered items were deleted
740
+ const inTrash1 = await repositoryWithFilter.load({ id: book1.id }, { removedOnly: true })
741
+ const inTrash2 = await repositoryWithFilter.load({ id: book2.id }, { removedOnly: true })
742
+ const inTrash3 = await repositoryWithFilter.load({ id: book3.id }, { removedOnly: true })
743
+
744
+ expect(inTrash1).toBeNull()
745
+ expect(inTrash2).toBeNull()
746
+ expect(inTrash3).not.toBeNull()
747
+ expect(inTrash3?.title).toBe('Other Book')
748
+ })
749
+
750
+ it('should return 0 when trash is empty', async () => {
751
+ const count = await service.emptyTrash()
752
+ expect(count).toBe(0)
753
+ })
754
+
755
+ it('should trigger before and after events for emptyTrash', async () => {
756
+ const book = await repository.create({
757
+ title: 'Book to Remove',
758
+ author: 'Author',
759
+ publishedDate: new Date(),
760
+ })
761
+ await repository.remove({ id: book.id })
762
+
763
+ await service.emptyTrash()
764
+
765
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledTimes(1)
766
+ expect(beforeEmptyTrashSpy).toHaveBeenCalledWith(
767
+ expect.objectContaining({ type: 'books::book.beforeEmptyTrash' }),
768
+ )
769
+ expect(afterEmptyTrashSpy).toHaveBeenCalledTimes(1)
770
+ expect(afterEmptyTrashSpy).toHaveBeenCalledWith(
771
+ expect.objectContaining({ type: 'books::book.afterEmptyTrash' }),
772
+ )
773
+ })
774
+
775
+ it('should not affect non-removed items', async () => {
776
+ await repository.create({ title: 'Active Book 1', author: 'Author 1', publishedDate: new Date() })
777
+ await repository.create({ title: 'Active Book 2', author: 'Author 2', publishedDate: new Date() })
778
+
779
+ const book3 = await repository.create({
780
+ title: 'Book to Remove',
781
+ author: 'Author 3',
782
+ publishedDate: new Date(),
783
+ })
784
+ await repository.remove({ id: book3.id })
785
+
786
+ // Empty trash
787
+ await service.emptyTrash()
788
+
789
+ // Verify active items are still there
790
+ const results = await repository.search({})
791
+ expect(results.results).toHaveLength(2)
792
+ })
793
+ })
794
+
795
+ describe('permanentlyDeleteFromTrash', () => {
796
+ it('should permanently delete a removed item from trash', async () => {
797
+ const book = await repository.create({
798
+ title: 'Book to Delete',
799
+ author: 'Author',
800
+ publishedDate: new Date(),
801
+ })
802
+
803
+ // Remove the book
804
+ await repository.remove({ id: book.id })
805
+
806
+ // Verify it's in trash
807
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
808
+ expect(inTrash).not.toBeNull()
809
+
810
+ // Permanently delete from trash
811
+ const deleted = await service.permanentlyDeleteFromTrash({ id: book.id })
812
+
813
+ expect(deleted.id).toBe(book.id)
814
+ expect(deleted.title).toBe('Book to Delete')
815
+
816
+ // Verify it's no longer in trash
817
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
818
+ expect(afterDelete).toBeNull()
819
+
820
+ // Verify it's not in main data either
821
+ const inMain = await repository.load({ id: book.id })
822
+ expect(inMain).toBeNull()
823
+ })
824
+
825
+ it('should throw error when trying to delete non-existent item from trash', async () => {
826
+ await expect(service.permanentlyDeleteFromTrash({ id: 999 })).rejects.toThrow()
827
+ })
828
+
829
+ it('should throw error when trying to delete active item from trash', async () => {
830
+ const book = await repository.create({
831
+ title: 'Active Book',
832
+ author: 'Author',
833
+ publishedDate: new Date(),
834
+ })
835
+
836
+ // Should fail because item is not in trash
837
+ await expect(service.permanentlyDeleteFromTrash({ id: book.id })).rejects.toThrow()
838
+ })
839
+
840
+ it('should trigger before and after events for permanentlyDeleteFromTrash', async () => {
841
+ const book = await repository.create({
842
+ title: 'Book to Delete',
843
+ author: 'Author',
844
+ publishedDate: new Date(),
845
+ })
846
+ await repository.remove({ id: book.id })
847
+
848
+ await service.permanentlyDeleteFromTrash({ id: book.id })
849
+
850
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
851
+ expect(beforePermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
852
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDeleteFromTrash' }),
853
+ )
854
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledTimes(1)
855
+ expect(afterPermanentlyDeleteFromTrashSpy).toHaveBeenCalledWith(
856
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDeleteFromTrash' }),
857
+ )
858
+ })
859
+ })
860
+
861
+ describe('permanentlyDelete', () => {
862
+ it('should permanently delete a removed item', async () => {
863
+ const book = await repository.create({
864
+ title: 'Book to Delete',
865
+ author: 'Author',
866
+ publishedDate: new Date(),
867
+ })
868
+
869
+ // Remove the book first
870
+ await repository.remove({ id: book.id })
871
+
872
+ // Verify it's in trash
873
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
874
+ expect(inTrash).not.toBeNull()
875
+
876
+ // Permanently delete
877
+ const deleted = await service.permanentlyDelete({ id: book.id })
878
+
879
+ expect(deleted.id).toBe(book.id)
880
+ expect(deleted.title).toBe('Book to Delete')
881
+
882
+ // Verify it's no longer anywhere
883
+ const afterDelete = await repository.load({ id: book.id }, { removedOnly: true })
884
+ expect(afterDelete).toBeNull()
885
+
886
+ const inMain = await repository.load({ id: book.id })
887
+ expect(inMain).toBeNull()
888
+ })
889
+
890
+ it('should permanently delete an active item', async () => {
891
+ const book = await repository.create({
892
+ title: 'Active Book to Delete',
893
+ author: 'Author',
894
+ publishedDate: new Date(),
895
+ })
896
+
897
+ // Verify it exists
898
+ const exists = await repository.load({ id: book.id })
899
+ expect(exists).not.toBeNull()
900
+
901
+ // Permanently delete without removing first
902
+ const deleted = await service.permanentlyDelete({ id: book.id })
903
+
904
+ expect(deleted.id).toBe(book.id)
905
+ expect(deleted.title).toBe('Active Book to Delete')
906
+
907
+ // Verify it's no longer in main data
908
+ const afterDelete = await repository.load({ id: book.id })
909
+ expect(afterDelete).toBeNull()
910
+
911
+ // Verify it's not in trash either
912
+ const inTrash = await repository.load({ id: book.id }, { removedOnly: true })
913
+ expect(inTrash).toBeNull()
914
+ })
915
+
916
+ it('should throw error when trying to delete non-existent item', async () => {
917
+ await expect(service.permanentlyDelete({ id: 999 })).rejects.toThrow()
918
+ })
919
+
920
+ it('should trigger before and after events for permanentlyDelete', async () => {
921
+ const book = await repository.create({
922
+ title: 'Book to Delete',
923
+ author: 'Author',
924
+ publishedDate: new Date(),
925
+ })
926
+
927
+ await service.permanentlyDelete({ id: book.id })
928
+
929
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
930
+ expect(beforePermanentlyDeleteSpy).toHaveBeenCalledWith(
931
+ expect.objectContaining({ type: 'books::book.beforePermanentlyDelete' }),
932
+ )
933
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledTimes(1)
934
+ expect(afterPermanentlyDeleteSpy).toHaveBeenCalledWith(
935
+ expect.objectContaining({ type: 'books::book.afterPermanentlyDelete' }),
936
+ )
937
+ })
938
+ })
939
+ })
940
+ })