Skip to content

Commit 68c6c1d

Browse files
ratorikratorik
andauthored
fix: resolve issue when updating or adding content type (#998)
* fix: resolve issue when updating or adding content type with draft enabled fix: resolve Meilisearch issue with draftAndPublish enabled When updating or adding an item with draftAndPublish enabled, the item is removed from Meilisearch, and the following error occurs: error: Meilisearch could not add entry with id: 9: Transaction query already complete This issue is described in #997. The item should update correctly without errors when draftAndPublish is enabled. * test (lifecycle): implemend test for afterCreate, afterCreateMany, afterUpdate, afterUpdateMany, afterDelete and afterDeleteMany * cleanup: eslint issues --------- Co-authored-by: ratorik <[email protected]>
1 parent bd01940 commit 68c6c1d

File tree

4 files changed

+368
-20
lines changed

4 files changed

+368
-20
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module.exports = {
2222
'**/__tests__/**/content-types.test.[jt]s?(x)',
2323
'**/__tests__/**/meilisearch.test.[jt]s?(x)',
2424
'**/__tests__/**/configuration.test.[jt]s?(x)',
25+
'**/__tests__/**/lifecycle.test.[jt]s?(x)',
2526
'**/__tests__/**/configuration-validation.test.[jt]s?(x)',
2627
],
2728
}

server/src/__mocks__/strapi.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function createStrapiMock({
3232
const mockPluginService = jest.fn(() => {
3333
return {
3434
getContentTypesUid: () => ['restaurant', 'about'],
35+
getContentTypeUid: ({ contentType }) => contentType,
3536
getCollectionName: ({ contentType }) => contentType,
3637
getCredentials: () => ({
3738
host: 'http://localhost:7700',
@@ -48,6 +49,10 @@ function createStrapiMock({
4849
subscribeContentType: () => {
4950
return
5051
},
52+
// Add methods for Meilisearch operations
53+
addEntriesToMeilisearch: jest.fn(),
54+
updateEntriesInMeilisearch: jest.fn(),
55+
deleteEntriesFromMeiliSearch: jest.fn(),
5156
}
5257
})
5358

@@ -64,6 +69,9 @@ function createStrapiMock({
6469
return 1
6570
})
6671
const mockDb = {
72+
lifecycles: {
73+
subscribe: jest.fn(),
74+
},
6775
query: jest.fn(() => ({
6876
count: mockFindWithCount,
6977
})),
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
import createLifecycle from '../services/lifecycle/lifecycle.js'
2+
import { MeiliSearch } from '../__mocks__/meilisearch'
3+
import { createStrapiMock } from '../__mocks__/strapi'
4+
5+
global.meiliSearch = MeiliSearch
6+
7+
const strapiMock = createStrapiMock({})
8+
global.strapi = strapiMock
9+
10+
// Setup service mocks to handle lifecycle operations
11+
const meilisearchService = {
12+
addEntriesToMeilisearch: jest.fn().mockReturnValue(Promise.resolve()),
13+
updateEntriesInMeilisearch: jest.fn().mockReturnValue(Promise.resolve()),
14+
deleteEntriesFromMeiliSearch: jest.fn().mockReturnValue(Promise.resolve()),
15+
getContentTypesUid: () => ['restaurant', 'about'],
16+
getContentTypeUid: ({ contentType }) => contentType,
17+
getCollectionName: ({ contentType }) => contentType,
18+
entriesQuery: jest.fn(() => ({})),
19+
}
20+
21+
const storeService = {
22+
addListenedContentType: jest.fn(() => ({})),
23+
}
24+
25+
const contentTypeService = {
26+
getContentTypeUid: ({ contentType }) => contentType,
27+
getEntry: jest.fn(),
28+
}
29+
30+
// Create a mock of the plugin service function
31+
const originalPlugin = strapiMock.plugin
32+
strapiMock.plugin = jest.fn(pluginName => {
33+
if (pluginName === 'meilisearch') {
34+
return {
35+
service: jest.fn(serviceName => {
36+
if (serviceName === 'store') return storeService
37+
if (serviceName === 'meilisearch') return meilisearchService
38+
if (serviceName === 'contentType') return contentTypeService
39+
return originalPlugin().service()
40+
}),
41+
}
42+
}
43+
return originalPlugin(pluginName)
44+
})
45+
46+
describe('Lifecycle Meilisearch integration', () => {
47+
let lifecycleHandler
48+
49+
beforeEach(async () => {
50+
jest.clearAllMocks()
51+
jest.restoreAllMocks()
52+
53+
// Reset all mocks for clean state
54+
meilisearchService.addEntriesToMeilisearch
55+
.mockClear()
56+
.mockReturnValue(Promise.resolve())
57+
meilisearchService.updateEntriesInMeilisearch
58+
.mockClear()
59+
.mockReturnValue(Promise.resolve())
60+
meilisearchService.deleteEntriesFromMeiliSearch
61+
.mockClear()
62+
.mockReturnValue(Promise.resolve())
63+
64+
contentTypeService.getEntries = jest
65+
.fn()
66+
.mockResolvedValue([{ id: '1', title: 'Test' }])
67+
contentTypeService.numberOfEntries = jest.fn().mockResolvedValue(5)
68+
69+
lifecycleHandler = createLifecycle({ strapi: strapiMock })
70+
})
71+
72+
test('should add entry to Meilisearch on afterCreate', async () => {
73+
const contentTypeUid = 'api::restaurant.restaurant'
74+
const result = { id: '123', title: 'Test Entry' }
75+
76+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
77+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreate({
78+
result,
79+
})
80+
81+
expect(meilisearchService.addEntriesToMeilisearch).toHaveBeenCalledWith({
82+
contentType: contentTypeUid,
83+
entries: [result],
84+
})
85+
expect(storeService.addListenedContentType).toHaveBeenCalledWith({
86+
contentType: contentTypeUid,
87+
})
88+
})
89+
90+
test('should handle error during afterCreate', async () => {
91+
const contentTypeUid = 'api::restaurant.restaurant'
92+
const result = { id: '123', title: 'Test Entry' }
93+
const error = new Error('Connection failed')
94+
95+
// Mock error scenario
96+
meilisearchService.addEntriesToMeilisearch.mockRejectedValueOnce(error)
97+
jest.spyOn(strapiMock.log, 'error')
98+
99+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
100+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreate({
101+
result,
102+
})
103+
104+
expect(strapiMock.log.error).toHaveBeenCalledWith(
105+
`Meilisearch could not add entry with id: ${result.id}: ${error.message}`,
106+
)
107+
})
108+
109+
test('should process multiple entries on afterCreateMany', async () => {
110+
const contentTypeUid = 'api::restaurant.restaurant'
111+
const result = {
112+
count: 3,
113+
ids: ['1', '2', '3'],
114+
}
115+
116+
const mockEntries = [
117+
{ id: '1', title: 'Entry 1' },
118+
{ id: '2', title: 'Entry 2' },
119+
{ id: '3', title: 'Entry 3' },
120+
]
121+
122+
contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
123+
124+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
125+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreateMany({
126+
result,
127+
})
128+
129+
expect(contentTypeService.getEntries).toHaveBeenCalledWith({
130+
contentType: contentTypeUid,
131+
start: 0,
132+
limit: 500,
133+
filters: {
134+
id: {
135+
$in: result.ids,
136+
},
137+
},
138+
})
139+
140+
expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
141+
contentType: contentTypeUid,
142+
entries: mockEntries,
143+
})
144+
})
145+
146+
test('should handle error during afterCreateMany', async () => {
147+
const contentTypeUid = 'api::restaurant.restaurant'
148+
const result = {
149+
count: 3,
150+
ids: ['1', '2', '3'],
151+
}
152+
153+
const mockEntries = [
154+
{ id: '1', title: 'Entry 1' },
155+
{ id: '2', title: 'Entry 2' },
156+
{ id: '3', title: 'Entry 3' },
157+
]
158+
159+
// Setup the mock to return entries but fail on updateEntriesInMeilisearch
160+
contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
161+
const error = new Error('Batch update failed')
162+
meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
163+
164+
jest.spyOn(strapiMock.log, 'error')
165+
166+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
167+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterCreateMany({
168+
result,
169+
})
170+
171+
expect(strapiMock.log.error).toHaveBeenCalledWith(
172+
`Meilisearch could not update the entries: ${error.message}`,
173+
)
174+
})
175+
176+
test('should update entry in Meilisearch on afterUpdate', async () => {
177+
const contentTypeUid = 'api::restaurant.restaurant'
178+
const result = { id: '123', title: 'Updated Entry' }
179+
180+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
181+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdate({
182+
result,
183+
})
184+
185+
expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
186+
contentType: contentTypeUid,
187+
entries: [result],
188+
})
189+
})
190+
191+
test('should handle error during afterUpdate', async () => {
192+
const contentTypeUid = 'api::restaurant.restaurant'
193+
const result = { id: '123', title: 'Updated Entry' }
194+
const error = new Error('Update failed')
195+
196+
meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
197+
jest.spyOn(strapiMock.log, 'error')
198+
199+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
200+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdate({
201+
result,
202+
})
203+
204+
expect(strapiMock.log.error).toHaveBeenCalledWith(
205+
`Meilisearch could not update entry with id: ${result.id}: ${error.message}`,
206+
)
207+
})
208+
209+
test('should process multiple entries on afterUpdateMany', async () => {
210+
const contentTypeUid = 'api::restaurant.restaurant'
211+
const event = {
212+
params: {
213+
where: { type: 'restaurant' },
214+
},
215+
}
216+
217+
const mockEntries = [
218+
{ id: '1', title: 'Updated 1' },
219+
{ id: '2', title: 'Updated 2' },
220+
]
221+
222+
contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
223+
224+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
225+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdateMany(
226+
event,
227+
)
228+
229+
expect(contentTypeService.numberOfEntries).toHaveBeenCalledWith({
230+
contentType: contentTypeUid,
231+
filters: event.params.where,
232+
})
233+
234+
expect(contentTypeService.getEntries).toHaveBeenCalledWith({
235+
contentType: contentTypeUid,
236+
filters: event.params.where,
237+
start: 0,
238+
limit: 500,
239+
})
240+
241+
expect(meilisearchService.updateEntriesInMeilisearch).toHaveBeenCalledWith({
242+
contentType: contentTypeUid,
243+
entries: mockEntries,
244+
})
245+
})
246+
247+
test('should handle error during afterUpdateMany', async () => {
248+
const contentTypeUid = 'api::restaurant.restaurant'
249+
const event = {
250+
params: {
251+
where: { type: 'restaurant' },
252+
},
253+
}
254+
255+
const mockEntries = [
256+
{ id: '1', title: 'Updated 1' },
257+
{ id: '2', title: 'Updated 2' },
258+
]
259+
260+
// Setup mocks for the success path but failure during Meilisearch update
261+
contentTypeService.getEntries.mockResolvedValueOnce(mockEntries)
262+
const error = new Error('Batch update failed')
263+
meilisearchService.updateEntriesInMeilisearch.mockRejectedValueOnce(error)
264+
265+
jest.spyOn(strapiMock.log, 'error')
266+
267+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
268+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterUpdateMany(
269+
event,
270+
)
271+
272+
expect(strapiMock.log.error).toHaveBeenCalledWith(
273+
`Meilisearch could not update the entries: ${error.message}`,
274+
)
275+
})
276+
277+
test('should delete entry from Meilisearch on afterDelete', async () => {
278+
const contentTypeUid = 'api::restaurant.restaurant'
279+
const result = { id: '123' }
280+
281+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
282+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
283+
result,
284+
})
285+
286+
expect(
287+
meilisearchService.deleteEntriesFromMeiliSearch,
288+
).toHaveBeenCalledWith({
289+
contentType: contentTypeUid,
290+
entriesId: [result.id],
291+
})
292+
})
293+
294+
test('should handle multiple ids in afterDelete', async () => {
295+
const contentTypeUid = 'api::restaurant.restaurant'
296+
const result = { id: '123' }
297+
const params = {
298+
where: {
299+
$and: [{ id: { $in: ['101', '102', '103'] } }],
300+
},
301+
}
302+
303+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
304+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
305+
result,
306+
params,
307+
})
308+
309+
expect(
310+
meilisearchService.deleteEntriesFromMeiliSearch,
311+
).toHaveBeenCalledWith({
312+
contentType: contentTypeUid,
313+
entriesId: ['101', '102', '103'],
314+
})
315+
})
316+
317+
test('should handle error during afterDelete', async () => {
318+
const contentTypeUid = 'api::restaurant.restaurant'
319+
const result = { id: '123' }
320+
const error = new Error('Delete failed')
321+
322+
meilisearchService.deleteEntriesFromMeiliSearch.mockRejectedValueOnce(error)
323+
jest.spyOn(strapiMock.log, 'error')
324+
325+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
326+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDelete({
327+
result,
328+
})
329+
330+
expect(strapiMock.log.error).toHaveBeenCalledWith(
331+
`Meilisearch could not delete entry with id: ${result.id}: ${error.message}`,
332+
)
333+
})
334+
335+
test('should call afterDelete from afterDeleteMany', async () => {
336+
const contentTypeUid = 'api::restaurant.restaurant'
337+
const event = { result: { id: '123' } }
338+
339+
await lifecycleHandler.subscribeContentType({ contentType: contentTypeUid })
340+
341+
// Get a reference to the afterDelete handler
342+
const afterDeleteSpy = jest.spyOn(
343+
strapiMock.db.lifecycles.subscribe.mock.calls[0][0],
344+
'afterDelete',
345+
)
346+
347+
// Call afterDeleteMany
348+
await strapiMock.db.lifecycles.subscribe.mock.calls[0][0].afterDeleteMany(
349+
event,
350+
)
351+
352+
// Verify it calls afterDelete with the same event
353+
expect(afterDeleteSpy).toHaveBeenCalledWith(event)
354+
})
355+
})

0 commit comments

Comments
 (0)