From 39712f49bb17502543b2a04f152dee3238928d57 Mon Sep 17 00:00:00 2001 From: markchushenkov Date: Fri, 6 Feb 2026 15:26:47 +0200 Subject: [PATCH 1/3] Add VIN generators and validate real VIN pool entries --- src/lib/get-request-value.js | 73 ++++++++++++++++++++++ template/config.json | 110 +++++++++++++++++++++++++++++++++ test/get-request-value-spec.js | 47 ++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/src/lib/get-request-value.js b/src/lib/get-request-value.js index 0a7d0f1..25f2f4f 100644 --- a/src/lib/get-request-value.js +++ b/src/lib/get-request-value.js @@ -1,4 +1,62 @@ const type_flag = '_type', + vinAllowedChars = 'ABCDEFGHJKLMNPRSTUVWXYZ0123456789', + vinSerialChars = '0123456789', + vinFormatRegex = /^[A-HJ-NPR-Z0-9]{17}$/, + vinWeights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2], + vinTransliteration = { + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, + J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, + S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9, + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, + '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 + }, + randomChar = function (chars) { + return chars[Math.floor(Math.random() * chars.length)]; + }, + vinValue = function (char) { + const mapped = vinTransliteration[char]; + return (typeof mapped === 'number') ? mapped : 0; + }, + calculateVinCheckDigit = function (vinChars) { + let sum = 0; + for (let i = 0; i < vinChars.length; i++) { + sum += vinValue(vinChars[i]) * vinWeights[i]; + } + const remainder = sum % 11; + return remainder === 10 ? 'X' : `${remainder}`; + }, + generateRandomVin = function () { + const vinChars = new Array(17); + for (let i = 0; i < vinChars.length; i++) { + if (i === 8) { + vinChars[i] = '0'; + } else { + vinChars[i] = randomChar(vinAllowedChars); + } + } + vinChars[8] = calculateVinCheckDigit(vinChars); + return vinChars.join(''); + }, + mutateVinSerial = function (vin) { + const vinChars = vin.toUpperCase().split(''); + for (let i = 11; i < vinChars.length; i++) { + vinChars[i] = randomChar(vinSerialChars); + } + vinChars[8] = '0'; + vinChars[8] = calculateVinCheckDigit(vinChars); + return vinChars.join(''); + }, + pickFromPool = function (pool) { + return pool[Math.floor(Math.random() * pool.length)]; + }, + normalizeVinPool = function (pool) { + if (!Array.isArray(pool)) { + return []; + } + return pool + .map(vin => (typeof vin === 'string' ? vin.trim().toUpperCase() : '')) + .filter(vin => vinFormatRegex.test(vin)); + }, generators = { literal: function (request) { return request.value; @@ -10,6 +68,21 @@ const type_flag = '_type', value += request.template; } return value.substring(0, request.size); + }, + vin: function (request) { + const mode = request.mode || 'valid'; + if (mode === 'real') { + const pool = normalizeVinPool(request.pool); + if (!pool.length) { + return generateRandomVin(); + } + const picked = pickFromPool(pool); + if (request.mutateSerial) { + return mutateVinSerial(picked); + } + return picked; + } + return generateRandomVin(); } }; diff --git a/template/config.json b/template/config.json index ad8f6a1..364edb6 100644 --- a/template/config.json +++ b/template/config.json @@ -138,6 +138,116 @@ "1E-16", "-1", "0.0001", "1,234,567", "1.234.567,89" ], + "VINs": { + "Random valid VIN": { "_type": "vin", "mode": "valid" }, + "Random real VIN": { + "_type": "vin", + "mode": "real", + "mutateSerial": true, + "pool": [ + "19XFB2F55CE065344", + "19XFB2F57FE015985", + "1C3CDFAHXDD319370", + "1C4BJWDGXEL161008", + "1D7HU182X7S223329", + "1FADP3F25DL231400", + "1FADP5AU7DL509038", + "1FAFP444X3F407826", + "1FDKF37G6VEB24646", + "1FDKF37GXVEB21815", + "1FDXE45S7YHB32330", + "1FMCU0J92DUC12004", + "1FTFW1CF9DFA31880", + "1FTFW1EV1AFA88803", + "1FUJCRCK56PV71604", + "1G11C5SL5FF137854", + "1G1AK55FX67672309", + "1G1YF2D77E5122846", + "1G3HN52K0S4830196", + "1G6DA5E5XC0104969", + "1G6DG5EY2B0102432", + "1G8ZK5275WZ228518", + "1GAZG1FGXE1111677", + "1GCDT148168266667", + "1GCVKSEC5EZ229744", + "1GKCS13W0Y2207683", + "1GKDT13SX52126717", + "1GKKRRKD7FJ108514", + "1GNEC13T93R313404", + "1GNFH15TX31190570", + "1GNLRGED4AS111641", + "1HGCG555XWA157274", + "1HGCG56632A166187", + "1HGCP2F36CA226503", + "1HGCP2F37CA050576", + "1HGCS2B83AA000965", + "1HGEM21525L051619", + "1HTSDAAN1YH233592", + "1J4GK48K67W506892", + "1J4GW58N22C111641", + "1J8GA59198L640523", + "1J8GS48K37C559050", + "1N4AL11D36N436040", + "1N4AL21E89N556158", + "1N4AL3AP0FN304875", + "1N4AL3AP7EC103419", + "1N4BL11D45C244172", + "1NXBR32E74Z245624", + "1NXBU4EE4AZ171431", + "1YVHZ8BH2B5M22068", + "2C3KA43R58H175878", + "2C3KA53G46H405086", + "2C4RC1CG0FR610217", + "2C4RC1CGXDR801737", + "2C4RDGCG9ER265252", + "2FMDK4JCXABA86201", + "2G1FB1E30E9322561", + "2G1WF5E37D1146066", + "2G2FV32G822122005", + "2GCEK19T041221983", + "2GKFLTEK7D6228236", + "2HGFG3B87FH507235", + "2T1KR32E03C109092", + "2T3BFREV0FW278448", + "2T3DFREV6EW187837", + "2V4RW3DG2BR669772", + "3C63DRGL7CG217087", + "3C6TRVAG2EE116026", + "3D4PG4FB6AT130074", + "3FA6P0H77FR211122", + "3FA6P0RU4FR147044", + "3FADP4BJ5EM191726", + "3FADP4EJ8BM165015", + "3GCUKREC3EG380201", + "3GNCA53V99S568422", + "3GTP1VE05CG154205", + "3MEHM07Z67R660001", + "3N1CN7AP2EL817258", + "4T1BE30K03U743425", + "4T1BF1FK9FU893482", + "4T1BF3EK2BU670479", + "4T1BF3EK9AU009045", + "4TANL42NXWZ102130", + "55SWF6GB9FU033851", + "5FNRL18623B024271", + "5FNRL38627B458935", + "5FRYD4H8XEB023334", + "5J6RE3H34BL025729", + "5J6RE3H70BL009370", + "5J6RM4H70DL080841", + "5J6YH2H72BL005124", + "5NPDH4AE1EH501856", + "5TDDK3EHXDS250974", + "5XYZG3AB2CG134978", + "5YFBURHE0EP106902", + "5YFBURHE9EP030774", + "JF2SHABC5BH716784", + "JHMCB7652PC029395", + "JHMCG56702C021652", + "JM1BM1K77F1239111" + ] + } + }, "Amounts": ["5000", "$5,000", "$5 000", "$5,000.00"], "Currencies": { "No decimals": "JPY", diff --git a/test/get-request-value-spec.js b/test/get-request-value-spec.js index ca446fc..9fcde6b 100644 --- a/test/get-request-value-spec.js +++ b/test/get-request-value-spec.js @@ -1,6 +1,32 @@ import { getRequestValue } from '../src/lib/get-request-value.js'; describe('getRequestValue', () => { + const vinWeights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; + const vinTransliteration = { + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, + J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, + S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9, + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, + '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 + }; + const isValidVin = function (vin) { + if (!vin || vin.length !== 17) { + return false; + } + const upper = vin.toUpperCase(); + let sum = 0; + for (let i = 0; i < upper.length; i++) { + const value = vinTransliteration[upper[i]]; + if (typeof value !== 'number') { + return false; + } + sum += value * vinWeights[i]; + } + const remainder = sum % 11; + const checkDigit = remainder === 10 ? 'X' : `${remainder}`; + return upper[8] === checkDigit; + }; + it('returns a literal value for _type=literal', () => { expect(getRequestValue({_type: 'literal', value: 'abc'})).toEqual('abc'); }); @@ -8,4 +34,25 @@ describe('getRequestValue', () => { expect(getRequestValue({ '_type': 'size', 'size': '5', 'template': 'A' })).toEqual('AAAAA'); expect(getRequestValue({ '_type': 'size', 'size': '20', 'template': '1234567' })).toEqual('12345671234567123456'); }); + it('returns a valid vin for _type=vin mode=valid', () => { + const vin = getRequestValue({ _type: 'vin', mode: 'valid' }); + expect(isValidVin(vin)).toBe(true); + }); + it('returns a vin from the pool for _type=vin mode=real without mutation', () => { + const poolVin = '1G6DA5E5XC0104969'; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin] }); + expect(vin).toEqual(poolVin); + }); + it('returns a valid vin for _type=vin mode=real with mutation', () => { + const poolVin = '1G6DA5E5XC0104969'; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin], mutateSerial: true }); + expect(isValidVin(vin)).toBe(true); + expect(vin.slice(0, 11)).toEqual(poolVin.slice(0, 11)); + }); + it('ignores invalid vins in the real mode pool', () => { + const invalidVin = '1G6DA5E5IC0104969'; + const validVin = '1G6DA5E5XC0104969'; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [invalidVin, validVin] }); + expect(vin).toEqual(validVin); + }); }); From 49ebc14f1018c0fd53fe0e366f175226e72ee7aa Mon Sep 17 00:00:00 2001 From: Mark Chushenkov <67003721+MarkChushenkov@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:51:20 +0200 Subject: [PATCH 2/3] Improve test coverage: remove duplication, add edge cases --- test/get-request-value-spec.js | 125 +++++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 28 deletions(-) diff --git a/test/get-request-value-spec.js b/test/get-request-value-spec.js index 9fcde6b..0f61a81 100644 --- a/test/get-request-value-spec.js +++ b/test/get-request-value-spec.js @@ -1,58 +1,127 @@ -import { getRequestValue } from '../src/lib/get-request-value.js'; +import { getRequestValue, isValidVin, VIN_WEIGHTS, VIN_TRANSLITERATION } from '../src/lib/get-request-value.js'; describe('getRequestValue', () => { - const vinWeights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; - const vinTransliteration = { - A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, - J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, - S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9, - '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, - '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 - }; - const isValidVin = function (vin) { - if (!vin || vin.length !== 17) { - return false; - } - const upper = vin.toUpperCase(); - let sum = 0; - for (let i = 0; i < upper.length; i++) { - const value = vinTransliteration[upper[i]]; - if (typeof value !== 'number') { - return false; - } - sum += value * vinWeights[i]; - } - const remainder = sum % 11; - const checkDigit = remainder === 10 ? 'X' : `${remainder}`; - return upper[8] === checkDigit; - }; - it('returns a literal value for _type=literal', () => { expect(getRequestValue({_type: 'literal', value: 'abc'})).toEqual('abc'); }); + it('returns a replicated value up to size for _type=size', () => { expect(getRequestValue({ '_type': 'size', 'size': '5', 'template': 'A' })).toEqual('AAAAA'); expect(getRequestValue({ '_type': 'size', 'size': '20', 'template': '1234567' })).toEqual('12345671234567123456'); }); + +describe('VIN generation', () => { it('returns a valid vin for _type=vin mode=valid', () => { const vin = getRequestValue({ _type: 'vin', mode: 'valid' }); expect(isValidVin(vin)).toBe(true); }); + + it('generates different random VINs', () => { + const vin1 = getRequestValue({ _type: 'vin', mode: 'valid' }); + const vin2 = getRequestValue({ _type: 'vin', mode: 'valid' }); + const vin3 = getRequestValue({ _type: 'vin', mode: 'valid' }); + + // All should be valid + expect(isValidVin(vin1)).toBe(true); + expect(isValidVin(vin2)).toBe(true); + expect(isValidVin(vin3)).toBe(true); + + // At least one should be different (extremely high probability) + const allSame = (vin1 === vin2 && vin2 === vin3); + expect(allSame).toBe(false); + }); + it('returns a vin from the pool for _type=vin mode=real without mutation', () => { const poolVin = '1G6DA5E5XC0104969'; const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin] }); expect(vin).toEqual(poolVin); }); + it('returns a valid vin for _type=vin mode=real with mutation', () => { const poolVin = '1G6DA5E5XC0104969'; const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin], mutateSerial: true }); expect(isValidVin(vin)).toBe(true); + // First 11 characters (WMI + VDS + check digit recalculated, but WMI stays same) expect(vin.slice(0, 11)).toEqual(poolVin.slice(0, 11)); }); + + it('mutates serial portion creating different VINs', () => { + const poolVin = '1G6DA5E5XC0104969'; + const vin1 = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin], mutateSerial: true }); + const vin2 = getRequestValue({ _type: 'vin', mode: 'real', pool: [poolVin], mutateSerial: true }); + + expect(isValidVin(vin1)).toBe(true); + expect(isValidVin(vin2)).toBe(true); + // Serial portions should likely be different + expect(vin1.slice(11)).not.toEqual(vin2.slice(11)); + }); + it('ignores invalid vins in the real mode pool', () => { - const invalidVin = '1G6DA5E5IC0104969'; + const invalidVin = '1G6DA5E5IC0104969'; // Invalid check digit const validVin = '1G6DA5E5XC0104969'; const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [invalidVin, validVin] }); expect(vin).toEqual(validVin); }); + + it('falls back to random generation when pool is empty', () => { + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: [] }); + expect(isValidVin(vin)).toBe(true); + }); + + it('falls back to random generation when pool has no valid VINs', () => { + const invalidVins = ['INVALID', '123', 'ABCDEFGHIJK123456']; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: invalidVins }); + expect(isValidVin(vin)).toBe(true); + }); + + it('handles non-array pool gracefully', () => { + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: 'not-an-array' }); + expect(isValidVin(vin)).toBe(true); + }); + + it('handles pool with non-string values', () => { + const pool = [null, undefined, 123, { vin: '1G6DA5E5XC0104969' }, '1G6DA5E5XC0104969']; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: pool }); + expect(vin).toEqual('1G6DA5E5XC0104969'); + }); + + it('normalizes pool VINs to uppercase and trims whitespace', () => { + const pool = [' 1g6da5e5xc0104969 ', '1G6DG5EY2B0102432']; + const vin = getRequestValue({ _type: 'vin', mode: 'real', pool: pool }); + expect(isValidVin(vin)).toBe(true); + expect(['1G6DA5E5XC0104969', '1G6DG5EY2B0102432']).toContain(vin); + }); +}); + +describe('isValidVin helper', () => { + it('validates correct VINs', () => { + expect(isValidVin('1G6DA5E5XC0104969')).toBe(true); + expect(isValidVin('1HGCG555XWA157274')).toBe(true); + expect(isValidVin('JM1BM1K77F1239111')).toBe(true); + }); + + it('rejects VINs with wrong check digit', () => { + expect(isValidVin('1G6DA5E5IC0104969')).toBe(false); // 'I' instead of 'X' + }); + + it('rejects VINs with invalid length', () => { + expect(isValidVin('1G6DA5E5X')).toBe(false); + expect(isValidVin('1G6DA5E5XC0104969EXTRA')).toBe(false); + }); + + it('rejects VINs with invalid characters', () => { + expect(isValidVin('1G6DA5E5XC010496O')).toBe(false); // Contains 'O' + expect(isValidVin('1G6DA5E5XC010496I')).toBe(false); // Contains 'I' + expect(isValidVin('1G6DA5E5XC010496Q')).toBe(false); // Contains 'Q' + }); + + it('handles null and undefined', () => { + expect(isValidVin(null)).toBe(false); + expect(isValidVin(undefined)).toBe(false); + }); + + it('handles empty string', () => { + expect(isValidVin('')).toBe(false); + }); +}); }); From ef96c2f8987ef9689f59d1eebd5001cc7a602c88 Mon Sep 17 00:00:00 2001 From: Mark Chushenkov <67003721+MarkChushenkov@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:52:51 +0200 Subject: [PATCH 3/3] Refactor VIN code: add JSDoc, fix issues, export validators --- src/lib/get-request-value.js | 180 ++++++++++++++++++++++++++++++----- 1 file changed, 156 insertions(+), 24 deletions(-) diff --git a/src/lib/get-request-value.js b/src/lib/get-request-value.js index 25f2f4f..e584c7c 100644 --- a/src/lib/get-request-value.js +++ b/src/lib/get-request-value.js @@ -1,62 +1,171 @@ +/** + * VIN (Vehicle Identification Number) Constants and Utilities + * + * VIN Structure (ISO 3779): + * - Positions 1-3: World Manufacturer Identifier (WMI) + * - Positions 4-8: Vehicle Descriptor Section (VDS) + * - Position 9: Check digit (calculated using ISO 3779 algorithm) + * - Position 10: Model year + * - Position 11: Plant code + * - Positions 12-17: Sequential number (vehicle serial) + * + * Valid characters: A-H, J-N, P, R-Z (excluding I, O, Q), 0-9 + */ + const type_flag = '_type', - vinAllowedChars = 'ABCDEFGHJKLMNPRSTUVWXYZ0123456789', - vinSerialChars = '0123456789', - vinFormatRegex = /^[A-HJ-NPR-Z0-9]{17}$/, - vinWeights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2], - vinTransliteration = { - A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, - J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, - S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9, - '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, - '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 + // VIN Constants + VIN_ALLOWED_CHARS = 'ABCDEFGHJKLMNPRSTUVWXYZ0123456789', + VIN_SERIAL_CHARS = '0123456789', + VIN_FORMAT_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/, + VIN_CHECK_DIGIT_POSITION = 8, + VIN_SERIAL_START_POSITION = 11, + VIN_LENGTH = 17, + + /** + * Weights used for VIN check digit calculation per ISO 3779 + * Position 9 (index 8) has weight 0 as it's the check digit itself + */ + VIN_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2], + + /** + * Character transliteration values for VIN check digit calculation + * Letters are mapped to numeric values, numbers remain unchanged + */ + VIN_TRANSLITERATION = { + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, + J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, + S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9, + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, + '5': 5, '6': 6, '7': 7, '8': 8, '9': 9 }, - randomChar = function (chars) { + + /** + * Returns a random character from the provided character set + * @param {string} chars - String of characters to choose from + * @returns {string} A single random character + */ + getRandomChar = function (chars) { return chars[Math.floor(Math.random() * chars.length)]; }, - vinValue = function (char) { - const mapped = vinTransliteration[char]; - return (typeof mapped === 'number') ? mapped : 0; + + /** + * Gets the numeric value of a VIN character for check digit calculation + * @param {string} char - Single character from a VIN + * @returns {number} Numeric value (0-9) or 0 for invalid characters + */ + getVinCharValue = function (char) { + const mapped = VIN_TRANSLITERATION[char]; + if (typeof mapped !== 'number') { + console.warn(`Invalid VIN character: '${char}'. Using 0 as fallback.`); + return 0; + } + return mapped; }, + + /** + * Calculates the VIN check digit (position 9) per ISO 3779 + * @param {Array} vinChars - Array of 17 VIN characters + * @returns {string} Check digit ('0'-'9' or 'X' for 10) + */ calculateVinCheckDigit = function (vinChars) { let sum = 0; for (let i = 0; i < vinChars.length; i++) { - sum += vinValue(vinChars[i]) * vinWeights[i]; + sum += getVinCharValue(vinChars[i]) * VIN_WEIGHTS[i]; } const remainder = sum % 11; return remainder === 10 ? 'X' : `${remainder}`; }, + + /** + * Generates a random valid VIN with correct check digit + * @returns {string} A 17-character VIN string + */ generateRandomVin = function () { - const vinChars = new Array(17); + const vinChars = new Array(VIN_LENGTH); for (let i = 0; i < vinChars.length; i++) { - if (i === 8) { + if (i === VIN_CHECK_DIGIT_POSITION) { + // Placeholder; will be calculated below vinChars[i] = '0'; } else { - vinChars[i] = randomChar(vinAllowedChars); + vinChars[i] = getRandomChar(VIN_ALLOWED_CHARS); } } - vinChars[8] = calculateVinCheckDigit(vinChars); + vinChars[VIN_CHECK_DIGIT_POSITION] = calculateVinCheckDigit(vinChars); return vinChars.join(''); }, + + /** + * Mutates the serial number portion (positions 12-17) of a VIN + * while preserving the WMI and VDS, then recalculates check digit + * @param {string} vin - Base VIN to mutate + * @returns {string} New VIN with mutated serial and valid check digit + */ mutateVinSerial = function (vin) { const vinChars = vin.toUpperCase().split(''); - for (let i = 11; i < vinChars.length; i++) { - vinChars[i] = randomChar(vinSerialChars); + // Mutate serial portion (positions 12-17, indices 11-16) + for (let i = VIN_SERIAL_START_POSITION; i < vinChars.length; i++) { + vinChars[i] = getRandomChar(VIN_SERIAL_CHARS); } - vinChars[8] = '0'; - vinChars[8] = calculateVinCheckDigit(vinChars); + // Recalculate check digit with mutated serial + vinChars[VIN_CHECK_DIGIT_POSITION] = calculateVinCheckDigit(vinChars); return vinChars.join(''); }, + + /** + * Picks a random element from an array + * @param {Array} pool - Array to pick from + * @returns {*} Random element from the array + */ pickFromPool = function (pool) { return pool[Math.floor(Math.random() * pool.length)]; }, + + /** + * Normalizes and validates a pool of VINs + * Filters out invalid VINs, trims whitespace, converts to uppercase + * @param {Array} pool - Array of potential VIN strings + * @returns {Array} Array of valid, normalized VINs + */ normalizeVinPool = function (pool) { if (!Array.isArray(pool)) { return []; } return pool .map(vin => (typeof vin === 'string' ? vin.trim().toUpperCase() : '')) - .filter(vin => vinFormatRegex.test(vin)); + .filter(vin => VIN_FORMAT_REGEX.test(vin)); + }, + + /** + * Validates a VIN string according to ISO 3779 standard + * Checks length, character validity, and check digit + * @param {string} vin - VIN string to validate + * @returns {boolean} True if VIN is valid + */ + validateVin = function (vin) { + if (!vin || vin.length !== VIN_LENGTH) { + return false; + } + const upper = vin.toUpperCase(); + + // Validate format (allowed characters) + if (!VIN_FORMAT_REGEX.test(upper)) { + return false; + } + + // Validate check digit + let sum = 0; + for (let i = 0; i < upper.length; i++) { + const value = VIN_TRANSLITERATION[upper[i]]; + if (typeof value !== 'number') { + return false; + } + sum += value * VIN_WEIGHTS[i]; + } + const remainder = sum % 11; + const expectedCheckDigit = remainder === 10 ? 'X' : `${remainder}`; + return upper[VIN_CHECK_DIGIT_POSITION] === expectedCheckDigit; }, + generators = { literal: function (request) { return request.value; @@ -69,11 +178,20 @@ const type_flag = '_type', } return value.substring(0, request.size); }, + /** + * VIN Generator + * @param {Object} request - Configuration object + * @param {string} request.mode - 'valid' for random VIN, 'real' for pool-based + * @param {Array} [request.pool] - Pool of real VINs (for 'real' mode) + * @param {boolean} [request.mutateSerial] - Whether to mutate serial portion (for 'real' mode) + * @returns {string} Generated VIN + */ vin: function (request) { const mode = request.mode || 'valid'; if (mode === 'real') { const pool = normalizeVinPool(request.pool); if (!pool.length) { + // No valid VINs in pool, fall back to random generation return generateRandomVin(); } const picked = pickFromPool(pool); @@ -96,3 +214,17 @@ export function getRequestValue(request) { } return generator(request); } + +/** + * Export VIN validation function for testing + * @param {string} vin - VIN to validate + * @returns {boolean} True if valid + */ +export function isValidVin(vin) { + return validateVin(vin); +} + +/** + * Export VIN constants for testing + */ +export { VIN_WEIGHTS, VIN_TRANSLITERATION }; \ No newline at end of file