Просмотр исходного кода

Merge pull request #10105 from weseek/imprv/167506-axios-date-conversion-circular-reference-prevention

imprv: Circular reference prevention in axios date conversion
mergify[bot] 9 месяцев назад
Родитель
Сommit
e03f6b64f9
2 измененных файлов с 266 добавлено и 49 удалено
  1. 220 39
      apps/app/src/utils/axios-date-conversion.spec.ts
  2. 46 10
      apps/app/src/utils/axios.ts

+ 220 - 39
apps/app/src/utils/axios-date-conversion.spec.ts

@@ -1,6 +1,8 @@
-import { convertDateStringsToDates } from './axios';
+import type { DateConvertible } from './axios';
+import { convertStringsToDates } from './axios';
 
-describe('convertDateStringsToDates', () => {
+
+describe('convertStringsToDates', () => {
 
   // Test case 1: Basic conversion in a flat object
   test('should convert ISO date strings to Date objects in a flat object', () => {
@@ -15,9 +17,14 @@ describe('convertDateStringsToDates', () => {
       createdAt: new Date(dateString),
       name: 'Test Item',
     };
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as Record<string, DateConvertible>;
+
     expect(result.createdAt).toBeInstanceOf(Date);
-    expect(result.createdAt.toISOString()).toEqual(dateString);
+
+    if (result.createdAt instanceof Date) {
+      expect(result.createdAt.toISOString()).toEqual(dateString);
+    }
+
     expect(result).toEqual(expected);
   });
 
@@ -53,11 +60,32 @@ describe('convertDateStringsToDates', () => {
         },
       },
     };
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as {
+        data: {
+            item1: {
+                updatedAt: DateConvertible; // Assert 'updatedAt' later
+                value: number;
+            };
+            item2: {
+                nested: {
+                    deletedAt: DateConvertible; // Assert 'deletedAt' later
+                    isActive: boolean;
+                };
+            };
+        };
+    };
     expect(result.data.item1.updatedAt).toBeInstanceOf(Date);
-    expect(result.data.item1.updatedAt.toISOString()).toEqual(dateString1);
-    expect(result.data.item2.nested.deletedAt).toBeInstanceOf(Date);
-    expect(result.data.item2.nested.deletedAt.toISOString()).toEqual(dateString2);
+
+    if (result.data.item1.updatedAt instanceof Date) {
+      expect(result.data.item1.updatedAt.toISOString()).toEqual(dateString1);
+      expect(result.data.item2.nested.deletedAt).toBeInstanceOf(Date);
+    }
+
+    if (result.data.item2.nested.deletedAt instanceof Date) {
+      expect(result.data.item2.nested.deletedAt).toBeInstanceOf(Date);
+      expect(result.data.item2.nested.deletedAt.toISOString()).toEqual(dateString2);
+    }
+
     expect(result).toEqual(expected);
   });
 
@@ -73,22 +101,38 @@ describe('convertDateStringsToDates', () => {
       { id: 1, eventDate: new Date(dateString1) },
       { id: 2, eventDate: new Date(dateString2), data: { nestedProp: 'value' } },
     ];
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as [
+      { id: number, eventDate: DateConvertible},
+      { id: number, eventDate: DateConvertible, data: { nestedProp: string }},
+    ];
+
     expect(result[0].eventDate).toBeInstanceOf(Date);
-    expect(result[0].eventDate.toISOString()).toEqual(dateString1);
-    expect(result[1].eventDate).toBeInstanceOf(Date);
-    expect(result[1].eventDate.toISOString()).toEqual(dateString2);
+
+    if (result[0].eventDate instanceof Date) {
+      expect(result[0].eventDate.toISOString()).toEqual(dateString1);
+    }
+
+    if (result[1].eventDate instanceof Date) {
+      expect(result[1].eventDate).toBeInstanceOf(Date);
+      expect(result[1].eventDate.toISOString()).toEqual(dateString2);
+    }
+
     expect(result).toEqual(expected);
   });
 
   // Test case 4: Array containing date strings directly (though less common for this function)
   test('should handle arrays containing date strings directly', () => {
     const dateString = '2023-06-20T18:00:00.000Z';
-    const input = ['text', dateString, 123];
+    const input: [string, string, number] = ['text', dateString, 123];
     const expected = ['text', new Date(dateString), 123];
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as DateConvertible[];
+
     expect(result[1]).toBeInstanceOf(Date);
-    expect(result[1].toISOString()).toEqual(dateString);
+
+    if (result[1] instanceof Date) {
+      expect(result[1].toISOString()).toEqual(dateString);
+    }
+
     expect(result).toEqual(expected);
   });
 
@@ -101,28 +145,28 @@ describe('convertDateStringsToDates', () => {
       description: 'Some text',
     };
     const originalInput = JSON.parse(JSON.stringify(input)); // Deep copy to ensure no mutation
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input);
     expect(result).toEqual(originalInput); // Should be deeply equal
-    expect(result).toBe(input); // Confirm it mutated the original object
+    expect(result).not.toBe(input); // Confirm it mutated the original object
   });
 
   // Test case 6: Null, undefined, and primitive values
   test('should return primitive values as is', () => {
-    expect(convertDateStringsToDates(null)).toBeNull();
-    expect(convertDateStringsToDates(undefined)).toBeUndefined();
-    expect(convertDateStringsToDates(123)).toBe(123);
-    expect(convertDateStringsToDates('hello')).toBe('hello');
-    expect(convertDateStringsToDates(true)).toBe(true);
+    expect(convertStringsToDates(null)).toBeNull();
+    expect(convertStringsToDates(undefined)).toBeUndefined();
+    expect(convertStringsToDates(123)).toBe(123);
+    expect(convertStringsToDates('hello')).toBe('hello');
+    expect(convertStringsToDates(true)).toBe(true);
   });
 
   // Test case 7: Edge case - empty objects/arrays
   test('should handle empty objects and arrays correctly', () => {
     const emptyObject = {};
     const emptyArray = [];
-    expect(convertDateStringsToDates(emptyObject)).toEqual({});
-    expect(convertDateStringsToDates(emptyArray)).toEqual([]);
-    expect(convertDateStringsToDates(emptyObject)).toBe(emptyObject);
-    expect(convertDateStringsToDates(emptyArray)).toEqual(emptyArray);
+    expect(convertStringsToDates(emptyObject)).toEqual({});
+    expect(convertStringsToDates(emptyArray)).toEqual([]);
+    expect(convertStringsToDates(emptyObject)).not.toBe(emptyObject);
+    expect(convertStringsToDates(emptyArray)).toEqual(emptyArray);
   });
 
   // Test case 8: Date string with different milliseconds (isoDateRegex without .000)
@@ -130,9 +174,14 @@ describe('convertDateStringsToDates', () => {
     const dateString = '2023-01-15T10:00:00Z'; // No milliseconds
     const input = { createdAt: dateString };
     const expected = { createdAt: new Date(dateString) };
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as Record<string, DateConvertible>;
+
     expect(result.createdAt).toBeInstanceOf(Date);
-    expect(result.createdAt.toISOString()).toEqual('2023-01-15T10:00:00.000Z');
+
+    if (result.createdAt instanceof Date) {
+      expect(result.createdAt.toISOString()).toEqual('2023-01-15T10:00:00.000Z');
+    }
+
     expect(result).toEqual(expected);
   });
 
@@ -155,7 +204,14 @@ describe('convertDateStringsToDates', () => {
         nestedDate: new Date(dateString),
       },
     };
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as {
+      prop1: DateConvertible,
+      prop2: null,
+      prop3: {
+        nestedNull: null,
+        nestedDate: DateConvertible
+      }
+    };
     expect(result.prop1).toBeInstanceOf(Date);
     expect(result.prop3.nestedDate).toBeInstanceOf(Date);
     expect(result).toEqual(expected);
@@ -179,12 +235,23 @@ describe('convertDateStringsToDates', () => {
       },
     };
 
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as {
+      id: number,
+      eventTime: DateConvertible,
+      details: {
+        lastActivity: DateConvertible
+      }
+    };
 
     expect(result.eventTime).toBeInstanceOf(Date);
-    expect(result.eventTime.toISOString()).toEqual(new Date(dateStringWithOffset).toISOString());
+    if (result.eventTime instanceof Date) {
+      expect(result.eventTime.toISOString()).toEqual(new Date(dateStringWithOffset).toISOString());
+    }
+
     expect(result.details.lastActivity).toBeInstanceOf(Date);
-    expect(result.details.lastActivity.toISOString()).toEqual(new Date('2025-06-12T05:00:00-04:00').toISOString());
+    if (result.details.lastActivity instanceof Date) {
+      expect(result.details.lastActivity.toISOString()).toEqual(new Date('2025-06-12T05:00:00-04:00').toISOString());
+    }
 
     expect(result).toEqual(expected);
   });
@@ -199,10 +266,13 @@ describe('convertDateStringsToDates', () => {
       startTime: new Date(dateStringWithNegativeOffset),
     };
 
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as Record<string, DateConvertible>;
 
     expect(result.startTime).toBeInstanceOf(Date);
-    expect(result.startTime.toISOString()).toEqual(new Date(dateStringWithNegativeOffset).toISOString());
+    if (result.startTime instanceof Date) {
+      expect(result.startTime.toISOString()).toEqual(new Date(dateStringWithNegativeOffset).toISOString());
+    }
+
     expect(result).toEqual(expected);
   });
 
@@ -216,10 +286,12 @@ describe('convertDateStringsToDates', () => {
       zeroOffsetDate: new Date(dateStringWithZeroOffset),
     };
 
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as Record<string, DateConvertible>;
 
     expect(result.zeroOffsetDate).toBeInstanceOf(Date);
-    expect(result.zeroOffsetDate.toISOString()).toEqual(new Date(dateStringWithZeroOffset).toISOString());
+    if (result.zeroOffsetDate instanceof Date) {
+      expect(result.zeroOffsetDate.toISOString()).toEqual(new Date(dateStringWithZeroOffset).toISOString());
+    }
     expect(result).toEqual(expected);
   });
 
@@ -233,10 +305,12 @@ describe('convertDateStringsToDates', () => {
       detailedTime: new Date(dateStringWithMsAndOffset),
     };
 
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as Record<string, DateConvertible>;
 
     expect(result.detailedTime).toBeInstanceOf(Date);
-    expect(result.detailedTime.toISOString()).toEqual(new Date(dateStringWithMsAndOffset).toISOString());
+    if (result.detailedTime instanceof Date) {
+      expect(result.detailedTime.toISOString()).toEqual(new Date(dateStringWithMsAndOffset).toISOString());
+    }
     expect(result).toEqual(expected);
   });
 
@@ -260,7 +334,14 @@ describe('convertDateStringsToDates', () => {
     // Deep copy to ensure comparison is accurate since the function modifies in place
     const expected = JSON.parse(JSON.stringify(input));
 
-    const result = convertDateStringsToDates(input);
+    const result = convertStringsToDates(input) as {
+      date1: DateConvertible,
+      date2: DateConvertible,
+      date3: DateConvertible,
+      date4: DateConvertible,
+      date5: DateConvertible,
+      someOtherString: string,
+    };
 
     // Assert that they remain strings (or whatever their original type was)
     expect(typeof result.date1).toBe('string');
@@ -282,4 +363,104 @@ describe('convertDateStringsToDates', () => {
     expect(result).toEqual(expected);
   });
 
+
+  describe('test circular reference occurrences', () => {
+
+    // Test case 1: Circular references
+    test('should handle circular references without crashing and preserve the cycle', () => {
+      const dateString1 = '2023-02-20T12:30:00.000Z';
+      const dateString2 = '2023-03-01T08:00:00.000Z';
+      const dateString3 = '2023-04-05T14:15:00.000Z';
+
+      const input: any = {
+        data: {
+          item1: {
+            updatedAt: dateString1,
+            value: 10,
+          },
+          item2: {
+            nested1: {
+              deletedAt: dateString2,
+              isActive: false,
+              nested2: {
+                createdAt: dateString3,
+                parent: null as any,
+              },
+            },
+            anotherItem: {
+              someValue: 42,
+              lastSeen: '2023-11-01T12:00:00Z',
+            },
+          },
+        },
+      };
+
+      // Create a circular reference
+      input.data.item2.nested1.nested2.parent = input;
+
+      const convertedOutput = convertStringsToDates(input) as {
+        data: {
+          item1: {
+            updatedAt: DateConvertible,
+            value: number,
+          },
+          item2: {
+            nested1: {
+              deletedAt: DateConvertible,
+              isActive: boolean,
+              nested2: {
+                createdAt: DateConvertible,
+                parent: any,
+              },
+            },
+            anotherItem: {
+              someValue: number,
+              lastSeen: DateConvertible,
+            },
+          },
+        },
+      };
+
+      // Expect the function not to have thrown an error
+      expect(convertedOutput).toBeDefined();
+      expect(convertedOutput).toBeInstanceOf(Object);
+
+      // Check if circular reference is present
+      expect(convertedOutput.data.item2.nested1.nested2.parent).toBe(input);
+
+      // Check if the date conversion worked
+      expect(convertedOutput.data.item1.updatedAt).toBeInstanceOf(Date);
+      if (convertedOutput.data.item1.updatedAt instanceof Date) {
+        expect(convertedOutput.data.item1.updatedAt.toISOString()).toBe(dateString1);
+      }
+
+      expect(convertedOutput.data.item2.nested1.deletedAt).toBeInstanceOf(Date);
+      if (convertedOutput.data.item2.nested1.deletedAt instanceof Date) {
+        expect(convertedOutput.data.item2.nested1.deletedAt.toISOString()).toBe(dateString2);
+      }
+
+      expect(convertedOutput.data.item2.nested1.nested2.createdAt).toBeInstanceOf(Date);
+      if (convertedOutput.data.item2.nested1.nested2.createdAt instanceof Date) {
+        expect(convertedOutput.data.item2.nested1.nested2.createdAt.toISOString()).toBe(dateString3);
+      }
+
+      expect(convertedOutput.data.item2.anotherItem.lastSeen).toBeInstanceOf(Date);
+      if (convertedOutput.data.item2.anotherItem.lastSeen instanceof Date) {
+        expect(convertedOutput.data.item2.anotherItem.lastSeen.toISOString()).toBe(new Date(input.data.item2.anotherItem.lastSeen).toISOString());
+      }
+    });
+
+    // Test case 2: Direct self-reference
+    test('should work when encountering direct self-references', () => {
+      const obj: any = {};
+      obj.self = obj;
+      obj.createdAt = '2023-02-01T00:00:00Z';
+
+      const converted = convertStringsToDates(obj) as Record<string, DateConvertible>;
+
+      expect(converted).toBeDefined();
+      expect(converted.self).toBe(obj);
+      expect(converted.createdAt).toBeInstanceOf(Date);
+    });
+  });
 });

+ 46 - 10
apps/app/src/utils/axios.ts

@@ -1,6 +1,6 @@
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
-import dayjs from 'dayjs';
+import { formatISO } from 'date-fns';
 import qs from 'qs';
 
 // eslint-disable-next-line no-restricted-imports
@@ -8,7 +8,16 @@ export * from 'axios';
 
 const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(Z|[+-]\d{2}:\d{2})$/;
 
-export function convertDateStringsToDates(data: any): any {
+export type DateConvertible = string | number | boolean | Date | null | undefined | DateConvertible[] | { [key: string]: DateConvertible };
+
+/**
+* Converts string to dates recursively.
+*
+* @param data - Data to be transformed to Date if applicable.
+* @param seen - Set containing data that has been through the function before.
+* @returns - Data containing transformed Dates.
+*/
+function convertStringsToDatesRecursive(data: DateConvertible, seen: Set<unknown>): DateConvertible {
   if (typeof data !== 'object' || data === null) {
     if (typeof data === 'string' && isoDateRegex.test(data)) {
       return new Date(data);
@@ -16,21 +25,44 @@ export function convertDateStringsToDates(data: any): any {
     return data;
   }
 
+  // Check for circular reference
+  if (seen.has(data)) {
+    return data;
+  }
+  seen.add(data);
+
   if (Array.isArray(data)) {
-    return data.map(item => convertDateStringsToDates(item));
+    return data.map(item => convertStringsToDatesRecursive(item, seen));
   }
 
-  for (const key of Object.keys(data)) {
-    const value = data[key];
+  const newData: Record<string, DateConvertible> = {};
+
+  for (const key of Object.keys(data as object)) {
+    const value = (data as Record<string, DateConvertible>)[key];
+
     if (typeof value === 'string' && isoDateRegex.test(value)) {
-      data[key] = new Date(value);
+      newData[key] = new Date(value);
     }
 
     else if (typeof value === 'object' && value !== null) {
-      data[key] = convertDateStringsToDates(value);
+      newData[key] = convertStringsToDatesRecursive(value, seen);
+    }
+
+    else {
+      newData[key] = value;
     }
   }
-  return data;
+
+  return newData;
+}
+
+// Function overloads for better type inference
+export function convertStringsToDates(data: string): string | Date;
+export function convertStringsToDates<T extends DateConvertible>(data: T): DateConvertible;
+export function convertStringsToDates<T extends DateConvertible[]>(data: T): DateConvertible[];
+export function convertStringsToDates<T extends Record<string, DateConvertible>>(data: T): Record<string, DateConvertible>;
+export function convertStringsToDates(data: DateConvertible): DateConvertible {
+  return convertStringsToDatesRecursive(data, new Set());
 }
 
 // Determine the base array of transformers
@@ -54,14 +86,18 @@ const customAxios = axios.create({
 
   transformResponse: baseTransformers.concat(
     (data) => {
-      return convertDateStringsToDates(data);
+      return convertStringsToDates(data);
     },
   ),
 });
 
 // serialize Date config: https://github.com/axios/axios/issues/1548#issuecomment-548306666
 customAxios.interceptors.request.use((config) => {
-  config.paramsSerializer = params => qs.stringify(params, { serializeDate: (date: Date) => dayjs(date).format('YYYY-MM-DDTHH:mm:ssZ') });
+  config.paramsSerializer = params => qs.stringify(params, {
+    serializeDate: (date: Date) => {
+      return formatISO(date, { representation: 'complete' });
+    },
+  });
   return config;
 });