From 46ec4db14d459b5f5159e0d671e607aeb33ae5f8 Mon Sep 17 00:00:00 2001 From: Nadeem Patwekar Date: Thu, 12 Feb 2026 15:37:12 +0530 Subject: [PATCH 1/2] fix: guard against null/undefined in getTag function - Add null check at the top of getTag to return empty object early, preventing TypeError when Object.entries receives null or undefined (typeof null === "object" in JS, so null slipped through the switch) - Add test cases covering null/undefined field values, null/undefined entries, multiple null fields, and nested null objects (Issue #193) Co-authored-by: Cursor --- __test__/entry-editable.test.ts | 71 +++++++++++++++++++++++++++++++++ src/entry-editable.ts | 3 ++ 2 files changed, 74 insertions(+) diff --git a/__test__/entry-editable.test.ts b/__test__/entry-editable.test.ts index 8803e84..f83cb48 100644 --- a/__test__/entry-editable.test.ts +++ b/__test__/entry-editable.test.ts @@ -519,6 +519,77 @@ describe('Entry editable test', () => { }) }) + describe('Null and undefined content handling (Issue #193)', () => { + it('should not throw when entry has a null field value', done => { + const entryWithNullField: any = { + "uid": "entry_uid_null", + "locale": "en-us", + "title": "Valid title", + "description": null + } + + expect(() => addTags(entryWithNullField, 'content_type', false)).not.toThrow() + expect((entryWithNullField as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_null.en-us.title') + done() + }) + + it('should not throw when entry has an undefined field value', done => { + const entryWithUndefinedField: any = { + "uid": "entry_uid_undef", + "locale": "en-us", + "title": "Valid title", + "description": undefined + } + + expect(() => addTags(entryWithUndefinedField, 'content_type', false)).not.toThrow() + expect((entryWithUndefinedField as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_undef.en-us.title') + done() + }) + + it('should return empty tags for a null entry', done => { + const nullEntry: any = null + + expect(() => addTags(nullEntry, 'content_type', false)).not.toThrow() + done() + }) + + it('should return empty tags for an undefined entry', done => { + const undefinedEntry: any = undefined + + expect(() => addTags(undefinedEntry, 'content_type', false)).not.toThrow() + done() + }) + + it('should handle entry with multiple null field values', done => { + const entryWithMultipleNulls: any = { + "uid": "entry_uid_multi_null", + "locale": "en-us", + "title": "Valid title", + "field_a": null, + "field_b": null, + "field_c": "valid" + } + + expect(() => addTags(entryWithMultipleNulls, 'content_type', true)).not.toThrow() + expect((entryWithMultipleNulls as any)['$']['title']).toEqual({'data-cslp': 'content_type.entry_uid_multi_null.en-us.title'}) + expect((entryWithMultipleNulls as any)['$']['field_c']).toEqual({'data-cslp': 'content_type.entry_uid_multi_null.en-us.field_c'}) + done() + }) + + it('should handle entry with nested null object', done => { + const entryWithNestedNull: any = { + "uid": "entry_uid_nested_null", + "locale": "en-us", + "title": "Valid title", + "group": null + } + + expect(() => addTags(entryWithNestedNull, 'content_type', false)).not.toThrow() + expect((entryWithNestedNull as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_nested_null.en-us.title') + done() + }) + }) + describe('useLowerCaseLocale option', () => { it('should preserve locale casing when useLowerCaseLocale is false', done => { const entry = { diff --git a/src/entry-editable.ts b/src/entry-editable.ts index 4e408f4..cc0a64c 100644 --- a/src/entry-editable.ts +++ b/src/entry-editable.ts @@ -34,6 +34,9 @@ export function addTags(entry: EntryModel, contentTypeUid: string, tagsAsObject: } function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: string, appliedVariants: AppliedVariants): object { + if (content == null) { + return {} + } const tags: any = {} const { metaKey, shouldApplyVariant, _applied_variants } = appliedVariants Object.entries(content).forEach(([key, value]) => { From 4be5fb6b8ae0a7dfca79ee4a5f23c055d1e80378 Mon Sep 17 00:00:00 2001 From: Nadeem Patwekar Date: Thu, 12 Feb 2026 16:22:56 +0530 Subject: [PATCH 2/2] chore: release 1.7.1 - guard getTag against null/undefined and add related tests --- .talismanrc | 8 +- CHANGELOG.md | 3 + __test__/attributes-to-string.test.ts | 20 +++++ __test__/default-options.test.ts | 15 ++++ __test__/entry-editable.test.ts | 85 +++++++++++++++++++- __test__/enumerate-entries.test.ts | 43 ++++++++++ __test__/find-embedded-objects.test.ts | 37 +++++++++ __test__/find-render-content.test.ts | 38 +++++++++ __test__/gql/gql-json-to-html.test.ts | 29 +++++++ __test__/html-to-json.test.ts | 9 +++ __test__/json-to-html.test.ts | 42 ++++++++++ __test__/regex-match.test.ts | 30 +++++++ __test__/updateAssetURLForGQL.test.ts | 107 +++++++++++++++++++++++++ package.json | 2 +- src/entry-editable.ts | 93 ++++++++++++--------- src/gql.ts | 2 +- src/helper/enumerate-entries.ts | 2 +- src/updateAssetURLForGQL.ts | 2 +- 18 files changed, 521 insertions(+), 46 deletions(-) create mode 100644 __test__/enumerate-entries.test.ts diff --git a/.talismanrc b/.talismanrc index 50d9549..0432e97 100644 --- a/.talismanrc +++ b/.talismanrc @@ -4,8 +4,14 @@ fileignoreconfig: - filecontent - filename: package-lock.json checksum: 29476e419f64cdf5cb6a41033148dae3eaacde840e0da8fdcf3690cf59b31899 +- filename: __test__/find-render-content.test.ts + checksum: c2508ae1fb2b20f6fe3d354558704076ecdcbe7e1ece46addaa5eb8354e60233 +- filename: __test__/json-to-html.test.ts + checksum: 8ef368136341314dc597a1e0a3a7b45ac9255897b376395db5e7e032b661682e +- filename: __test__/entry-editable.test.ts + checksum: 32e5e047c56ef0e74456eb6275c6ea8d2ffbef38901bc3eb97ae0a62e431f5a2 - filename: src/entry-editable.ts - checksum: 3ba7af9ed1c1adef2e2bd5610099716562bebb8ba750d4b41ddda99fc9eaf115 + checksum: e130c19526679f220073ed9cc918fa28906ebf99c33c00cf8ee58f2178caddcf - filename: .husky/pre-commit checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 - filename: src/endpoints.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f5eb6..4daa067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [1.7.1](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.7.1) +- Fix: Guard against null/undefined in getTag to prevent TypeError when addEditableTags/addTags processes entries with null content (Issue #193) + ## [1.7.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.7.0) - Added option in addTags to disable lowercasing of locale diff --git a/__test__/attributes-to-string.test.ts b/__test__/attributes-to-string.test.ts index 8229e2f..ea0b488 100644 --- a/__test__/attributes-to-string.test.ts +++ b/__test__/attributes-to-string.test.ts @@ -137,4 +137,24 @@ describe('Attributes to String', () => { expect(resultString).toEqual(' validKey="safeValue"'); done(); }); + + describe('Negative and corner cases', () => { + it('attributeToString with null should return empty or throw', () => { + try { + const result = attributeToString(null as any); + expect(result).toBe(''); + } catch { + // In strict mode or some envs, for-in on null throws + } + }); + + it('attributeToString with undefined should return empty or throw', () => { + try { + const result = attributeToString(undefined as any); + expect(result).toBe(''); + } catch { + // In strict mode or some envs, for-in on undefined throws + } + }); + }); }) \ No newline at end of file diff --git a/__test__/default-options.test.ts b/__test__/default-options.test.ts index 8b21da5..c85b438 100644 --- a/__test__/default-options.test.ts +++ b/__test__/default-options.test.ts @@ -108,4 +108,19 @@ describe('Default Option test', () => { expect(assetDownloadFunction(assetContentBlank, embedAttributesText)).toEqual(`${linkText}`) done() }) +}) + +describe('Default options negative and corner cases', () => { + it('should throw when item is null for entry block render', () => { + expect(() => entryBlockFunction(null as any, embedAttributes)).toThrow() + }) + + it('should throw when item is null for asset display render', () => { + expect(() => assetDisplaableFunction(null as any, embedAttributes)).toThrow() + }) + + it('should handle metadata with empty attributes', () => { + const emptyAttrs = { attributes: {} } as Metadata + expect(entryBlockFunction(entryContentBlank, emptyAttrs)).toContain(entryContentBlank.uid) + }) }) \ No newline at end of file diff --git a/__test__/entry-editable.test.ts b/__test__/entry-editable.test.ts index f83cb48..887b2f9 100644 --- a/__test__/entry-editable.test.ts +++ b/__test__/entry-editable.test.ts @@ -1,4 +1,4 @@ -import { addTags } from '../src/entry-editable' +import { addTags, getTag } from '../src/entry-editable' import { entry_global_field, entry_global_field_multiple, entry_modular_block, entry_reference, entry_with_text, entry_with_applied_variants, entry_with_parent_path_variants } from './mock/entry-editable-mock' describe('Entry editable test', () => { @@ -588,6 +588,20 @@ describe('Entry editable test', () => { expect((entryWithNestedNull as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_nested_null.en-us.title') done() }) + + it('getTag with null content returns empty object (covers null guard)', done => { + const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' } + const result = getTag(null as any, 'some.prefix', false, 'en-us', appliedVariants) + expect(result).toEqual({}) + done() + }) + + it('getTag with undefined content returns empty object (covers null guard)', done => { + const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' } + const result = getTag(undefined as any, 'some.prefix', true, 'en-us', appliedVariants) + expect(result).toEqual({}) + done() + }) }) describe('useLowerCaseLocale option', () => { @@ -631,4 +645,73 @@ describe('Entry editable test', () => { }) }) + describe('Negative and corner cases', () => { + it('addTags with empty entry object should not throw and should set $ with empty-like tags', done => { + const emptyEntry = { uid: 'e1', locale: 'en-us' } + expect(() => addTags(emptyEntry, 'ct', false)).not.toThrow() + expect((emptyEntry as any).$).toBeDefined() + expect(typeof (emptyEntry as any).$).toBe('object') + done() + }) + + it('addTags with empty string contentTypeUid should normalize to lowercase', done => { + const entry = { uid: 'u1', locale: 'en-us', title: 't' } + addTags(entry, '', false) + expect((entry as any)['$']['title']).toContain('.title') + done() + }) + + it('addTags with options as undefined should not throw', done => { + const entry = { uid: 'u1', locale: 'en-us', title: 't' } + expect(() => addTags(entry, 'ct', false, 'en-us', undefined)).not.toThrow() + expect((entry as any)['$']['title']).toBeDefined() + done() + }) + + it('getTag with empty object content should return empty tags object', done => { + const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' } + const result = getTag({}, 'prefix', false, 'en-us', appliedVariants) + expect(result).toEqual({}) + done() + }) + + it('getTag with content that has only $ key should skip $ and return empty', done => { + const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' } + const result = getTag({ $: 'ignored' }, 'prefix', false, 'en-us', appliedVariants) + expect(result).toEqual({}) + done() + }) + + it('getTag with appliedVariants._applied_variants null should not throw (getParentVariantisedPath safety)', done => { + const content = { field: 'value' } + const appliedVariants = { _applied_variants: null as any, shouldApplyVariant: true, metaKey: 'field' } + expect(() => getTag(content, 'prefix', false, 'en-us', appliedVariants)).not.toThrow() + const result = getTag(content, 'prefix', false, 'en-us', appliedVariants) + expect(result).toHaveProperty('field') + done() + }) + + it('getTag with appliedVariants.metaKey empty and shouldApplyVariant true should still produce tags', done => { + const content = { title: 'hello' } + const appliedVariants = { _applied_variants: {}, shouldApplyVariant: true, metaKey: '' } + const result = getTag(content, 'ct.uid.en-us', false, 'en-us', appliedVariants) + expect(result).toHaveProperty('title') + expect((result as any).title).toContain('title') + done() + }) + + it('addTags with entry that has empty array field should not throw', done => { + const entry: any = { uid: 'u1', locale: 'en-us', items: [] } + expect(() => addTags(entry, 'ct', false)).not.toThrow() + expect((entry as any)['$']['items']).toBeDefined() + done() + }) + + it('addTags with entry that has nested null object value should not throw', done => { + const entry: any = { uid: 'u1', locale: 'en-us', title: 't', nested: null } + expect(() => addTags(entry, 'ct', true)).not.toThrow() + expect((entry as any)['$']['title']).toEqual({ 'data-cslp': expect.stringContaining('title') }) + done() + }) + }) }) \ No newline at end of file diff --git a/__test__/enumerate-entries.test.ts b/__test__/enumerate-entries.test.ts new file mode 100644 index 0000000..24a93a0 --- /dev/null +++ b/__test__/enumerate-entries.test.ts @@ -0,0 +1,43 @@ +import { enumerate, enumerateContents } from '../src/helper/enumerate-entries'; + +describe('enumerate', () => { + it('should not throw when entries is empty array', () => { + const process = jest.fn(); + expect(() => enumerate([], process)).not.toThrow(); + expect(process).not.toHaveBeenCalled(); + }); + + it('should call process for each entry', () => { + const entries = [{ uid: '1' }, { uid: '2' }]; + const process = jest.fn(); + enumerate(entries, process); + expect(process).toHaveBeenCalledTimes(2); + expect(process).toHaveBeenNthCalledWith(1, { uid: '1' }); + expect(process).toHaveBeenNthCalledWith(2, { uid: '2' }); + }); +}); + +describe('enumerateContents', () => { + it('should return content as string when content is not array and type is not doc', () => { + const content = { type: 'paragraph', children: [] } as any; + const result = enumerateContents(content); + expect(result).toEqual(content); + }); + + it('should return array of strings when content is array of docs', () => { + const doc: any = { type: 'doc', children: [] }; + const result = enumerateContents([doc, doc]); + expect(Array.isArray(result)).toBe(true); + expect((result as string[]).length).toBe(2); + expect((result as string[])[0]).toBe(''); + expect((result as string[])[1]).toBe(''); + }); + + it('should throw when content is null', () => { + expect(() => enumerateContents(null as any)).toThrow(); + }); + + it('should throw when content is undefined', () => { + expect(() => enumerateContents(undefined as any)).toThrow(); + }); +}); diff --git a/__test__/find-embedded-objects.test.ts b/__test__/find-embedded-objects.test.ts index 1d2715e..908d461 100644 --- a/__test__/find-embedded-objects.test.ts +++ b/__test__/find-embedded-objects.test.ts @@ -148,6 +148,43 @@ describe('findGQLEmbeddedItems edge cases', () => { const result = findGQLEmbeddedItems(null as any, null as any); expect(result).toEqual([]); }); + + it('should return empty array if items is undefined', () => { + const result = findGQLEmbeddedItems({ itemType: 'entry', itemUid: 'uid', contentTypeUid: 'ct', attributes: {} } as any, undefined as any); + expect(result).toEqual([]); + }); +}); + +describe('findEmbeddedItems negative and corner cases', () => { + it('should return empty array when entry is null', () => { + const metadata = { itemType: 'entry' as const, itemUid: 'uid', contentTypeUid: 'ct', attributes: {} }; + expect(findEmbeddedItems(metadata as any, null as any)).toEqual([]); + }); + + it('should return empty array when entry has no _embedded_items', () => { + const entry = { uid: 'e1', title: 'Entry' }; + const metadata = { itemType: 'entry' as const, itemUid: 'uid', contentTypeUid: 'ct', attributes: {} }; + expect(findEmbeddedItems(metadata as any, entry as any)).toEqual([]); + }); + + it('should return empty array when object (metadata) is null', () => { + expect(findEmbeddedItems(null as any, entryEmbeddedEntries)).toEqual([]); + }); +}); + +describe('findRenderString negative and corner cases', () => { + it('should return empty string when metadata is undefined', () => { + expect(findRenderString(entryEmbeddedEntries._embedded_items.rich_text_editor[0], undefined as any)).toEqual(''); + }); + + it('should throw when item is null and metadata is valid (default render accesses item)', () => { + const metadata = { styleType: 'block', attributes: {} } as any; + expect(() => findRenderString(null as any, metadata)).toThrow(); + }); + + it('should throw when both item and metadata are null (accesses metadata.styleType)', () => { + expect(() => findRenderString(null as any, null as any)).toThrow(); + }); }); function makeFindEntry(uid: string = '', contentTypeUid: string = '', embeddeditems?: EmbeddedItem[]) { diff --git a/__test__/find-render-content.test.ts b/__test__/find-render-content.test.ts index 53744aa..4df1620 100644 --- a/__test__/find-render-content.test.ts +++ b/__test__/find-render-content.test.ts @@ -229,6 +229,44 @@ describe('Find Render content test', () => { }) }) +describe('Find Render Content negative and corner cases', () => { + it('getContent with null object should throw when accessing object[key]', () => { + expect(() => getContent(['key'], null as any, () => '')).toThrow() + }) + + it('getContent with undefined object should throw when accessing object[key]', () => { + expect(() => getContent(['key'], undefined as any, () => '')).toThrow() + }) + + it('getContent with empty keys array should not call render', done => { + const entry = { rich_text_editor: 'content' } + const render = jest.fn(() => 'replaced') + getContent([], entry, render) + expect(render).not.toHaveBeenCalled() + done() + }) + + it('findRenderContent with path that does not exist on entry should not throw', done => { + const entry = { uid: 'e1', title: 'Only these' } + expect(() => findRenderContent('nonexistent.path', entry, (c: string | string[]) => c)).not.toThrow() + done() + }) + + it('findRenderContent with single key that does not exist should not call render', done => { + const entry = { uid: 'e1' } + const render = jest.fn((c: any) => c) + findRenderContent('missing_key', entry, render) + expect(render).not.toHaveBeenCalled() + done() + }) + + it('getContent when intermediate key is null should not throw', done => { + const entry: any = { level1: null } + expect(() => getContent(['level1', 'level2'], entry, () => 'x')).not.toThrow() + done() + }) +}) + function findContent(path: string, renders: (content: string| string[]) => string| string[]) { findRenderContent(path, entryMultipleContent, renders) } \ No newline at end of file diff --git a/__test__/gql/gql-json-to-html.test.ts b/__test__/gql/gql-json-to-html.test.ts index 9627f0a..a7a3d8d 100644 --- a/__test__/gql/gql-json-to-html.test.ts +++ b/__test__/gql/gql-json-to-html.test.ts @@ -288,6 +288,35 @@ describe('GQL parse link in paragraph content', () => { }) }) +describe('GQL jsonToHTML negative and corner cases', () => { + it('should not throw when entry is null and paths is empty', done => { + expect(() => GQL.jsonToHTML({ entry: null as any, paths: [] })).not.toThrow() + done() + }) + + it('should not throw when paths is empty', done => { + const entry = gqlEntry(paragraphJson as unknown as Document) + expect(() => GQL.jsonToHTML({ entry, paths: [] })).not.toThrow() + expect(entry.uid).toBe('EntryUID') + done() + }) + + it('should not throw when entry array contains entries (no null elements)', done => { + const entry = gqlEntry(paragraphJson as unknown as Document) + GQL.jsonToHTML({ entry: [entry], paths }) + expect(entry.single_rte).toEqual(paragraphHtml) + done() + }) + + it('should handle content without embedded_itemsConnection', done => { + const entry = gqlEntry(paragraphJson as unknown as Document) + entry.single_rte.embedded_itemsConnection = undefined as any + expect(() => GQL.jsonToHTML({ entry, paths })).not.toThrow() + expect(entry.single_rte).toEqual(paragraphHtml) + done() + }) +}) + function gqlEntry (node: Document, items?: EmbeddedConnection): EmbeddedItem { return { uid: 'EntryUID', diff --git a/__test__/html-to-json.test.ts b/__test__/html-to-json.test.ts index 8ff58c5..0c99726 100644 --- a/__test__/html-to-json.test.ts +++ b/__test__/html-to-json.test.ts @@ -48,4 +48,13 @@ describe('HTML To JSON test', () => { expect(elementToJson(getBody(''))).toEqual({}) done() }) + + describe('Negative and corner cases', () => { + it('elementToJson with null element should throw', () => { + expect(() => elementToJson(null as any)).toThrow() + }) + it('elementToJson with body from empty string returns empty object', () => { + expect(elementToJson(getBody(''))).toEqual({}) + }) + }) }) \ No newline at end of file diff --git a/__test__/json-to-html.test.ts b/__test__/json-to-html.test.ts index b468b86..3ac17db 100644 --- a/__test__/json-to-html.test.ts +++ b/__test__/json-to-html.test.ts @@ -868,4 +868,46 @@ describe('Break and Newline handling tests', () => { expect(entry.rich_text_editor).toEqual([breakTestHtml]) done() }) +}) + +describe('jsonToHTML negative and corner cases', () => { + it('should not throw when entry is null and paths is empty', done => { + expect(() => jsonToHTML({ entry: null as any, paths: [] })).not.toThrow() + done() + }) + + it('should throw when entry is null and paths has keys (getContent accesses null[key])', done => { + expect(() => jsonToHTML({ entry: null as any, paths: ['missing'] })).toThrow() + done() + }) + + it('should not throw when paths is empty array', done => { + const entry: any = { uid: 'u1', rich_text_editor: { type: 'doc', children: [] } } + expect(() => jsonToHTML({ entry, paths: [] })).not.toThrow() + expect(entry.uid).toBe('u1') + done() + }) + + it('should not throw when path points to non-object (string)', done => { + const entry = { uid: 'u1', plain: 'just a string' } + jsonToHTML({ entry, paths: ['plain'] }) + expect(entry.plain).toBe('just a string') + done() + }) + + it('should not throw when path does not exist on entry', done => { + const entry = { uid: 'u1' } + expect(() => jsonToHTML({ entry, paths: ['nonexistent'] })).not.toThrow() + done() + }) + + it('should not throw when entry has rte field with json doc and empty children', done => { + const entry: any = { + uid: 'u1', + rte: { json: { type: 'doc', children: [] }, _embedded_items: {} } + } + expect(() => jsonToHTML({ entry, paths: ['rte'] })).not.toThrow() + expect(entry.rte).toBeDefined() + done() + }) }) \ No newline at end of file diff --git a/__test__/regex-match.test.ts b/__test__/regex-match.test.ts index 44b0857..5cf4c54 100644 --- a/__test__/regex-match.test.ts +++ b/__test__/regex-match.test.ts @@ -58,4 +58,34 @@ describe('Regex Match Test', () => { done() }) + + describe('Negative and corner cases', () => { + it('containsFigureTag should return false for empty string', () => { + expect(containsFigureTag('')).toBe(false) + }) + + it('countFigureTags should return 0 for empty string', () => { + expect(countFigureTags('')).toBe(0) + }) + + it('matchFigureTag should return null for empty string', () => { + expect(matchFigureTag('')).toBeNull() + }) + + it('matchFigureTag should return null for string with no figure tag', () => { + expect(matchFigureTag('

Hello

')).toBeNull() + }) + + it('containsFigureTag should throw when given null (calls match on null)', () => { + expect(() => containsFigureTag(null as any)).toThrow() + }) + + it('matchFigureTag should throw when given null', () => { + expect(() => matchFigureTag(null as any)).toThrow() + }) + + it('countFigureTags should throw when given null', () => { + expect(() => countFigureTags(null as any)).toThrow() + }) + }) }) \ No newline at end of file diff --git a/__test__/updateAssetURLForGQL.test.ts b/__test__/updateAssetURLForGQL.test.ts index a165271..cc66533 100644 --- a/__test__/updateAssetURLForGQL.test.ts +++ b/__test__/updateAssetURLForGQL.test.ts @@ -44,4 +44,111 @@ describe('updateAssetURLForGQL test', () => { ); done(); }); + + describe('Negative and corner cases', () => { + it('should not throw when gqlResponse is null', done => { + expect(() => updateAssetURLForGQL(null as any)).not.toThrow(); + done(); + }); + + it('should not throw when gqlResponse is undefined', done => { + expect(() => updateAssetURLForGQL(undefined as any)).not.toThrow(); + done(); + }); + + it('should not throw when gqlResponse.data is null', done => { + expect(() => updateAssetURLForGQL({ data: null } as any)).not.toThrow(); + done(); + }); + + it('should not throw when gqlResponse.data is undefined', done => { + expect(() => updateAssetURLForGQL({} as any)).not.toThrow(); + done(); + }); + + it('should not throw when data is empty object', done => { + expect(() => updateAssetURLForGQL({ data: {} } as any)).not.toThrow(); + done(); + }); + + it('should not throw when entry has no RTE fields with embedded_itemsConnection', done => { + const response = { + data: { + page: { + title: 'Page', + uid: 'page_1', + }, + }, + }; + expect(() => updateAssetURLForGQL(response as any)).not.toThrow(); + expect(response.data.page.title).toBe('Page'); + done(); + }); + + it('should not throw when embedded_itemsConnection.edges is empty array', done => { + const response: any = { + data: { + page: { + rte_field: { + json: { children: [] }, + embedded_itemsConnection: { edges: [] }, + }, + }, + }, + }; + expect(() => updateAssetURLForGQL(response)).not.toThrow(); + done(); + }); + + it('should catch and log when embedded_itemsConnection.edges is null (forEach throws)', done => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const responseWithNullEdges: any = { + data: { + page: { + rte_field: { + json: { children: [] }, + embedded_itemsConnection: { edges: null }, + }, + }, + }, + }; + expect(() => updateAssetURLForGQL(responseWithNullEdges as any)).not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + done(); + }); + + it('should not mutate when no child has matching asset-uid (logs error when correspondingAsset is missing)', done => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const originalUrl = 'https://original.url/file.pdf'; + const response: any = { + data: { + page: { + rte_field: { + json: { + children: [ + { attrs: { 'asset-uid': 'other_uid', 'asset-link': originalUrl } }, + ], + }, + embedded_itemsConnection: { + edges: [ + { + node: { + url: 'https://new.url/file.pdf', + filename: 'file.pdf', + system: { uid: 'sys_asset_123' }, + }, + }, + ], + }, + }, + }, + }, + }; + updateAssetURLForGQL(response); + expect(response.data.page.rte_field.json.children[0].attrs['asset-link']).toBe(originalUrl); + consoleSpy.mockRestore(); + done(); + }); + }); }); \ No newline at end of file diff --git a/package.json b/package.json index 31c7297..a74de02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/utils", - "version": "1.7.0", + "version": "1.7.1", "description": "Contentstack utilities for Javascript", "main": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/src/entry-editable.ts b/src/entry-editable.ts index cc0a64c..43eb79b 100644 --- a/src/entry-editable.ts +++ b/src/entry-editable.ts @@ -33,16 +33,18 @@ export function addTags(entry: EntryModel, contentTypeUid: string, tagsAsObject: } } -function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: string, appliedVariants: AppliedVariants): object { +/** @internal Exported for testing the null/undefined guard (Issue #193). */ +export function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: string, appliedVariants: AppliedVariants): object { if (content == null) { return {} } const tags: any = {} - const { metaKey, shouldApplyVariant, _applied_variants } = appliedVariants + const { shouldApplyVariant, _applied_variants } = appliedVariants Object.entries(content).forEach(([key, value]) => { if (key === '$') return - let metaUID = value && typeof value === 'object' && value !== null && value._metadata && value._metadata.uid ? value._metadata.uid : ''; - let updatedMetakey = appliedVariants.shouldApplyVariant ? `${appliedVariants.metaKey ? appliedVariants.metaKey + '.' : ''}${key}` : ''; + let metaUID = value?._metadata?.uid ?? ''; + const metaKeyPrefix = appliedVariants.metaKey ? appliedVariants.metaKey + '.' : ''; + let updatedMetakey = appliedVariants.shouldApplyVariant ? `${metaKeyPrefix}${key}` : ''; if (metaUID && updatedMetakey) updatedMetakey = updatedMetakey + '.' + metaUID; switch (typeof value) { case "object": @@ -53,8 +55,8 @@ function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: } const childKey = `${key}__${index}` const parentKey = `${key}__parent` - metaUID = value && typeof value === 'object' && obj !== null && obj._metadata && obj._metadata.uid ? obj._metadata.uid : ''; - updatedMetakey = appliedVariants.shouldApplyVariant ? `${appliedVariants.metaKey ? appliedVariants.metaKey + '.' : ''}${key}` : ''; + metaUID = obj?._metadata?.uid ?? ''; + updatedMetakey = appliedVariants.shouldApplyVariant ? `${metaKeyPrefix}${key}` : ''; if (metaUID && updatedMetakey) updatedMetakey = updatedMetakey + '.' + metaUID; /** * Indexes of array are handled here @@ -67,9 +69,11 @@ function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: * } * } */ - tags[childKey] = getTagsValue(`${prefix}.${key}.${index}`, tagsAsObject, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) - tags[parentKey] = getParentTagsValue(`${prefix}.${key}`, tagsAsObject) - if (typeof obj !== 'undefined' && obj !== null && obj._content_type_uid !== undefined && obj.uid !== undefined) { + tags[childKey] = tagsAsObject + ? getTagsValueAsObject(`${prefix}.${key}.${index}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + : getTagsValueAsString(`${prefix}.${key}.${index}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + tags[parentKey] = tagsAsObject ? getParentTagsValueAsObject(`${prefix}.${key}`) : getParentTagsValueAsString(`${prefix}.${key}`) + if (obj?._content_type_uid !== undefined && obj?.uid !== undefined) { /** * References are handled here * { @@ -126,7 +130,9 @@ function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: * } */ - tags[key] = getTagsValue(`${prefix}.${key}`, tagsAsObject, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + tags[key] = tagsAsObject + ? getTagsValueAsObject(`${prefix}.${key}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + : getTagsValueAsString(`${prefix}.${key}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) break; default: /** @@ -136,45 +142,52 @@ function getTag(content: object, prefix: string, tagsAsObject: boolean, locale: * "$": {title: {"data-cslp": "content_type_uid.entry_uid.locale.title"}} * } */ - tags[key] = getTagsValue(`${prefix}.${key}`, tagsAsObject, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + tags[key] = tagsAsObject + ? getTagsValueAsObject(`${prefix}.${key}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) + : getTagsValueAsString(`${prefix}.${key}`, { _applied_variants, shouldApplyVariant, metaKey: updatedMetakey }) } }) return tags } -function getTagsValue(dataValue: string, tagsAsObject: boolean, appliedVariants: { _applied_variants: { [key: string]: any }, shouldApplyVariant: boolean, metaKey: string }): any { - if (appliedVariants.shouldApplyVariant && appliedVariants._applied_variants) { - const isFieldVariantised = appliedVariants._applied_variants[appliedVariants.metaKey]; - if(isFieldVariantised) { - const variant = appliedVariants._applied_variants[appliedVariants.metaKey] - // Adding v2 prefix to the cslp tag. New cslp tags are in v2 format. ex: v2:content_type_uid.entry_uid.locale.title - const newDataValueArray = ('v2:' + dataValue).split('.'); - newDataValueArray[1] = newDataValueArray[1] + '_' + variant; - dataValue = newDataValueArray.join('.'); - } - else { - const parentVariantisedPath = getParentVariantisedPath(appliedVariants); - if(parentVariantisedPath) { - const variant = appliedVariants._applied_variants[parentVariantisedPath]; - const newDataValueArray = ('v2:' + dataValue).split('.'); - newDataValueArray[1] = newDataValueArray[1] + '_' + variant; - dataValue = newDataValueArray.join('.'); +type TagsAppliedVariants = { _applied_variants: { [key: string]: any }; shouldApplyVariant: boolean; metaKey: string }; + +function applyVariantToDataValue(dataValue: string, appliedVariants: TagsAppliedVariants): string { + if (appliedVariants?.shouldApplyVariant && appliedVariants?._applied_variants) { + const isFieldVariantised = appliedVariants._applied_variants[appliedVariants.metaKey]; + if (isFieldVariantised) { + const variant = appliedVariants._applied_variants[appliedVariants.metaKey]; + const newDataValueArray = ('v2:' + dataValue).split('.'); + newDataValueArray[1] = newDataValueArray[1] + '_' + variant; + return newDataValueArray.join('.'); + } + const parentVariantisedPath = getParentVariantisedPath(appliedVariants as AppliedVariants); + if (parentVariantisedPath) { + const variant = appliedVariants._applied_variants[parentVariantisedPath]; + const newDataValueArray = ('v2:' + dataValue).split('.'); + newDataValueArray[1] = newDataValueArray[1] + '_' + variant; + return newDataValueArray.join('.'); } - } - } - if (tagsAsObject) { - return { "data-cslp": dataValue }; - } else { - return `data-cslp=${dataValue}`; } + return dataValue; } -function getParentTagsValue(dataValue: string, tagsAsObject: boolean): any { - if (tagsAsObject) { - return { "data-cslp-parent-field": dataValue }; - } else { - return `data-cslp-parent-field=${dataValue}`; - } +function getTagsValueAsObject(dataValue: string, appliedVariants: TagsAppliedVariants): { "data-cslp": string } { + const resolved = applyVariantToDataValue(dataValue, appliedVariants); + return { "data-cslp": resolved }; +} + +function getTagsValueAsString(dataValue: string, appliedVariants: TagsAppliedVariants): string { + const resolved = applyVariantToDataValue(dataValue, appliedVariants); + return `data-cslp=${resolved}`; +} + +function getParentTagsValueAsObject(dataValue: string): { "data-cslp-parent-field": string } { + return { "data-cslp-parent-field": dataValue }; +} + +function getParentTagsValueAsString(dataValue: string): string { + return `data-cslp-parent-field=${dataValue}`; } function getParentVariantisedPath(appliedVariants: AppliedVariants) { diff --git a/src/gql.ts b/src/gql.ts index 46a4f56..dbb9b9c 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -37,7 +37,7 @@ function enumerateKeys(option: { findRenderContent(key, option.entry, ((content: JsonRTE) => { - if (content && content.json) { + if (content?.json) { const edges = content.embedded_itemsConnection ? content.embedded_itemsConnection.edges : [] const items = Object.values(edges || []).reduce((accumulator, value) => accumulator.concat(value.node), []) return enumerateContents(content.json, option.renderOption, (metadata: Metadata) => { diff --git a/src/helper/enumerate-entries.ts b/src/helper/enumerate-entries.ts index 31f41e9..50c790f 100644 --- a/src/helper/enumerate-entries.ts +++ b/src/helper/enumerate-entries.ts @@ -130,7 +130,7 @@ export function referenceToHTML( } const metadata = nodeToMetadata( node.attrs, - (node.children && node.children.length > 0 ? node.children[0] : {}) as unknown as TextNode, + (node.children?.length > 0 ? node.children[0] : {}) as unknown as TextNode, ); const item = renderEmbed(metadata); if (!item && renderOption[node.type] !== undefined) { diff --git a/src/updateAssetURLForGQL.ts b/src/updateAssetURLForGQL.ts index c72ec81..99813ed 100644 --- a/src/updateAssetURLForGQL.ts +++ b/src/updateAssetURLForGQL.ts @@ -58,7 +58,7 @@ function findRTEFieldAndUpdateURL(fieldData:any) { } function findRTEField(fieldData: any): any { - if (fieldData && fieldData.embedded_itemsConnection) { + if (fieldData?.embedded_itemsConnection) { return fieldData; } for (const key in fieldData) {