Merge pull request #391 from andygrunwald/andygrunwald/csv-import-coerce-custom-field-types
fix: coerce boolean and numeric values in custom CSV columns
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {ContactService} from '../../services/ContactService.js';
|
||||
import {coerceCustomValue} from '../import-processor.js';
|
||||
|
||||
/**
|
||||
* Tests for Contact Import Processor - Subscription Status Preservation
|
||||
@@ -279,3 +280,51 @@ describe('Contact Import - Subscription Status Preservation', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceCustomValue', () => {
|
||||
describe('boolean coercion', () => {
|
||||
it.each(['true', 'TRUE', 'True', ' true ', 'yes', 'YES', 'Yes'])('coerces %j to true', value => {
|
||||
expect(coerceCustomValue(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => {
|
||||
expect(coerceCustomValue(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number coercion', () => {
|
||||
it.each([
|
||||
['42', 42],
|
||||
['-7', -7],
|
||||
['3.14', 3.14],
|
||||
[' 42 ', 42],
|
||||
['0.5', 0.5],
|
||||
['-0.25', -0.25],
|
||||
['0', 0],
|
||||
['1', 1],
|
||||
])('coerces %j to %j', (value, expected) => {
|
||||
expect(coerceCustomValue(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['01234', '+42', '.5', '42.', '1e10', 'NaN', 'Infinity', '1.2.3', '4-2'])(
|
||||
'leaves %j as a string (preserves IDs / rejects loose formats)',
|
||||
value => {
|
||||
expect(coerceCustomValue(value)).toBe(value);
|
||||
},
|
||||
);
|
||||
|
||||
it('"1.0" is a number (does not match the boolean truthy set)', () => {
|
||||
expect(coerceCustomValue('1.0')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passthrough', () => {
|
||||
it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => {
|
||||
expect(coerceCustomValue(value)).toBe(value);
|
||||
});
|
||||
|
||||
it('leaves empty string as empty string', () => {
|
||||
expect(coerceCustomValue('')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,11 @@ export function createImportWorker() {
|
||||
|
||||
// Extract custom data (all fields except email and subscribed)
|
||||
const {email: _, subscribed: __, ...customData} = record;
|
||||
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
||||
const customEntries = Object.entries(customData);
|
||||
const data =
|
||||
customEntries.length > 0
|
||||
? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)]))
|
||||
: undefined;
|
||||
|
||||
// Check if contact exists before upserting
|
||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||
@@ -216,3 +220,30 @@ function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// Values considered as boolean during import.
|
||||
// Numbers (0, 1) are intentionally absent.
|
||||
const BOOLEAN_TRUE = new Set(['true', 'yes']);
|
||||
const BOOLEAN_FALSE = new Set(['false', 'no']);
|
||||
|
||||
// Strict integer-or-decimal number detection pattern.
|
||||
// Valid: 0, 42, -42, 3.14
|
||||
// Rejected: 007, +42, 1.2.3, 1e5
|
||||
const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* Coerces a raw string into its most natural primitive type: `boolean`,
|
||||
* `number`, or `string`. Values that match neither are
|
||||
* returned unchanged.
|
||||
*
|
||||
* @param value The raw string to coerce.
|
||||
* @returns The coerced value as `boolean`, `number`, or `string`.
|
||||
*/
|
||||
export function coerceCustomValue(value: string): string | boolean | number {
|
||||
const trimmed = value.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (BOOLEAN_TRUE.has(lower)) return true;
|
||||
if (BOOLEAN_FALSE.has(lower)) return false;
|
||||
if (NUMERIC_RE.test(trimmed)) return Number(trimmed);
|
||||
return value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user