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 { hasColumnCached } = require('../../utils/schemaCache');
|
||||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||||
const { getAppSetting } = require('../../utils/appSettings');
|
const { getAppSetting } = require('../../utils/appSettings');
|
||||||
|
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||||
const downloadZipService = require('../../services/downloadZipService');
|
const downloadZipService = require('../../services/downloadZipService');
|
||||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
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 = {};
|
let slideshowSeed = {};
|
||||||
if (await hasColumnCached('events', 'show_interval_ms')) {
|
if (await hasColumnCached('events', 'show_interval_ms')) {
|
||||||
try {
|
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 oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
||||||
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
||||||
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
|
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;
|
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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{event?.client_access_enabled && (
|
{/* !! — SQLite integer boolean; bare 0 renders as literal "0" */}
|
||||||
|
{!!event?.client_access_enabled && (
|
||||||
<>
|
<>
|
||||||
{/* Set/Change PIN */}
|
{/* Set/Change PIN */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft Banner */}
|
{/* 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">
|
<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">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
|
<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'}
|
{event.protection_level || 'standard'}
|
||||||
</span>
|
</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">
|
<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" />
|
<MousePointer className="w-3 h-3 mr-1" />
|
||||||
{t('events.rightClickBlocked', 'Right-click blocked')}
|
{t('events.rightClickBlocked', 'Right-click blocked')}
|
||||||
</span>
|
</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">
|
<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" />
|
<Monitor className="w-3 h-3 mr-1" />
|
||||||
{t('events.devtoolsDetection', 'DevTools detection')}
|
{t('events.devtoolsDetection', 'DevTools detection')}
|
||||||
@@ -853,7 +854,7 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
|||||||
{t('events.downloadsDisabled', 'Downloads disabled')}
|
{t('events.downloadsDisabled', 'Downloads disabled')}
|
||||||
</span>
|
</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">
|
<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" />
|
<Droplets className="w-3 h-3 mr-1" />
|
||||||
{t('events.watermarked', 'Watermarked')}
|
{t('events.watermarked', 'Watermarked')}
|
||||||
|
|||||||
Reference in New Issue
Block a user