adapt-authoring-tags 1.0.3 → 1.1.0

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.
@@ -0,0 +1,15 @@
1
+ name: Tests
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
package/package.json CHANGED
@@ -1,27 +1,14 @@
1
1
  {
2
2
  "name": "adapt-authoring-tags",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Module for managing tags",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-tags",
6
6
  "license": "GPL-3.0",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-tags",
10
- "peerDependencies": {
11
- "adapt-authoring-core": "^1.7.0",
12
- "adapt-authoring-jsonschema": "^1.2.0",
13
- "adapt-authoring-mongodb": "^1.1.3"
14
- },
15
- "peerDependenciesMeta": {
16
- "adapt-authoring-core": {
17
- "optional": true
18
- },
19
- "adapt-authoring-jsonschema": {
20
- "optional": true
21
- },
22
- "adapt-authoring-mongodb": {
23
- "optional": true
24
- }
10
+ "scripts": {
11
+ "test": "node --test 'tests/**/*.spec.js'"
25
12
  },
26
13
  "devDependencies": {
27
14
  "@semantic-release/git": "^10.0.1",
@@ -55,5 +42,24 @@
55
42
  }
56
43
  ]
57
44
  ]
45
+ },
46
+ "dependencies": {
47
+ "adapt-authoring-api": "^1.3.2"
48
+ },
49
+ "peerDependencies": {
50
+ "adapt-authoring-core": "^1.7.0",
51
+ "adapt-authoring-jsonschema": "^1.2.0",
52
+ "adapt-authoring-mongodb": "^1.1.3"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "adapt-authoring-core": {
56
+ "optional": true
57
+ },
58
+ "adapt-authoring-jsonschema": {
59
+ "optional": true
60
+ },
61
+ "adapt-authoring-mongodb": {
62
+ "optional": true
63
+ }
58
64
  }
59
65
  }
@@ -0,0 +1,614 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import TagsModule from '../lib/TagsModule.js'
4
+
5
+ /**
6
+ * Creates a TagsModule instance with mocked dependencies.
7
+ * Prevents the AbstractModule constructor from calling init()
8
+ * by temporarily replacing the prototype method.
9
+ */
10
+ function createInstance (overrides = {}) {
11
+ const mockJsonschema = {
12
+ registerSchemasHook: { tap: mock.fn() },
13
+ extendSchema: mock.fn()
14
+ }
15
+ const mockMongodb = {
16
+ setIndex: mock.fn(async () => {}),
17
+ find: mock.fn(async () => []),
18
+ insert: mock.fn(async () => ({})),
19
+ delete: mock.fn(async () => {})
20
+ }
21
+ const mockApp = {
22
+ waitForModule: mock.fn(async (name) => {
23
+ if (name === 'jsonschema') return mockJsonschema
24
+ if (name === 'mongodb') return mockMongodb
25
+ return {}
26
+ }),
27
+ errors: {},
28
+ dependencyloader: {
29
+ moduleLoadedHook: {
30
+ tap: () => {},
31
+ untap: () => {}
32
+ }
33
+ },
34
+ ...overrides
35
+ }
36
+
37
+ const originalInit = TagsModule.prototype.init
38
+ TagsModule.prototype.init = async function () {}
39
+
40
+ const instance = new TagsModule(mockApp, { name: 'adapt-authoring-tags' })
41
+
42
+ TagsModule.prototype.init = originalInit
43
+
44
+ // Manually set properties that setValues() would set
45
+ instance.root = 'tags'
46
+ instance.schemaName = 'tag'
47
+ instance.schemaExtensionName = 'tags'
48
+ instance.collectionName = 'tags'
49
+ instance.modules = []
50
+ instance.routes = []
51
+ instance.log = mock.fn()
52
+
53
+ return { instance, mockApp, mockJsonschema, mockMongodb }
54
+ }
55
+
56
+ describe('TagsModule', () => {
57
+ describe('#setValues()', () => {
58
+ it('should set root to "tags"', async () => {
59
+ const { instance } = createInstance()
60
+ instance.root = undefined
61
+ instance.schemaName = undefined
62
+ instance.schemaExtensionName = undefined
63
+ instance.collectionName = undefined
64
+ instance.modules = undefined
65
+ instance.routes = undefined
66
+ // useDefaultRouteConfig normally sets this.routes; mock must do the same
67
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
68
+ await instance.setValues()
69
+ assert.equal(instance.root, 'tags')
70
+ })
71
+
72
+ it('should set schemaName to "tag"', async () => {
73
+ const { instance } = createInstance()
74
+ instance.root = undefined
75
+ instance.schemaName = undefined
76
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
77
+ await instance.setValues()
78
+ assert.equal(instance.schemaName, 'tag')
79
+ })
80
+
81
+ it('should set schemaExtensionName to "tags"', async () => {
82
+ const { instance } = createInstance()
83
+ instance.schemaExtensionName = undefined
84
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
85
+ await instance.setValues()
86
+ assert.equal(instance.schemaExtensionName, 'tags')
87
+ })
88
+
89
+ it('should set collectionName to "tags"', async () => {
90
+ const { instance } = createInstance()
91
+ instance.collectionName = undefined
92
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
93
+ await instance.setValues()
94
+ assert.equal(instance.collectionName, 'tags')
95
+ })
96
+
97
+ it('should initialise modules as an empty array', async () => {
98
+ const { instance } = createInstance()
99
+ instance.modules = undefined
100
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
101
+ await instance.setValues()
102
+ assert.deepEqual(instance.modules, [])
103
+ })
104
+
105
+ it('should call useDefaultRouteConfig', async () => {
106
+ const { instance } = createInstance()
107
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
108
+ await instance.setValues()
109
+ assert.equal(instance.useDefaultRouteConfig.mock.calls.length, 1)
110
+ })
111
+
112
+ it('should add autocomplete and transfer routes', async () => {
113
+ const { instance } = createInstance()
114
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
115
+ await instance.setValues()
116
+
117
+ const autocompleteRoute = instance.routes.find(r => r.route === '/autocomplete')
118
+ assert.ok(autocompleteRoute, 'autocomplete route should exist')
119
+ assert.ok(autocompleteRoute.handlers.get, 'autocomplete should have GET handler')
120
+ assert.deepEqual(autocompleteRoute.permissions.get, ['read:content'])
121
+
122
+ const transferRoute = instance.routes.find(r => r.route === '/transfer/:_id')
123
+ assert.ok(transferRoute, 'transfer route should exist')
124
+ assert.ok(transferRoute.handlers.post, 'transfer should have POST handler')
125
+ assert.deepEqual(transferRoute.permissions.post, ['write:content'])
126
+ assert.equal(transferRoute.modifying, false)
127
+ })
128
+
129
+ it('should call mongodb.setIndex for unique title', async () => {
130
+ const { instance, mockMongodb } = createInstance()
131
+ instance.useDefaultRouteConfig = mock.fn(function () { this.routes = [] })
132
+ await instance.setValues()
133
+ assert.equal(mockMongodb.setIndex.mock.calls.length, 1)
134
+ const call = mockMongodb.setIndex.mock.calls[0]
135
+ assert.equal(call.arguments[0], 'tags')
136
+ assert.equal(call.arguments[1], 'title')
137
+ assert.deepEqual(call.arguments[2], { unique: true })
138
+ })
139
+ })
140
+
141
+ describe('#registerModule()', () => {
142
+ it('should register a module with a schemaName', async () => {
143
+ const { instance } = createInstance()
144
+ const mod = { schemaName: 'testSchema', name: 'test-mod' }
145
+ await instance.registerModule(mod)
146
+ assert.equal(instance.modules.length, 1)
147
+ assert.equal(instance.modules[0], mod)
148
+ })
149
+
150
+ it('should call registerSchema when registering a module', async () => {
151
+ const { instance, mockJsonschema } = createInstance()
152
+ const mod = { schemaName: 'testSchema', name: 'test-mod' }
153
+ await instance.registerModule(mod)
154
+ assert.ok(mockJsonschema.extendSchema.mock.calls.length > 0)
155
+ const call = mockJsonschema.extendSchema.mock.calls[0]
156
+ assert.equal(call.arguments[0], 'testSchema')
157
+ assert.equal(call.arguments[1], 'tags')
158
+ })
159
+
160
+ it('should log a debug message after registering', async () => {
161
+ const { instance } = createInstance()
162
+ const mod = { schemaName: 'testSchema', name: 'test-mod' }
163
+ await instance.registerModule(mod)
164
+ const debugCalls = instance.log.mock.calls.filter(
165
+ c => c.arguments[0] === 'debug'
166
+ )
167
+ assert.ok(debugCalls.length > 0)
168
+ assert.ok(debugCalls[0].arguments[1].includes('test-mod'))
169
+ })
170
+
171
+ it('should log a warning when module has no schemaName', async () => {
172
+ const { instance } = createInstance()
173
+ const mod = { name: 'no-schema-mod' }
174
+ await instance.registerModule(mod)
175
+ const warnCalls = instance.log.mock.calls.filter(
176
+ c => c.arguments[0] === 'warn'
177
+ )
178
+ assert.ok(warnCalls.length > 0)
179
+ assert.ok(warnCalls[0].arguments[1].includes('schemaName'))
180
+ })
181
+
182
+ it('should not add module to modules array when schemaName is missing', async () => {
183
+ const { instance } = createInstance()
184
+ const mod = { name: 'no-schema-mod' }
185
+ await instance.registerModule(mod)
186
+ assert.equal(instance.modules.length, 0)
187
+ })
188
+ })
189
+
190
+ describe('#registerSchema()', () => {
191
+ it('should call jsonschema.extendSchema with the correct arguments', async () => {
192
+ const { instance, mockJsonschema } = createInstance()
193
+ const mod = { schemaName: 'mySchema' }
194
+ await instance.registerSchema(mod)
195
+ assert.equal(mockJsonschema.extendSchema.mock.calls.length, 1)
196
+ const call = mockJsonschema.extendSchema.mock.calls[0]
197
+ assert.equal(call.arguments[0], 'mySchema')
198
+ assert.equal(call.arguments[1], 'tags')
199
+ })
200
+
201
+ it('should silently catch errors from extendSchema', async () => {
202
+ const failingJsonschema = {
203
+ registerSchemasHook: { tap: mock.fn() },
204
+ extendSchema: mock.fn(() => { throw new Error('schema error') })
205
+ }
206
+ const { instance } = createInstance({
207
+ waitForModule: mock.fn(async () => failingJsonschema)
208
+ })
209
+ const mod = { schemaName: 'badSchema' }
210
+ await assert.doesNotReject(() => instance.registerSchema(mod))
211
+ })
212
+
213
+ it('should silently catch errors from waitForModule', async () => {
214
+ const { instance } = createInstance({
215
+ waitForModule: mock.fn(async () => { throw new Error('module not found') })
216
+ })
217
+ const mod = { schemaName: 'testSchema' }
218
+ await assert.doesNotReject(() => instance.registerSchema(mod))
219
+ })
220
+ })
221
+
222
+ describe('#registerSchemas()', () => {
223
+ it('should call registerSchema for each registered module', async () => {
224
+ const { instance, mockJsonschema } = createInstance()
225
+ instance.modules = [
226
+ { schemaName: 'schema1' },
227
+ { schemaName: 'schema2' },
228
+ { schemaName: 'schema3' }
229
+ ]
230
+ await instance.registerSchemas()
231
+ assert.equal(mockJsonschema.extendSchema.mock.calls.length, 3)
232
+ })
233
+
234
+ it('should handle empty modules array', async () => {
235
+ const { instance, mockJsonschema } = createInstance()
236
+ instance.modules = []
237
+ await instance.registerSchemas()
238
+ assert.equal(mockJsonschema.extendSchema.mock.calls.length, 0)
239
+ })
240
+ })
241
+
242
+ describe('#autocompleteHandler()', () => {
243
+ it('should return mapped tag data as JSON', async () => {
244
+ const { instance } = createInstance()
245
+ const findResults = [
246
+ { _id: 'id1', title: 'JavaScript' },
247
+ { _id: 'id2', title: 'Java' }
248
+ ]
249
+ instance.find = mock.fn(async () => findResults)
250
+ const jsonData = []
251
+ const req = {
252
+ apiData: { query: { term: 'Ja' } }
253
+ }
254
+ const res = {
255
+ json: mock.fn((data) => { jsonData.push(...data) })
256
+ }
257
+ const next = mock.fn()
258
+ await instance.autocompleteHandler(req, res, next)
259
+ assert.equal(res.json.mock.calls.length, 1)
260
+ assert.equal(jsonData.length, 2)
261
+ assert.deepEqual(jsonData[0], { _id: 'id1', title: 'JavaScript', value: 'JavaScript' })
262
+ assert.deepEqual(jsonData[1], { _id: 'id2', title: 'Java', value: 'Java' })
263
+ })
264
+
265
+ it('should query find with a regex based on the term', async () => {
266
+ const { instance } = createInstance()
267
+ instance.find = mock.fn(async () => [])
268
+ const req = {
269
+ apiData: { query: { term: 'test' } }
270
+ }
271
+ const res = { json: mock.fn() }
272
+ const next = mock.fn()
273
+ await instance.autocompleteHandler(req, res, next)
274
+ assert.equal(instance.find.mock.calls.length, 1)
275
+ const query = instance.find.mock.calls[0].arguments[0]
276
+ assert.ok(query.title.$regex)
277
+ assert.equal(query.title.$regex, '^test')
278
+ })
279
+
280
+ it('should return empty array when no tags match', async () => {
281
+ const { instance } = createInstance()
282
+ instance.find = mock.fn(async () => [])
283
+ const jsonData = []
284
+ const req = {
285
+ apiData: { query: { term: 'nonexistent' } }
286
+ }
287
+ const res = {
288
+ json: mock.fn((data) => { jsonData.push(...data) })
289
+ }
290
+ const next = mock.fn()
291
+ await instance.autocompleteHandler(req, res, next)
292
+ assert.equal(res.json.mock.calls.length, 1)
293
+ assert.equal(jsonData.length, 0)
294
+ })
295
+
296
+ it('should map value to title for each result', async () => {
297
+ const { instance } = createInstance()
298
+ instance.find = mock.fn(async () => [
299
+ { _id: 'id1', title: 'React', extraProp: 'ignored' }
300
+ ])
301
+ let result
302
+ const req = {
303
+ apiData: { query: { term: 'Re' } }
304
+ }
305
+ const res = {
306
+ json: mock.fn((data) => { result = data })
307
+ }
308
+ const next = mock.fn()
309
+ await instance.autocompleteHandler(req, res, next)
310
+ assert.equal(result[0].value, result[0].title)
311
+ assert.equal(result[0].extraProp, undefined)
312
+ })
313
+ })
314
+
315
+ describe('#transferHandler()', () => {
316
+ let instance
317
+
318
+ beforeEach(() => {
319
+ ({ instance } = createInstance())
320
+ })
321
+
322
+ it('should push destId tag to all registered modules', async () => {
323
+ const mod = {
324
+ updateMany: mock.fn(async () => {})
325
+ }
326
+ instance.modules = [mod]
327
+ const req = {
328
+ apiData: {
329
+ query: { _id: 'sourceTag' },
330
+ data: { destId: 'destTag' }
331
+ }
332
+ }
333
+ const res = { json: mock.fn() }
334
+ const next = mock.fn()
335
+ await instance.transferHandler(req, res, next)
336
+ assert.ok(mod.updateMany.mock.calls.length >= 1)
337
+ const pushCall = mod.updateMany.mock.calls[0]
338
+ assert.deepEqual(pushCall.arguments[0], { tags: 'sourceTag' })
339
+ assert.deepEqual(pushCall.arguments[1], { $push: { tags: 'destTag' } })
340
+ assert.deepEqual(pushCall.arguments[2], { rawUpdate: true })
341
+ })
342
+
343
+ it('should pull sourceId when deleteSourceTag is not "true"', async () => {
344
+ const mod = {
345
+ updateMany: mock.fn(async () => {})
346
+ }
347
+ instance.modules = [mod]
348
+ const req = {
349
+ apiData: {
350
+ query: { _id: 'sourceTag' },
351
+ data: { destId: 'destTag' }
352
+ }
353
+ }
354
+ const res = { json: mock.fn() }
355
+ const next = mock.fn()
356
+ await instance.transferHandler(req, res, next)
357
+ assert.equal(mod.updateMany.mock.calls.length, 2)
358
+ const pullCall = mod.updateMany.mock.calls[1]
359
+ assert.deepEqual(pullCall.arguments[0], { tags: 'sourceTag' })
360
+ assert.deepEqual(pullCall.arguments[1], { $pull: { tags: 'sourceTag' } })
361
+ assert.deepEqual(pullCall.arguments[2], { rawUpdate: true })
362
+ })
363
+
364
+ it('should delete the source tag when deleteSourceTag is "true"', async () => {
365
+ const mod = {
366
+ updateMany: mock.fn(async () => {})
367
+ }
368
+ instance.modules = [mod]
369
+ instance.delete = mock.fn(async () => ({}))
370
+ const req = {
371
+ apiData: {
372
+ query: { _id: 'sourceTag', deleteSourceTag: 'true' },
373
+ data: { destId: 'destTag' }
374
+ }
375
+ }
376
+ const res = { json: mock.fn() }
377
+ const next = mock.fn()
378
+ await instance.transferHandler(req, res, next)
379
+ assert.equal(instance.delete.mock.calls.length, 1)
380
+ assert.deepEqual(instance.delete.mock.calls[0].arguments[0], { _id: 'sourceTag' })
381
+ })
382
+
383
+ it('should not pull sourceId when deleteSourceTag is "true"', async () => {
384
+ const mod = {
385
+ updateMany: mock.fn(async () => {})
386
+ }
387
+ instance.modules = [mod]
388
+ instance.delete = mock.fn(async () => ({}))
389
+ const req = {
390
+ apiData: {
391
+ query: { _id: 'sourceTag', deleteSourceTag: 'true' },
392
+ data: { destId: 'destTag' }
393
+ }
394
+ }
395
+ const res = { json: mock.fn() }
396
+ const next = mock.fn()
397
+ await instance.transferHandler(req, res, next)
398
+ // When deleteSourceTag is "true", it should only call $push, not $pull
399
+ assert.equal(mod.updateMany.mock.calls.length, 1)
400
+ assert.deepEqual(mod.updateMany.mock.calls[0].arguments[1], { $push: { tags: 'destTag' } })
401
+ })
402
+
403
+ it('should not delete source tag when deleteSourceTag is not "true"', async () => {
404
+ const mod = {
405
+ updateMany: mock.fn(async () => {})
406
+ }
407
+ instance.modules = [mod]
408
+ instance.delete = mock.fn(async () => ({}))
409
+ const req = {
410
+ apiData: {
411
+ query: { _id: 'sourceTag' },
412
+ data: { destId: 'destTag' }
413
+ }
414
+ }
415
+ const res = { json: mock.fn() }
416
+ const next = mock.fn()
417
+ await instance.transferHandler(req, res, next)
418
+ assert.equal(instance.delete.mock.calls.length, 0)
419
+ })
420
+
421
+ it('should handle multiple modules', async () => {
422
+ const mod1 = { updateMany: mock.fn(async () => {}) }
423
+ const mod2 = { updateMany: mock.fn(async () => {}) }
424
+ instance.modules = [mod1, mod2]
425
+ const req = {
426
+ apiData: {
427
+ query: { _id: 'sourceTag' },
428
+ data: { destId: 'destTag' }
429
+ }
430
+ }
431
+ const res = { json: mock.fn() }
432
+ const next = mock.fn()
433
+ await instance.transferHandler(req, res, next)
434
+ assert.ok(mod1.updateMany.mock.calls.length >= 1)
435
+ assert.ok(mod2.updateMany.mock.calls.length >= 1)
436
+ })
437
+
438
+ it('should call next with error when delete throws', async () => {
439
+ instance.modules = []
440
+ instance.delete = mock.fn(async () => { throw new Error('delete failed') })
441
+ const req = {
442
+ apiData: {
443
+ query: { _id: 'sourceTag', deleteSourceTag: 'true' },
444
+ data: { destId: 'destTag' }
445
+ }
446
+ }
447
+ const res = { json: mock.fn() }
448
+ const next = mock.fn()
449
+ await instance.transferHandler(req, res, next)
450
+ assert.equal(next.mock.calls.length, 1)
451
+ assert.ok(next.mock.calls[0].arguments[0] instanceof Error)
452
+ })
453
+
454
+ it('should log a warning when a module updateMany fails', async () => {
455
+ const mod = {
456
+ updateMany: mock.fn(async () => { throw new Error('update failed') })
457
+ }
458
+ instance.modules = [mod]
459
+ const req = {
460
+ apiData: {
461
+ query: { _id: 'sourceTag' },
462
+ data: { destId: 'destTag' }
463
+ }
464
+ }
465
+ const res = { json: mock.fn() }
466
+ const next = mock.fn()
467
+ await instance.transferHandler(req, res, next)
468
+ const warnCalls = instance.log.mock.calls.filter(
469
+ c => c.arguments[0] === 'warn'
470
+ )
471
+ assert.ok(warnCalls.length > 0)
472
+ assert.ok(warnCalls[0].arguments[1].includes('Failed to transfer tag'))
473
+ })
474
+
475
+ it('should handle empty modules array', async () => {
476
+ instance.modules = []
477
+ const req = {
478
+ apiData: {
479
+ query: { _id: 'sourceTag' },
480
+ data: { destId: 'destTag' }
481
+ }
482
+ }
483
+ const res = { json: mock.fn() }
484
+ const next = mock.fn()
485
+ await instance.transferHandler(req, res, next)
486
+ assert.equal(next.mock.calls.length, 0)
487
+ })
488
+ })
489
+
490
+ describe('#insert()', () => {
491
+ it('should return existing tag if one is found', async () => {
492
+ const { instance } = createInstance()
493
+ const existingTag = { _id: 'existing1', title: 'JavaScript' }
494
+ instance.find = mock.fn(async () => [existingTag])
495
+ const result = await instance.insert({ title: 'JavaScript' })
496
+ assert.equal(result, existingTag)
497
+ })
498
+
499
+ it('should call find with the same arguments', async () => {
500
+ const { instance } = createInstance()
501
+ instance.find = mock.fn(async () => [{ _id: 'tag1', title: 'test' }])
502
+ const args = { title: 'test' }
503
+ await instance.insert(args)
504
+ assert.equal(instance.find.mock.calls.length, 1)
505
+ assert.equal(instance.find.mock.calls[0].arguments[0], args)
506
+ })
507
+
508
+ it('should call super.insert when no existing tag is found', async () => {
509
+ const { instance } = createInstance()
510
+ const newTag = { _id: 'new1', title: 'NewTag' }
511
+ instance.find = mock.fn(async () => [])
512
+ // Mock the parent insert via the prototype chain
513
+ const parentInsert = mock.fn(async () => newTag)
514
+ Object.getPrototypeOf(TagsModule.prototype).insert = parentInsert
515
+ const result = await instance.insert({ title: 'NewTag' })
516
+ assert.equal(result, newTag)
517
+ assert.equal(parentInsert.mock.calls.length, 1)
518
+ })
519
+
520
+ it('should pass through all arguments to find', async () => {
521
+ const { instance } = createInstance()
522
+ instance.find = mock.fn(async () => [{ _id: 'tag1', title: 'test' }])
523
+ const data = { title: 'test' }
524
+ const options = { validate: false }
525
+ const mongoOpts = { limit: 1 }
526
+ await instance.insert(data, options, mongoOpts)
527
+ const findArgs = instance.find.mock.calls[0].arguments
528
+ assert.equal(findArgs[0], data)
529
+ assert.equal(findArgs[1], options)
530
+ assert.equal(findArgs[2], mongoOpts)
531
+ })
532
+ })
533
+
534
+ describe('#delete()', () => {
535
+ it('should call super.delete and return its result', async () => {
536
+ const { instance } = createInstance()
537
+ const deletedTag = { _id: 'deleted1', title: 'OldTag' }
538
+ const parentDelete = mock.fn(async () => deletedTag)
539
+ Object.getPrototypeOf(TagsModule.prototype).delete = parentDelete
540
+ instance.modules = []
541
+ const result = await instance.delete({ _id: 'deleted1' })
542
+ assert.equal(result, deletedTag)
543
+ })
544
+
545
+ it('should remove deleted tag from all registered modules', async () => {
546
+ const { instance } = createInstance()
547
+ const deletedTag = { _id: 'tagToDelete', title: 'RemoveMe' }
548
+ const parentDelete = mock.fn(async () => deletedTag)
549
+ Object.getPrototypeOf(TagsModule.prototype).delete = parentDelete
550
+ const mod1 = { updateMany: mock.fn(async () => {}) }
551
+ const mod2 = { updateMany: mock.fn(async () => {}) }
552
+ instance.modules = [mod1, mod2]
553
+ await instance.delete({ _id: 'tagToDelete' })
554
+ assert.equal(mod1.updateMany.mock.calls.length, 1)
555
+ assert.deepEqual(mod1.updateMany.mock.calls[0].arguments[0], { tags: 'tagToDelete' })
556
+ assert.deepEqual(mod1.updateMany.mock.calls[0].arguments[1], { $pull: { tags: 'tagToDelete' } })
557
+ assert.deepEqual(mod1.updateMany.mock.calls[0].arguments[2], { rawUpdate: true })
558
+ assert.equal(mod2.updateMany.mock.calls.length, 1)
559
+ })
560
+
561
+ it('should log a warning when module updateMany fails during delete', async () => {
562
+ const { instance } = createInstance()
563
+ const deletedTag = { _id: 'tagToDelete', title: 'RemoveMe' }
564
+ const parentDelete = mock.fn(async () => deletedTag)
565
+ Object.getPrototypeOf(TagsModule.prototype).delete = parentDelete
566
+ const mod = {
567
+ updateMany: mock.fn(async () => { throw new Error('update failed') })
568
+ }
569
+ instance.modules = [mod]
570
+ await instance.delete({ _id: 'tagToDelete' })
571
+ const warnCalls = instance.log.mock.calls.filter(
572
+ c => c.arguments[0] === 'warn'
573
+ )
574
+ assert.ok(warnCalls.length > 0)
575
+ assert.ok(warnCalls[0].arguments[1].includes('Failed to remove tag'))
576
+ })
577
+
578
+ it('should handle empty modules array', async () => {
579
+ const { instance } = createInstance()
580
+ const deletedTag = { _id: 'tagToDelete', title: 'RemoveMe' }
581
+ const parentDelete = mock.fn(async () => deletedTag)
582
+ Object.getPrototypeOf(TagsModule.prototype).delete = parentDelete
583
+ instance.modules = []
584
+ const result = await instance.delete({ _id: 'tagToDelete' })
585
+ assert.equal(result, deletedTag)
586
+ })
587
+
588
+ it('should continue removing from other modules if one fails', async () => {
589
+ const { instance } = createInstance()
590
+ const deletedTag = { _id: 'tagToDelete', title: 'RemoveMe' }
591
+ const parentDelete = mock.fn(async () => deletedTag)
592
+ Object.getPrototypeOf(TagsModule.prototype).delete = parentDelete
593
+ const mod1 = {
594
+ updateMany: mock.fn(async () => { throw new Error('fail') })
595
+ }
596
+ const mod2 = { updateMany: mock.fn(async () => {}) }
597
+ instance.modules = [mod1, mod2]
598
+ await instance.delete({ _id: 'tagToDelete' })
599
+ assert.equal(mod2.updateMany.mock.calls.length, 1)
600
+ })
601
+ })
602
+
603
+ describe('#init()', () => {
604
+ it('should tap into jsonschema registerSchemasHook', async () => {
605
+ const { instance, mockJsonschema } = createInstance()
606
+ // Mock super.init to be a no-op
607
+ const origSuperInit = Object.getPrototypeOf(TagsModule.prototype).init
608
+ Object.getPrototypeOf(TagsModule.prototype).init = mock.fn(async function () {})
609
+ await instance.init()
610
+ Object.getPrototypeOf(TagsModule.prototype).init = origSuperInit
611
+ assert.equal(mockJsonschema.registerSchemasHook.tap.mock.calls.length, 1)
612
+ })
613
+ })
614
+ })