adapt-authoring-content 2.1.1 → 2.1.2

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.
@@ -6,6 +6,14 @@
6
6
  "description": "Specified item is not a valid content item Invalid parent itemparent",
7
7
  "statusCode": 500
8
8
  },
9
+ "DUPL_FRIENDLY_ID": {
10
+ "data": {
11
+ "_friendlyId": "The duplicate friendly ID",
12
+ "_courseId": "The course ID"
13
+ },
14
+ "description": "A content item with this _friendlyId already exists in this course",
15
+ "statusCode": 409
16
+ },
9
17
  "UNKNOWN_SCHEMA_NAME": {
10
18
  "data": {
11
19
  "_id": "The database _id",
@@ -50,6 +50,10 @@ class ContentModule extends AbstractApiModule {
50
50
  await this.registerConfigSchemas()
51
51
 
52
52
  await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
53
+ await mongodb.setIndex(this.collectionName, { _courseId: 1, _friendlyId: 1 }, {
54
+ unique: true,
55
+ partialFilterExpression: { _friendlyId: { $type: 'string' } }
56
+ })
53
57
  }
54
58
 
55
59
  /** @override */
@@ -109,7 +113,15 @@ class ContentModule extends AbstractApiModule {
109
113
 
110
114
  /** @override */
111
115
  async insert (data, options = {}, mongoOptions = {}) {
112
- const doc = await super.insert(data, options, mongoOptions)
116
+ let doc
117
+ try {
118
+ doc = await super.insert(data, options, mongoOptions)
119
+ } catch (e) {
120
+ if (e.code === this.app.errors.MONGO_DUPL_INDEX?.code) {
121
+ throw this.app.errors.DUPL_FRIENDLY_ID.setData({ _friendlyId: data._friendlyId, _courseId: data._courseId })
122
+ }
123
+ throw e
124
+ }
113
125
 
114
126
  if (doc._type === 'course') { // add the _courseId to a new course to make querying easier
115
127
  return this.update({ _id: doc._id }, { _courseId: doc._id.toString() })
@@ -123,7 +135,15 @@ class ContentModule extends AbstractApiModule {
123
135
 
124
136
  /** @override */
125
137
  async update (query, data, options, mongoOptions) {
126
- const doc = await super.update(query, data, options, mongoOptions)
138
+ let doc
139
+ try {
140
+ doc = await super.update(query, data, options, mongoOptions)
141
+ } catch (e) {
142
+ if (e.code === this.app.errors.MONGO_DUPL_INDEX?.code) {
143
+ throw this.app.errors.DUPL_FRIENDLY_ID.setData({ _friendlyId: data._friendlyId, _courseId: data._courseId })
144
+ }
145
+ throw e
146
+ }
127
147
  await Promise.all([
128
148
  this.updateSortOrder(doc, data),
129
149
  this.updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
@@ -232,6 +252,7 @@ class ContentModule extends AbstractApiModule {
232
252
  ...originalDoc,
233
253
  _id: undefined,
234
254
  _trackingId: undefined,
255
+ _friendlyId: originalDoc._type !== 'course' ? undefined : originalDoc._friendlyId,
235
256
  _courseId: parent?._type === 'course' ? parent?._id : parent?._courseId,
236
257
  _parentId,
237
258
  createdBy: userId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
@@ -30,7 +30,9 @@ function createMockApp () {
30
30
  errors: {
31
31
  NOT_FOUND: createMockError('NOT_FOUND'),
32
32
  INVALID_PARENT: createMockError('INVALID_PARENT'),
33
- UNKNOWN_SCHEMA_NAME: createMockError('UNKNOWN_SCHEMA_NAME')
33
+ UNKNOWN_SCHEMA_NAME: createMockError('UNKNOWN_SCHEMA_NAME'),
34
+ MONGO_DUPL_INDEX: createMockError('MONGO_DUPL_INDEX'),
35
+ DUPL_FRIENDLY_ID: createMockError('DUPL_FRIENDLY_ID')
34
36
  },
35
37
  waitForModule: mock.fn(async () => ({})),
36
38
  config: { get: mock.fn(() => 10) }
@@ -1686,6 +1688,141 @@ describe('ContentModule', () => {
1686
1688
  // Bug fixes
1687
1689
  // -----------------------------------------------------------------------
1688
1690
  describe('bug fixes', () => {
1691
+ it('insert should catch MONGO_DUPL_INDEX and throw DUPL_FRIENDLY_ID', async () => {
1692
+ const duplError = createMockError('MONGO_DUPL_INDEX')
1693
+ const inst = createInstance()
1694
+ const superInsert = mock.fn(async () => { throw duplError })
1695
+
1696
+ const origProto = Object.getPrototypeOf(ContentModule.prototype)
1697
+ const origInsert = origProto.insert
1698
+ origProto.insert = superInsert
1699
+ try {
1700
+ await assert.rejects(
1701
+ () => ContentModule.prototype.insert.call(inst, { _friendlyId: 'fid-1', _courseId: 'c1', _type: 'article' }),
1702
+ (err) => {
1703
+ assert.equal(err.code, 'DUPL_FRIENDLY_ID')
1704
+ assert.equal(err.data._friendlyId, 'fid-1')
1705
+ assert.equal(err.data._courseId, 'c1')
1706
+ return true
1707
+ }
1708
+ )
1709
+ } finally {
1710
+ origProto.insert = origInsert
1711
+ }
1712
+ })
1713
+
1714
+ it('insert should re-throw non-duplicate errors unchanged', async () => {
1715
+ const otherError = new Error('SOME_OTHER_ERROR')
1716
+ otherError.code = 'SOME_OTHER_ERROR'
1717
+ const inst = createInstance()
1718
+
1719
+ const origProto = Object.getPrototypeOf(ContentModule.prototype)
1720
+ const origInsert = origProto.insert
1721
+ origProto.insert = mock.fn(async () => { throw otherError })
1722
+ try {
1723
+ await assert.rejects(
1724
+ () => ContentModule.prototype.insert.call(inst, { _type: 'article' }),
1725
+ (err) => {
1726
+ assert.equal(err.code, 'SOME_OTHER_ERROR')
1727
+ return true
1728
+ }
1729
+ )
1730
+ } finally {
1731
+ origProto.insert = origInsert
1732
+ }
1733
+ })
1734
+
1735
+ it('update should catch MONGO_DUPL_INDEX and throw DUPL_FRIENDLY_ID', async () => {
1736
+ const duplError = createMockError('MONGO_DUPL_INDEX')
1737
+ const inst = createInstance()
1738
+
1739
+ const origProto = Object.getPrototypeOf(ContentModule.prototype)
1740
+ const origUpdate = origProto.update
1741
+ origProto.update = mock.fn(async () => { throw duplError })
1742
+ try {
1743
+ await assert.rejects(
1744
+ () => ContentModule.prototype.update.call(inst, { _id: 'x' }, { _friendlyId: 'fid-1', _courseId: 'c1' }),
1745
+ (err) => {
1746
+ assert.equal(err.code, 'DUPL_FRIENDLY_ID')
1747
+ assert.equal(err.data._friendlyId, 'fid-1')
1748
+ assert.equal(err.data._courseId, 'c1')
1749
+ return true
1750
+ }
1751
+ )
1752
+ } finally {
1753
+ origProto.update = origUpdate
1754
+ }
1755
+ })
1756
+
1757
+ it('update should re-throw non-duplicate errors unchanged', async () => {
1758
+ const otherError = new Error('SOME_OTHER_ERROR')
1759
+ otherError.code = 'SOME_OTHER_ERROR'
1760
+ const inst = createInstance()
1761
+
1762
+ const origProto = Object.getPrototypeOf(ContentModule.prototype)
1763
+ const origUpdate = origProto.update
1764
+ origProto.update = mock.fn(async () => { throw otherError })
1765
+ try {
1766
+ await assert.rejects(
1767
+ () => ContentModule.prototype.update.call(inst, { _id: 'x' }, { title: 'Updated' }),
1768
+ (err) => {
1769
+ assert.equal(err.code, 'SOME_OTHER_ERROR')
1770
+ return true
1771
+ }
1772
+ )
1773
+ } finally {
1774
+ origProto.update = origUpdate
1775
+ }
1776
+ })
1777
+
1778
+ it('clone should clear _friendlyId for non-course types', async () => {
1779
+ let findCallCount = 0
1780
+ const insertFn = mock.fn(async (data, opts) => ({
1781
+ ...data,
1782
+ _id: 'new-id'
1783
+ }))
1784
+ const inst = createInstance({
1785
+ find: mock.fn(async () => {
1786
+ findCallCount++
1787
+ if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1', _friendlyId: 'art-1' }]
1788
+ if (findCallCount === 2) return [{ _id: 'p', _type: 'page', _courseId: 'c1' }]
1789
+ return []
1790
+ }),
1791
+ insert: insertFn,
1792
+ preCloneHook: createMockHook(),
1793
+ postCloneHook: createMockHook()
1794
+ })
1795
+
1796
+ await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'p')
1797
+
1798
+ const payload = insertFn.mock.calls[0].arguments[0]
1799
+ assert.equal(payload._friendlyId, undefined)
1800
+ })
1801
+
1802
+ it('clone should preserve _friendlyId for course types', async () => {
1803
+ let findCallCount = 0
1804
+ const insertFn = mock.fn(async (data, opts) => ({
1805
+ ...data,
1806
+ _id: 'new-course-id'
1807
+ }))
1808
+ const inst = createInstance({
1809
+ find: mock.fn(async () => {
1810
+ findCallCount++
1811
+ if (findCallCount === 1) return [{ _id: 'c1', _type: 'course', _courseId: 'c1', _friendlyId: 'course-1' }]
1812
+ return []
1813
+ }),
1814
+ insert: insertFn,
1815
+ update: mock.fn(async () => ({})),
1816
+ preCloneHook: createMockHook(),
1817
+ postCloneHook: createMockHook()
1818
+ })
1819
+
1820
+ await ContentModule.prototype.clone.call(inst, 'user1', 'c1', undefined)
1821
+
1822
+ const payload = insertFn.mock.calls[0].arguments[0]
1823
+ assert.equal(payload._friendlyId, 'course-1')
1824
+ })
1825
+
1689
1826
  it('should handle clone of course when no config exists', async () => {
1690
1827
  let findCallCount = 0
1691
1828
  const inst = createInstance({