diff --git a/packages/server/package.json b/packages/server/package.json index 480cb7ee2..ff06a2160 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -167,6 +167,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^@/(.*)$": "/$1" + } } } diff --git a/packages/server/src/modules/Import/ImportFileDataTransformer.ts b/packages/server/src/modules/Import/ImportFileDataTransformer.ts index d20df20fa..b89edd011 100644 --- a/packages/server/src/modules/Import/ImportFileDataTransformer.ts +++ b/packages/server/src/modules/Import/ImportFileDataTransformer.ts @@ -55,9 +55,8 @@ export class ImportFileDataTransformer { /** * Aggregates parsed data based on resource metadata configuration. - * @param {number} tenantId - * @param {string} resourceName - * @param {Record} parsedData + * @param {string} resourceName - The resource name. + * @param {Record} parsedData - The parsed data to aggregate. * @returns {Record[]} */ public aggregateParsedValues( @@ -110,8 +109,11 @@ export class ImportFileDataTransformer { valueDTOs: Record[], trx?: Knex.Transaction ): Promise[]> { - // const tenantModels = this.tenancy.models(tenantId); - const _valueParser = valueParser(fields, {}, trx); + // Create a model resolver function that uses ResourceService + const modelResolver = (modelName: string) => { + return this.resource.getResourceModel(modelName)(); + }; + const _valueParser = valueParser(fields, modelResolver, trx); const _keyParser = parseKey(fields); const parseAsync = async (valueDTO) => { diff --git a/packages/server/src/modules/Import/_utils.spec.ts b/packages/server/src/modules/Import/_utils.spec.ts new file mode 100644 index 000000000..9665e5cca --- /dev/null +++ b/packages/server/src/modules/Import/_utils.spec.ts @@ -0,0 +1,287 @@ +import { aggregate } from './_utils'; + +describe('aggregate', () => { + describe('basic aggregation', () => { + it('should aggregate entries with matching comparator attribute', () => { + const input = [ + { id: 1, name: 'John', entries: ['entry1'] }, + { id: 2, name: 'Jane', entries: ['entry2'] }, + { id: 1, name: 'John', entries: ['entry3'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 1, + name: 'John', + entries: ['entry1', 'entry3'], + }); + expect(result[1]).toEqual({ + id: 2, + name: 'Jane', + entries: ['entry2'], + }); + }); + + it('should preserve order of first occurrence', () => { + const input = [ + { id: 2, entries: ['a'] }, + { id: 1, entries: ['b'] }, + { id: 2, entries: ['c'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result[0].id).toBe(2); + expect(result[1].id).toBe(1); + }); + }); + + describe('no matching entries', () => { + it('should return all entries unchanged when no comparator matches', () => { + const input = [ + { id: 1, name: 'John', entries: ['entry1'] }, + { id: 2, name: 'Jane', entries: ['entry2'] }, + { id: 3, name: 'Bob', entries: ['entry3'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(3); + expect(result).toEqual(input); + }); + }); + + describe('edge cases', () => { + it('should return empty array when input is empty', () => { + const result = aggregate([], 'id', 'entries'); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should return single entry unchanged when input has one item', () => { + const input = [{ id: 1, name: 'John', entries: ['entry1'] }]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 1, + name: 'John', + entries: ['entry1'], + }); + }); + + it('should handle multiple entries with same comparator value', () => { + const input = [ + { id: 1, entries: ['a'] }, + { id: 1, entries: ['b'] }, + { id: 1, entries: ['c'] }, + { id: 1, entries: ['d'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(1); + expect(result[0].entries).toEqual(['a', 'b', 'c', 'd']); + }); + }); + + describe('different comparator attributes', () => { + it('should work with string comparator attribute', () => { + const input = [ + { name: 'Product A', category: 'Electronics', entries: ['item1'] }, + { name: 'Product B', category: 'Books', entries: ['item2'] }, + { name: 'Product C', category: 'Electronics', entries: ['item3'] }, + ]; + + const result = aggregate(input, 'category', 'entries'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'Product A', + category: 'Electronics', + entries: ['item1', 'item3'], + }); + expect(result[1]).toEqual({ + name: 'Product B', + category: 'Books', + entries: ['item2'], + }); + }); + + it('should not aggregate items with undefined comparator values', () => { + const input = [ + { id: undefined, entries: ['a'] }, + { id: 1, entries: ['b'] }, + { id: undefined, entries: ['c'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(3); + // Items with undefined id are NOT aggregated - each remains separate + expect(result[0].entries).toEqual(['a']); + expect(result[1].entries).toEqual(['b']); + expect(result[2].entries).toEqual(['c']); + }); + + it('should handle null comparator values separately', () => { + const input = [ + { id: null, entries: ['a'] }, + { id: 1, entries: ['b'] }, + { id: null, entries: ['c'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(3); + expect(result[0].entries).toEqual(['a']); + expect(result[1].entries).toEqual(['b']); + expect(result[2].entries).toEqual(['c']); + }); + + it('should not aggregate items missing the comparatorAttr property', () => { + const input = [ + { id: 1, entries: ['a'] }, + { name: 'No ID', entries: ['b'] }, // missing 'id' property + { id: 1, entries: ['c'] }, + { entries: ['d'] }, // also missing 'id' property + ]; + + const result = aggregate(input, 'id', 'entries'); + + // 3 entries: aggregated id:1, and two separate items without 'id' property + expect(result).toHaveLength(3); + // Items with id: 1 are aggregated + expect(result[0]).toEqual({ id: 1, entries: ['a', 'c'] }); + // Items missing 'id' are NOT aggregated - each remains separate + expect(result[1]).toEqual({ name: 'No ID', entries: ['b'] }); + expect(result[2]).toEqual({ entries: ['d'] }); + }); + }); + + describe('different group attributes', () => { + it('should work with different groupOn attribute name', () => { + const input = [ + { id: 1, items: ['item1'] }, + { id: 1, items: ['item2'] }, + { id: 2, items: ['item3'] }, + ]; + + const result = aggregate(input, 'id', 'items'); + + expect(result).toHaveLength(2); + expect(result[0].items).toEqual(['item1', 'item2']); + expect(result[1].items).toEqual(['item3']); + }); + }); + + describe('complex entries', () => { + it('should aggregate entries containing objects', () => { + const input = [ + { invoiceId: 'INV-001', entries: [{ itemId: 1, quantity: 2 }] }, + { invoiceId: 'INV-002', entries: [{ itemId: 2, quantity: 1 }] }, + { invoiceId: 'INV-001', entries: [{ itemId: 3, quantity: 5 }] }, + ]; + + const result = aggregate(input, 'invoiceId', 'entries'); + + expect(result).toHaveLength(2); + expect(result[0].entries).toEqual([ + { itemId: 1, quantity: 2 }, + { itemId: 3, quantity: 5 }, + ]); + expect(result[1].entries).toEqual([{ itemId: 2, quantity: 1 }]); + }); + + it('should aggregate entries with multiple items in each entry', () => { + const input = [ + { id: 1, entries: ['a', 'b'] }, + { id: 1, entries: ['c', 'd'] }, + { id: 2, entries: ['e'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(2); + expect(result[0].entries).toEqual(['a', 'b', 'c', 'd']); + expect(result[1].entries).toEqual(['e']); + }); + }); + + describe('numeric comparator values', () => { + it('should correctly compare numeric values', () => { + const input = [ + { id: 1, entries: ['a'] }, + { id: 2, entries: ['b'] }, + { id: 1, entries: ['c'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(2); + expect(result.find((r) => r.id === 1).entries).toEqual(['a', 'c']); + expect(result.find((r) => r.id === 2).entries).toEqual(['b']); + }); + + it('should treat 0 as a valid comparator value', () => { + const input = [ + { id: 0, entries: ['a'] }, + { id: 1, entries: ['b'] }, + { id: 0, entries: ['c'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(2); + expect(result[0].entries).toEqual(['a', 'c']); + expect(result[1].entries).toEqual(['b']); + }); + }); + + describe('preserving other properties', () => { + it('should preserve all properties from the first matching entry', () => { + const input = [ + { id: 1, name: 'First', extra: 'data1', entries: ['a'] }, + { id: 1, name: 'Second', extra: 'data2', entries: ['b'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('First'); + expect(result[0].extra).toBe('data1'); + expect(result[0].entries).toEqual(['a', 'b']); + }); + }); + + describe('empty entries arrays', () => { + it('should handle empty entries arrays', () => { + const input = [ + { id: 1, entries: [] }, + { id: 1, entries: ['a'] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(1); + expect(result[0].entries).toEqual(['a']); + }); + + it('should handle all empty entries arrays', () => { + const input = [ + { id: 1, entries: [] }, + { id: 1, entries: [] }, + ]; + + const result = aggregate(input, 'id', 'entries'); + + expect(result).toHaveLength(1); + expect(result[0].entries).toEqual([]); + }); + }); +}); + diff --git a/packages/server/src/modules/Import/_utils.ts b/packages/server/src/modules/Import/_utils.ts index d30d85ee8..e9e836c42 100644 --- a/packages/server/src/modules/Import/_utils.ts +++ b/packages/server/src/modules/Import/_utils.ts @@ -284,9 +284,11 @@ export const getResourceColumns = (resourceColumns: { return R.compose(transformInputToGroupedFields, mapColumns)(resourceColumns); }; +export type ModelResolver = (modelName: string) => any; + // Prases the given object value based on the field key type. export const valueParser = - (fields: ResourceMetaFieldsMap, tenantModels: any, trx?: Knex.Transaction) => + (fields: ResourceMetaFieldsMap, modelResolver: ModelResolver, trx?: Knex.Transaction) => async (value: any, key: string, group = '') => { let _value = value; @@ -308,7 +310,7 @@ export const valueParser = _value = multiNumberParse(value); // Parses the relation value. } else if (field.fieldType === 'relation') { - const RelationModel = tenantModels[field.relationModel]; + const RelationModel = modelResolver(field.relationModel); if (!RelationModel) { throw new Error(`The relation model of ${key} field is not exist.`); @@ -325,7 +327,7 @@ export const valueParser = _value = get(result, 'id'); } else if (field.fieldType === 'collection') { const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key; - const _valueParser = valueParser(fields, tenantModels); + const _valueParser = valueParser(fields, modelResolver); _value = await _valueParser(value, ObjectFieldKey, fieldKey); } return _value; @@ -402,12 +404,17 @@ export function aggregate( groupOn: string, ): Array> { return input.reduce((acc, curr) => { + // Skip aggregation if the current item doesn't have the comparator attribute + if (curr[comparatorAttr] === undefined || curr[comparatorAttr] === null) { + acc.push({ ...curr }); + return acc; + } const existingEntry = acc.find( (entry) => entry[comparatorAttr] === curr[comparatorAttr], ); if (existingEntry) { - existingEntry[groupOn].push(...curr.entries); + existingEntry[groupOn].push(...curr[groupOn]); } else { acc.push({ ...curr }); }