fix: mirror #734 onto decomposed files (PG NaN slideshow seed, SQLite bool renders)
Same two pre-existing-on-main bugs, at their post-decomposition locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed; !! coercion in EventDetailsHeader, EventInformationCard, ClientAccessCard. Keeps this branch correct in either merge order with #734 — when merging main afterwards, resolve the adminEvents.js modify/delete conflict by keeping the deletion.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
|
||||
*
|
||||
* The event-create route seeds show_interval_ms/show_transition_ms from
|
||||
* app_settings via an int-parse-and-clamp. The old inline guard
|
||||
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
|
||||
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
|
||||
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
|
||||
* rejects NaN for integer columns ("invalid input syntax for type
|
||||
* integer: NaN") while SQLite silently stores NULL — so POST
|
||||
* /api/admin/events 500'd on PG whenever the slideshow settings rows
|
||||
* were absent (getAppSetting returns its null default).
|
||||
*/
|
||||
|
||||
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
|
||||
|
||||
describe('clampIntOrUndefined', () => {
|
||||
it('returns undefined for null (the getAppSetting missing-row default)', () => {
|
||||
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined, empty string, and booleans', () => {
|
||||
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-numeric garbage', () => {
|
||||
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never returns NaN for any of the failure-mode inputs', () => {
|
||||
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
|
||||
const out = clampIntOrUndefined(v, 100, 5000);
|
||||
expect(Number.isNaN(out)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses and clamps valid values', () => {
|
||||
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
|
||||
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
|
||||
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const { normaliseEventTimeTriple } = require('../../services/eventService');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
@@ -369,7 +370,13 @@ module.exports = (router) => {
|
||||
let slideshowSeed = {};
|
||||
if (await hasColumnCached('events', 'show_interval_ms')) {
|
||||
try {
|
||||
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
|
||||
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
|
||||
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
|
||||
// producing show_interval_ms=NaN in the INSERT — PG rejects that
|
||||
// with "invalid input syntax for type integer" while SQLite
|
||||
// silently stores NULL, so event creation 500'd on PG whenever the
|
||||
// slideshow app_settings rows were absent.
|
||||
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
|
||||
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
||||
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
||||
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
|
||||
|
||||
@@ -31,4 +31,20 @@ function ensureNumber(value, fallback = 0) {
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
module.exports = { ensureInt, ensureNumber };
|
||||
/**
|
||||
* Parse a value as an integer clamped to [min, max]; `undefined` on
|
||||
* anything that doesn't parse (null, undefined, '', booleans, garbage).
|
||||
*
|
||||
* Exists because the inline guard `Number.isFinite(+v) ? parseInt(v)`
|
||||
* disagrees with itself for null/''/true (`+null` is 0 but
|
||||
* `parseInt(null)` is NaN), which let NaN through Math.min/Math.max
|
||||
* and into an INSERT — PostgreSQL rejects NaN for integer columns
|
||||
* while SQLite silently stores NULL, so it only failed on PG.
|
||||
*/
|
||||
function clampIntOrUndefined(value, min, max) {
|
||||
const n = parseInt(value, 10);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
module.exports = { ensureInt, ensureNumber, clampIntOrUndefined };
|
||||
|
||||
@@ -49,7 +49,8 @@ export const ClientAccessCard: React.FC<ClientAccessCardProps> = ({ event, refet
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{event?.client_access_enabled && (
|
||||
{/* !! — SQLite integer boolean; bare 0 renders as literal "0" */}
|
||||
{!!event?.client_access_enabled && (
|
||||
<>
|
||||
{/* Set/Change PIN */}
|
||||
<div className="flex items-end gap-2">
|
||||
|
||||
@@ -207,7 +207,8 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Draft Banner */}
|
||||
{event.is_draft && !event.is_archived && (
|
||||
{/* !! — SQLite returns integer booleans; a bare 0 would render as literal "0" */}
|
||||
{!!event.is_draft && !event.is_archived && (
|
||||
<Card className="p-4 mb-6 border-2 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
|
||||
@@ -835,13 +835,14 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
}`}>
|
||||
{event.protection_level || 'standard'}
|
||||
</span>
|
||||
{event.disable_right_click && (
|
||||
{/* !! on the next three — SQLite integer booleans render literal "0" when falsy */}
|
||||
{!!event.disable_right_click && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<MousePointer className="w-3 h-3 mr-1" />
|
||||
{t('events.rightClickBlocked', 'Right-click blocked')}
|
||||
</span>
|
||||
)}
|
||||
{event.enable_devtools_protection && (
|
||||
{!!event.enable_devtools_protection && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<Monitor className="w-3 h-3 mr-1" />
|
||||
{t('events.devtoolsDetection', 'DevTools detection')}
|
||||
@@ -853,7 +854,7 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
{t('events.downloadsDisabled', 'Downloads disabled')}
|
||||
</span>
|
||||
)}
|
||||
{event.watermark_downloads && (
|
||||
{!!event.watermark_downloads && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<Droplets className="w-3 h-3 mr-1" />
|
||||
{t('events.watermarked', 'Watermarked')}
|
||||
|
||||
Reference in New Issue
Block a user