|
2 | 2 | const axios = require("axios"); |
3 | 3 | const settings = require("./settings"); |
4 | 4 |
|
| 5 | + |
| 6 | +function sortKeysDeep(x) { |
| 7 | + if (Array.isArray(x)) return x.map(sortKeysDeep); |
| 8 | + if (x && typeof x === "object") { |
| 9 | + return Object.keys(x) |
| 10 | + .sort() |
| 11 | + .reduce((acc, k) => { |
| 12 | + acc[k] = sortKeysDeep(x[k]); |
| 13 | + return acc; |
| 14 | + }, {}); |
| 15 | + } |
| 16 | + return x; |
| 17 | +} |
| 18 | + |
| 19 | + |
| 20 | +function isMetadataPayload(body) { |
| 21 | + return ( |
| 22 | + body && |
| 23 | + body.success && |
| 24 | + body.success.options && |
| 25 | + body.success.options.parameters && |
| 26 | + typeof body.success.options.parameters === "object" |
| 27 | + ); |
| 28 | +} |
| 29 | + |
| 30 | +function diffKeys(aObj = {}, bObj = {}) { |
| 31 | + const aKeys = new Set(Object.keys(aObj || {})); |
| 32 | + const bKeys = new Set(Object.keys(bObj || {})); |
| 33 | + const onlyInA = [...aKeys].filter((k) => !bKeys.has(k)).sort(); |
| 34 | + const onlyInB = [...bKeys].filter((k) => !aKeys.has(k)).sort(); |
| 35 | + return { onlyInA, onlyInB }; |
| 36 | +} |
| 37 | + |
| 38 | + |
| 39 | + |
5 | 40 | function extractArrayData(payload) { |
6 | 41 | try { |
7 | 42 | if (!payload || !payload.success) return null; |
@@ -216,10 +251,10 @@ module.exports = { |
216 | 251 |
|
217 | 252 | res.body.success.data.features.forEach(function (d, index) { |
218 | 253 | if ( |
219 | | - !d.geometry || |
220 | | - !d.geometry.coordinates || |
221 | | - !d.geometry.coordinates.length || |
222 | | - !d.properties |
| 254 | + !d.geometry || |
| 255 | + !d.geometry.coordinates || |
| 256 | + !d.geometry.coordinates.length || |
| 257 | + !d.properties |
223 | 258 | ) { |
224 | 259 | console.error("Malformed feature at index:", index, d); // Log issue for inspection |
225 | 260 | throw new Error(`GeoJSON was malformed at feature index ${index}`); |
@@ -269,91 +304,168 @@ module.exports = { |
269 | 304 | correctDataTypes: function (res: any) { |
270 | 305 | const data = res.body.success.data; |
271 | 306 | data.forEach( |
272 | | - (item: { t_age: any; b_age: any; t_prop?: any; t_int_age?: any }) => { |
273 | | - if (typeof item.t_age !== "number") { |
274 | | - throw new Error( |
275 | | - `t_age is not a numeric type: ${item.t_age} (type: ${typeof item.t_age})`, |
276 | | - ); |
277 | | - } |
278 | | - if (typeof item.b_age !== "number") { |
279 | | - throw new Error( |
280 | | - `b_age is not a numeric type: ${item.b_age} (type: ${typeof item.b_age})`, |
281 | | - ); |
282 | | - } |
283 | | - if (item.t_prop !== undefined && typeof item.t_prop !== "number") { |
284 | | - throw new Error( |
285 | | - `t_prop is not a numeric type: ${item.t_prop} (type: ${typeof item.t_prop})`, |
286 | | - ); |
287 | | - } |
288 | | - if ( |
289 | | - item.t_int_age !== undefined && |
290 | | - typeof item.t_int_age !== "number" |
291 | | - ) { |
292 | | - throw new Error( |
293 | | - `t_int_age is not a numeric type: ${item.t_int_age} (type: ${typeof item.t_int_age})`, |
294 | | - ); |
295 | | - } |
296 | | - }, |
| 307 | + (item: { t_age: any; b_age: any; t_prop?: any; t_int_age?: any }) => { |
| 308 | + if (typeof item.t_age !== "number") { |
| 309 | + throw new Error( |
| 310 | + `t_age is not a numeric type: ${item.t_age} (type: ${typeof item.t_age})`, |
| 311 | + ); |
| 312 | + } |
| 313 | + if (typeof item.b_age !== "number") { |
| 314 | + throw new Error( |
| 315 | + `b_age is not a numeric type: ${item.b_age} (type: ${typeof item.b_age})`, |
| 316 | + ); |
| 317 | + } |
| 318 | + if (item.t_prop !== undefined && typeof item.t_prop !== "number") { |
| 319 | + throw new Error( |
| 320 | + `t_prop is not a numeric type: ${item.t_prop} (type: ${typeof item.t_prop})`, |
| 321 | + ); |
| 322 | + } |
| 323 | + if ( |
| 324 | + item.t_int_age !== undefined && |
| 325 | + typeof item.t_int_age !== "number" |
| 326 | + ) { |
| 327 | + throw new Error( |
| 328 | + `t_int_age is not a numeric type: ${item.t_int_age} (type: ${typeof item.t_int_age})`, |
| 329 | + ); |
| 330 | + } |
| 331 | + }, |
297 | 332 | ); |
298 | 333 | }, |
| 334 | +async compareWithProduction(requestPath = "", localResponse) { |
| 335 | + const { host_prod } = settings; |
| 336 | + if (!host_prod) return; |
299 | 337 |
|
300 | | - async compareWithProduction(requestPath = "", localResponse) { |
301 | | - const { host_prod } = settings; |
| 338 | + const prodUrl = host_prod + requestPath; |
| 339 | + const { data: prodData } = await axios.get(prodUrl); |
302 | 340 |
|
303 | | - if (!host_prod) { |
304 | | - // Skip comparison if no production host is set |
305 | | - return; |
306 | | - } |
| 341 | + // Helpers (kept inside the function to keep the diff localized) |
| 342 | + function keysOfItem(item) { |
| 343 | + const base = |
| 344 | + item?.properties && typeof item.properties === "object" |
| 345 | + ? item.properties |
| 346 | + : item; |
| 347 | + return base && typeof base === "object" ? Object.keys(base) : []; |
| 348 | + } |
307 | 349 |
|
308 | | - const prodUrl = host_prod + requestPath; |
309 | | - const { data: prodData } = await axios.get(prodUrl); |
| 350 | + function diffItemKeys(localArr, prodArr) { |
| 351 | + const localKeys = new Set(keysOfItem(localArr[0])); |
| 352 | + const prodKeys = new Set(keysOfItem(prodArr[0])); |
| 353 | + const onlyInLocal = [...localKeys].filter((k) => !prodKeys.has(k)).sort(); |
| 354 | + const onlyInProd = [...prodKeys].filter((k) => !localKeys.has(k)).sort(); |
| 355 | + return { onlyInLocal, onlyInProd }; |
| 356 | + } |
| 357 | + |
| 358 | + function sameIdSet(aSet, bSet) { |
| 359 | + if (aSet.size !== bSet.size) return false; |
| 360 | + for (const v of aSet) if (!bSet.has(v)) return false; |
| 361 | + return true; |
| 362 | + } |
| 363 | + |
| 364 | + // 1) Canonical deep-equality (order-insensitive) |
| 365 | + if ( |
| 366 | + JSON.stringify(sortKeysDeep(localResponse.body)) === |
| 367 | + JSON.stringify(sortKeysDeep(prodData)) |
| 368 | + ) { |
| 369 | + return; |
| 370 | + } |
| 371 | + |
| 372 | + // 2) Lenient path: metadata variance (warn, don't fail) |
| 373 | + if (isMetadataPayload(localResponse.body) && isMetadataPayload(prodData)) { |
| 374 | + const localParams = localResponse.body.success.options.parameters; |
| 375 | + const prodParams = prodData.success.options.parameters; |
310 | 376 |
|
311 | | - // Exact JSON match still passes quickly. |
312 | | - if (JSON.stringify(localResponse.body) === JSON.stringify(prodData)) { |
| 377 | + const { onlyInA: onlyInLocal, onlyInB: onlyInProd } = diffKeys( |
| 378 | + localParams, |
| 379 | + prodParams, |
| 380 | + ); |
| 381 | + |
| 382 | + if (onlyInLocal.length || onlyInProd.length) { |
| 383 | + console.warn( |
| 384 | + [ |
| 385 | + `⚠️ metadata parameters mismatch for endpoint: ${requestPath}`, |
| 386 | + ` - Only in Dev (current host): ${ |
| 387 | + onlyInLocal.length ? onlyInLocal.join(", ") : "(none)" |
| 388 | + }`, |
| 389 | + ` - Only in Prod (host_prod): ${ |
| 390 | + onlyInProd.length ? onlyInProd.join(", ") : "(none)" |
| 391 | + }`, |
| 392 | + ` → Status: OK (metadata drift tolerated)`, |
| 393 | + ].join("\n"), |
| 394 | + ); |
313 | 395 | return; |
314 | 396 | } |
| 397 | + // If no param-key diffs but still not equal, fall through (real mismatch elsewhere) |
| 398 | + } |
315 | 399 |
|
316 | | - // Lenient path for array-like payloads |
317 | | - const localArr = extractArrayData(localResponse.body); |
318 | | - const prodArr = extractArrayData(prodData); |
| 400 | + // 3) Existing lenient path for arrays (idKey count checks, sample logic, etc.) |
| 401 | + const localArr = extractArrayData(localResponse.body); |
| 402 | + const prodArr = extractArrayData(prodData); |
319 | 403 |
|
320 | | - if ( |
321 | | - Array.isArray(localArr) && |
322 | | - Array.isArray(prodArr) && |
323 | | - localArr.length && |
324 | | - prodArr.length |
325 | | - ) { |
326 | | - // Auto-detect a shared *_id (or "id") to compare by counts |
327 | | - const idKey = chooseCommonIdKey(localArr, prodArr); |
| 404 | + if ( |
| 405 | + Array.isArray(localArr) && |
| 406 | + Array.isArray(prodArr) && |
| 407 | + localArr.length && |
| 408 | + prodArr.length |
| 409 | + ) { |
| 410 | + // 3a) Sample variance (lenient) |
| 411 | + if (/\bsample\b/i.test(requestPath)) { |
| 412 | + if (localArr.length === prodArr.length) { |
| 413 | + console.warn( |
| 414 | + `⚠️ Sample variance for endpoint: ${requestPath}\n` + |
| 415 | + ` - Dev sample length: ${localArr.length}\n` + |
| 416 | + ` - Prod sample length: ${prodArr.length}\n` + |
| 417 | + ` → Status: OK (non-deterministic sample selection)`, |
| 418 | + ); |
| 419 | + return; |
| 420 | + } |
| 421 | + } |
328 | 422 |
|
329 | | - if (idKey) { |
330 | | - console.info(`[compareWithProduction] Using id key: ${idKey}`); |
331 | | - const localIds = uniqueIds(localArr, idKey); |
332 | | - const prodIds = uniqueIds(prodArr, idKey); |
333 | | - const localCount = localIds.size; |
334 | | - const prodCount = prodIds.size; |
| 423 | + const idKey = chooseCommonIdKey(localArr, prodArr); |
| 424 | + if (idKey) { |
| 425 | + const localIds = uniqueIds(localArr, idKey); |
| 426 | + const prodIds = uniqueIds(prodArr, idKey); |
335 | 427 |
|
336 | | - if (localCount !== prodCount) { |
| 428 | + // 3b) Same-id-set but field-level drift (lenient) |
| 429 | + if (sameIdSet(localIds, prodIds)) { |
| 430 | + const { onlyInLocal, onlyInProd } = diffItemKeys(localArr, prodArr); |
| 431 | + if (onlyInLocal.length || onlyInProd.length) { |
337 | 432 | console.warn( |
338 | 433 | [ |
339 | | - `⚠️ ${idKey} count mismatch for endpoint: ${requestPath}`, |
340 | | - ` - Dev (current host) ${idKey} count: ${localCount}`, |
341 | | - ` - Prod (host_prod) ${idKey} count: ${prodCount}`, |
| 434 | + `⚠️ Field-level drift for endpoint: ${requestPath}`, |
| 435 | + ` - Same ${idKey} set, but object keys differ`, |
| 436 | + ` - Only in Dev (current host): ${ |
| 437 | + onlyInLocal.length ? onlyInLocal.join(", ") : "(none)" |
| 438 | + }`, |
| 439 | + ` - Only in Prod (host_prod): ${ |
| 440 | + onlyInProd.length ? onlyInProd.join(", ") : "(none)" |
| 441 | + }`, |
| 442 | + ` → Status: OK (field drift tolerated)`, |
342 | 443 | ].join("\n"), |
343 | 444 | ); |
344 | | - |
345 | 445 | return; |
346 | 446 | } |
347 | | - // If counts match but payloads differ, fall through to strict error to surface real mismatches. |
348 | 447 | } |
349 | | - // If we couldn't find a shared idKey, we’ll fall back to strict diff below. |
| 448 | + |
| 449 | + // 3c) idKey count mismatch (lenient) |
| 450 | + if (localIds.size !== prodIds.size) { |
| 451 | + console.warn( |
| 452 | + [ |
| 453 | + `⚠️ ${idKey} count mismatch for endpoint: ${requestPath}`, |
| 454 | + ` - Dev (current host) ${idKey} count: ${localIds.size}`, |
| 455 | + ` - Prod (host_prod) ${idKey} count: ${prodIds.size}`, |
| 456 | + ].join("\n"), |
| 457 | + ); |
| 458 | + return; |
| 459 | + } |
350 | 460 | } |
| 461 | + } |
351 | 462 |
|
352 | | - // Strict mismatch error with helpful diff |
353 | | - throw new Error( |
354 | | - `Mismatch for endpoint: ${requestPath}\n` + |
355 | | - `Local: ${JSON.stringify(localResponse.body, null, 2)}\n` + |
356 | | - `Production: ${JSON.stringify(prodData, null, 2)}`, |
357 | | - ); |
358 | | - }, |
359 | | -}; |
| 463 | + // 4) Strict mismatch |
| 464 | + throw new Error( |
| 465 | + `Mismatch for endpoint: ${requestPath}\n` + |
| 466 | + `Local: ${JSON.stringify(localResponse.body, null, 2)}\n` + |
| 467 | + `Production: ${JSON.stringify(prodData, null, 2)}`, |
| 468 | + ); |
| 469 | +} |
| 470 | + |
| 471 | +} |
0 commit comments