Skip to content

Commit e727346

Browse files
authored
Merge pull request #251 from contentstack/fix/DX-3657
fix: guard against null/undefined in getTag function
2 parents 1d60d75 + 4be5fb6 commit e727346

18 files changed

+595
-46
lines changed

.talismanrc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ fileignoreconfig:
44
- filecontent
55
- filename: package-lock.json
66
checksum: 29476e419f64cdf5cb6a41033148dae3eaacde840e0da8fdcf3690cf59b31899
7+
- filename: __test__/find-render-content.test.ts
8+
checksum: c2508ae1fb2b20f6fe3d354558704076ecdcbe7e1ece46addaa5eb8354e60233
9+
- filename: __test__/json-to-html.test.ts
10+
checksum: 8ef368136341314dc597a1e0a3a7b45ac9255897b376395db5e7e032b661682e
11+
- filename: __test__/entry-editable.test.ts
12+
checksum: 32e5e047c56ef0e74456eb6275c6ea8d2ffbef38901bc3eb97ae0a62e431f5a2
713
- filename: src/entry-editable.ts
8-
checksum: 3ba7af9ed1c1adef2e2bd5610099716562bebb8ba750d4b41ddda99fc9eaf115
14+
checksum: e130c19526679f220073ed9cc918fa28906ebf99c33c00cf8ee58f2178caddcf
915
- filename: .husky/pre-commit
1016
checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193
1117
- filename: src/endpoints.ts

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## [1.7.1](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.7.1)
4+
- Fix: Guard against null/undefined in getTag to prevent TypeError when addEditableTags/addTags processes entries with null content (Issue #193)
5+
36
## [1.7.0](https://github.com/contentstack/contentstack-utils-javascript/tree/v1.7.0)
47
- Added option in addTags to disable lowercasing of locale
58

__test__/attributes-to-string.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,24 @@ describe('Attributes to String', () => {
137137
expect(resultString).toEqual(' validKey="safeValue"');
138138
done();
139139
});
140+
141+
describe('Negative and corner cases', () => {
142+
it('attributeToString with null should return empty or throw', () => {
143+
try {
144+
const result = attributeToString(null as any);
145+
expect(result).toBe('');
146+
} catch {
147+
// In strict mode or some envs, for-in on null throws
148+
}
149+
});
150+
151+
it('attributeToString with undefined should return empty or throw', () => {
152+
try {
153+
const result = attributeToString(undefined as any);
154+
expect(result).toBe('');
155+
} catch {
156+
// In strict mode or some envs, for-in on undefined throws
157+
}
158+
});
159+
});
140160
})

__test__/default-options.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,19 @@ describe('Default Option test', () => {
108108
expect(assetDownloadFunction(assetContentBlank, embedAttributesText)).toEqual(`<a>${linkText}</a>`)
109109
done()
110110
})
111+
})
112+
113+
describe('Default options negative and corner cases', () => {
114+
it('should throw when item is null for entry block render', () => {
115+
expect(() => entryBlockFunction(null as any, embedAttributes)).toThrow()
116+
})
117+
118+
it('should throw when item is null for asset display render', () => {
119+
expect(() => assetDisplaableFunction(null as any, embedAttributes)).toThrow()
120+
})
121+
122+
it('should handle metadata with empty attributes', () => {
123+
const emptyAttrs = { attributes: {} } as Metadata
124+
expect(entryBlockFunction(entryContentBlank, emptyAttrs)).toContain(entryContentBlank.uid)
125+
})
111126
})

__test__/entry-editable.test.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addTags } from '../src/entry-editable'
1+
import { addTags, getTag } from '../src/entry-editable'
22
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'
33

44
describe('Entry editable test', () => {
@@ -519,6 +519,91 @@ describe('Entry editable test', () => {
519519
})
520520
})
521521

522+
describe('Null and undefined content handling (Issue #193)', () => {
523+
it('should not throw when entry has a null field value', done => {
524+
const entryWithNullField: any = {
525+
"uid": "entry_uid_null",
526+
"locale": "en-us",
527+
"title": "Valid title",
528+
"description": null
529+
}
530+
531+
expect(() => addTags(entryWithNullField, 'content_type', false)).not.toThrow()
532+
expect((entryWithNullField as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_null.en-us.title')
533+
done()
534+
})
535+
536+
it('should not throw when entry has an undefined field value', done => {
537+
const entryWithUndefinedField: any = {
538+
"uid": "entry_uid_undef",
539+
"locale": "en-us",
540+
"title": "Valid title",
541+
"description": undefined
542+
}
543+
544+
expect(() => addTags(entryWithUndefinedField, 'content_type', false)).not.toThrow()
545+
expect((entryWithUndefinedField as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_undef.en-us.title')
546+
done()
547+
})
548+
549+
it('should return empty tags for a null entry', done => {
550+
const nullEntry: any = null
551+
552+
expect(() => addTags(nullEntry, 'content_type', false)).not.toThrow()
553+
done()
554+
})
555+
556+
it('should return empty tags for an undefined entry', done => {
557+
const undefinedEntry: any = undefined
558+
559+
expect(() => addTags(undefinedEntry, 'content_type', false)).not.toThrow()
560+
done()
561+
})
562+
563+
it('should handle entry with multiple null field values', done => {
564+
const entryWithMultipleNulls: any = {
565+
"uid": "entry_uid_multi_null",
566+
"locale": "en-us",
567+
"title": "Valid title",
568+
"field_a": null,
569+
"field_b": null,
570+
"field_c": "valid"
571+
}
572+
573+
expect(() => addTags(entryWithMultipleNulls, 'content_type', true)).not.toThrow()
574+
expect((entryWithMultipleNulls as any)['$']['title']).toEqual({'data-cslp': 'content_type.entry_uid_multi_null.en-us.title'})
575+
expect((entryWithMultipleNulls as any)['$']['field_c']).toEqual({'data-cslp': 'content_type.entry_uid_multi_null.en-us.field_c'})
576+
done()
577+
})
578+
579+
it('should handle entry with nested null object', done => {
580+
const entryWithNestedNull: any = {
581+
"uid": "entry_uid_nested_null",
582+
"locale": "en-us",
583+
"title": "Valid title",
584+
"group": null
585+
}
586+
587+
expect(() => addTags(entryWithNestedNull, 'content_type', false)).not.toThrow()
588+
expect((entryWithNestedNull as any)['$']['title']).toEqual('data-cslp=content_type.entry_uid_nested_null.en-us.title')
589+
done()
590+
})
591+
592+
it('getTag with null content returns empty object (covers null guard)', done => {
593+
const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' }
594+
const result = getTag(null as any, 'some.prefix', false, 'en-us', appliedVariants)
595+
expect(result).toEqual({})
596+
done()
597+
})
598+
599+
it('getTag with undefined content returns empty object (covers null guard)', done => {
600+
const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' }
601+
const result = getTag(undefined as any, 'some.prefix', true, 'en-us', appliedVariants)
602+
expect(result).toEqual({})
603+
done()
604+
})
605+
})
606+
522607
describe('useLowerCaseLocale option', () => {
523608
it('should preserve locale casing when useLowerCaseLocale is false', done => {
524609
const entry = {
@@ -560,4 +645,73 @@ describe('Entry editable test', () => {
560645
})
561646
})
562647

648+
describe('Negative and corner cases', () => {
649+
it('addTags with empty entry object should not throw and should set $ with empty-like tags', done => {
650+
const emptyEntry = { uid: 'e1', locale: 'en-us' }
651+
expect(() => addTags(emptyEntry, 'ct', false)).not.toThrow()
652+
expect((emptyEntry as any).$).toBeDefined()
653+
expect(typeof (emptyEntry as any).$).toBe('object')
654+
done()
655+
})
656+
657+
it('addTags with empty string contentTypeUid should normalize to lowercase', done => {
658+
const entry = { uid: 'u1', locale: 'en-us', title: 't' }
659+
addTags(entry, '', false)
660+
expect((entry as any)['$']['title']).toContain('.title')
661+
done()
662+
})
663+
664+
it('addTags with options as undefined should not throw', done => {
665+
const entry = { uid: 'u1', locale: 'en-us', title: 't' }
666+
expect(() => addTags(entry, 'ct', false, 'en-us', undefined)).not.toThrow()
667+
expect((entry as any)['$']['title']).toBeDefined()
668+
done()
669+
})
670+
671+
it('getTag with empty object content should return empty tags object', done => {
672+
const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' }
673+
const result = getTag({}, 'prefix', false, 'en-us', appliedVariants)
674+
expect(result).toEqual({})
675+
done()
676+
})
677+
678+
it('getTag with content that has only $ key should skip $ and return empty', done => {
679+
const appliedVariants = { _applied_variants: {}, shouldApplyVariant: false, metaKey: '' }
680+
const result = getTag({ $: 'ignored' }, 'prefix', false, 'en-us', appliedVariants)
681+
expect(result).toEqual({})
682+
done()
683+
})
684+
685+
it('getTag with appliedVariants._applied_variants null should not throw (getParentVariantisedPath safety)', done => {
686+
const content = { field: 'value' }
687+
const appliedVariants = { _applied_variants: null as any, shouldApplyVariant: true, metaKey: 'field' }
688+
expect(() => getTag(content, 'prefix', false, 'en-us', appliedVariants)).not.toThrow()
689+
const result = getTag(content, 'prefix', false, 'en-us', appliedVariants)
690+
expect(result).toHaveProperty('field')
691+
done()
692+
})
693+
694+
it('getTag with appliedVariants.metaKey empty and shouldApplyVariant true should still produce tags', done => {
695+
const content = { title: 'hello' }
696+
const appliedVariants = { _applied_variants: {}, shouldApplyVariant: true, metaKey: '' }
697+
const result = getTag(content, 'ct.uid.en-us', false, 'en-us', appliedVariants)
698+
expect(result).toHaveProperty('title')
699+
expect((result as any).title).toContain('title')
700+
done()
701+
})
702+
703+
it('addTags with entry that has empty array field should not throw', done => {
704+
const entry: any = { uid: 'u1', locale: 'en-us', items: [] }
705+
expect(() => addTags(entry, 'ct', false)).not.toThrow()
706+
expect((entry as any)['$']['items']).toBeDefined()
707+
done()
708+
})
709+
710+
it('addTags with entry that has nested null object value should not throw', done => {
711+
const entry: any = { uid: 'u1', locale: 'en-us', title: 't', nested: null }
712+
expect(() => addTags(entry, 'ct', true)).not.toThrow()
713+
expect((entry as any)['$']['title']).toEqual({ 'data-cslp': expect.stringContaining('title') })
714+
done()
715+
})
716+
})
563717
})

__test__/enumerate-entries.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { enumerate, enumerateContents } from '../src/helper/enumerate-entries';
2+
3+
describe('enumerate', () => {
4+
it('should not throw when entries is empty array', () => {
5+
const process = jest.fn();
6+
expect(() => enumerate([], process)).not.toThrow();
7+
expect(process).not.toHaveBeenCalled();
8+
});
9+
10+
it('should call process for each entry', () => {
11+
const entries = [{ uid: '1' }, { uid: '2' }];
12+
const process = jest.fn();
13+
enumerate(entries, process);
14+
expect(process).toHaveBeenCalledTimes(2);
15+
expect(process).toHaveBeenNthCalledWith(1, { uid: '1' });
16+
expect(process).toHaveBeenNthCalledWith(2, { uid: '2' });
17+
});
18+
});
19+
20+
describe('enumerateContents', () => {
21+
it('should return content as string when content is not array and type is not doc', () => {
22+
const content = { type: 'paragraph', children: [] } as any;
23+
const result = enumerateContents(content);
24+
expect(result).toEqual(content);
25+
});
26+
27+
it('should return array of strings when content is array of docs', () => {
28+
const doc: any = { type: 'doc', children: [] };
29+
const result = enumerateContents([doc, doc]);
30+
expect(Array.isArray(result)).toBe(true);
31+
expect((result as string[]).length).toBe(2);
32+
expect((result as string[])[0]).toBe('');
33+
expect((result as string[])[1]).toBe('');
34+
});
35+
36+
it('should throw when content is null', () => {
37+
expect(() => enumerateContents(null as any)).toThrow();
38+
});
39+
40+
it('should throw when content is undefined', () => {
41+
expect(() => enumerateContents(undefined as any)).toThrow();
42+
});
43+
});

__test__/find-embedded-objects.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,43 @@ describe('findGQLEmbeddedItems edge cases', () => {
148148
const result = findGQLEmbeddedItems(null as any, null as any);
149149
expect(result).toEqual([]);
150150
});
151+
152+
it('should return empty array if items is undefined', () => {
153+
const result = findGQLEmbeddedItems({ itemType: 'entry', itemUid: 'uid', contentTypeUid: 'ct', attributes: {} } as any, undefined as any);
154+
expect(result).toEqual([]);
155+
});
156+
});
157+
158+
describe('findEmbeddedItems negative and corner cases', () => {
159+
it('should return empty array when entry is null', () => {
160+
const metadata = { itemType: 'entry' as const, itemUid: 'uid', contentTypeUid: 'ct', attributes: {} };
161+
expect(findEmbeddedItems(metadata as any, null as any)).toEqual([]);
162+
});
163+
164+
it('should return empty array when entry has no _embedded_items', () => {
165+
const entry = { uid: 'e1', title: 'Entry' };
166+
const metadata = { itemType: 'entry' as const, itemUid: 'uid', contentTypeUid: 'ct', attributes: {} };
167+
expect(findEmbeddedItems(metadata as any, entry as any)).toEqual([]);
168+
});
169+
170+
it('should return empty array when object (metadata) is null', () => {
171+
expect(findEmbeddedItems(null as any, entryEmbeddedEntries)).toEqual([]);
172+
});
173+
});
174+
175+
describe('findRenderString negative and corner cases', () => {
176+
it('should return empty string when metadata is undefined', () => {
177+
expect(findRenderString(entryEmbeddedEntries._embedded_items.rich_text_editor[0], undefined as any)).toEqual('');
178+
});
179+
180+
it('should throw when item is null and metadata is valid (default render accesses item)', () => {
181+
const metadata = { styleType: 'block', attributes: {} } as any;
182+
expect(() => findRenderString(null as any, metadata)).toThrow();
183+
});
184+
185+
it('should throw when both item and metadata are null (accesses metadata.styleType)', () => {
186+
expect(() => findRenderString(null as any, null as any)).toThrow();
187+
});
151188
});
152189

153190
function makeFindEntry(uid: string = '', contentTypeUid: string = '', embeddeditems?: EmbeddedItem[]) {

__test__/find-render-content.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,44 @@ describe('Find Render content test', () => {
229229
})
230230
})
231231

232+
describe('Find Render Content negative and corner cases', () => {
233+
it('getContent with null object should throw when accessing object[key]', () => {
234+
expect(() => getContent(['key'], null as any, () => '')).toThrow()
235+
})
236+
237+
it('getContent with undefined object should throw when accessing object[key]', () => {
238+
expect(() => getContent(['key'], undefined as any, () => '')).toThrow()
239+
})
240+
241+
it('getContent with empty keys array should not call render', done => {
242+
const entry = { rich_text_editor: 'content' }
243+
const render = jest.fn(() => 'replaced')
244+
getContent([], entry, render)
245+
expect(render).not.toHaveBeenCalled()
246+
done()
247+
})
248+
249+
it('findRenderContent with path that does not exist on entry should not throw', done => {
250+
const entry = { uid: 'e1', title: 'Only these' }
251+
expect(() => findRenderContent('nonexistent.path', entry, (c: string | string[]) => c)).not.toThrow()
252+
done()
253+
})
254+
255+
it('findRenderContent with single key that does not exist should not call render', done => {
256+
const entry = { uid: 'e1' }
257+
const render = jest.fn((c: any) => c)
258+
findRenderContent('missing_key', entry, render)
259+
expect(render).not.toHaveBeenCalled()
260+
done()
261+
})
262+
263+
it('getContent when intermediate key is null should not throw', done => {
264+
const entry: any = { level1: null }
265+
expect(() => getContent(['level1', 'level2'], entry, () => 'x')).not.toThrow()
266+
done()
267+
})
268+
})
269+
232270
function findContent(path: string, renders: (content: string| string[]) => string| string[]) {
233271
findRenderContent(path, entryMultipleContent, renders)
234272
}

0 commit comments

Comments
 (0)