screenshot: admin github button (#778)
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Pins the date-merge fix in `adminDashboard.js` /analytics route
|
||||
* (#661 Bug A). The merge previously failed on Postgres because pg's
|
||||
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
|
||||
* returns a string — the old `dateObj.date === row.date` comparison
|
||||
* was false on Postgres so chartData stayed all-zero even with traffic.
|
||||
*
|
||||
* We test the normalisation helper here in isolation. The route-level
|
||||
* integration is covered by the existing dashboard route test.
|
||||
*/
|
||||
|
||||
// The helper is internal to the route file; reimport via a small wrapper
|
||||
// so we don't need to export everything publicly.
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROUTE_SRC = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/routes/adminDashboard.js'),
|
||||
'utf8',
|
||||
);
|
||||
// Tiny evaluator that grabs the normaliseDateKey function definition from
|
||||
// the route source so the test pins the actual shipping implementation,
|
||||
// not a copy.
|
||||
function extractNormaliseDateKey() {
|
||||
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
|
||||
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(`${match[0]}; return normaliseDateKey;`)();
|
||||
}
|
||||
|
||||
const normaliseDateKey = extractNormaliseDateKey();
|
||||
|
||||
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
|
||||
test('passes through a YYYY-MM-DD string unchanged', () => {
|
||||
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('slices off a time component on a longer ISO string', () => {
|
||||
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
|
||||
const d = new Date('2026-06-22T12:34:56Z');
|
||||
expect(normaliseDateKey(d)).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('returns null for null / undefined / empty', () => {
|
||||
expect(normaliseDateKey(null)).toBeNull();
|
||||
expect(normaliseDateKey(undefined)).toBeNull();
|
||||
expect(normaliseDateKey('')).toBeNull();
|
||||
});
|
||||
|
||||
test('coerces unexpected types via String() to avoid throwing', () => {
|
||||
// We don't expect to receive a number from either driver, but the
|
||||
// helper should not crash if it does — date merge will simply miss.
|
||||
expect(normaliseDateKey(20260622)).toBe('20260622');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for the per-weekday business-hours floor (migration 114).
|
||||
* Exercises the pure snap logic against a fixed IANA zone so the results
|
||||
* don't drift with the CI box's local timezone.
|
||||
*
|
||||
* All scenarios use Europe/Zurich (the regulatory-scope default).
|
||||
*/
|
||||
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
parseHHMM,
|
||||
minutesToHHMM,
|
||||
normaliseSchedule,
|
||||
hasAnyBlocks,
|
||||
_internal,
|
||||
} = require('../../src/utils/businessHours');
|
||||
|
||||
const TZ = 'Europe/Zurich';
|
||||
|
||||
// Mon–Fri 09:00–18:00, weekend closed. Plain string-block storage shape.
|
||||
const STANDARD = {
|
||||
'1': [{ start: '09:00', end: '18:00' }],
|
||||
'2': [{ start: '09:00', end: '18:00' }],
|
||||
'3': [{ start: '09:00', end: '18:00' }],
|
||||
'4': [{ start: '09:00', end: '18:00' }],
|
||||
'5': [{ start: '09:00', end: '18:00' }],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
// Mon–Fri with a lunch break (09:00–12:00, 13:00–18:00).
|
||||
const LUNCH = {
|
||||
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
const cfg = (schedule, overrides = {}) => ({
|
||||
enabled: true,
|
||||
timezone: TZ,
|
||||
schedule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Build a UTC instant from a Zurich wall-clock so the assertions read in
|
||||
// local terms. Reuses the module's own converter (covered separately).
|
||||
function zurich(y, mo, d, hh, mi) {
|
||||
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
|
||||
}
|
||||
|
||||
function partsOf(date) {
|
||||
const p = _internal.getZonedParts(date, TZ);
|
||||
return [p.y, p.mo, p.d, p.hh, p.mi];
|
||||
}
|
||||
|
||||
describe('parseHHMM / minutesToHHMM', () => {
|
||||
it('parses valid times to minutes', () => {
|
||||
expect(parseHHMM('09:00')).toBe(540);
|
||||
expect(parseHHMM('00:00')).toBe(0);
|
||||
expect(parseHHMM('23:59')).toBe(1439);
|
||||
});
|
||||
it('rejects malformed input', () => {
|
||||
expect(parseHHMM('9:00')).toBeNull();
|
||||
expect(parseHHMM('24:00')).toBeNull();
|
||||
expect(parseHHMM('12:60')).toBeNull();
|
||||
expect(parseHHMM('')).toBeNull();
|
||||
expect(parseHHMM(null)).toBeNull();
|
||||
});
|
||||
it('round-trips minutesToHHMM', () => {
|
||||
expect(minutesToHHMM(540)).toBe('09:00');
|
||||
expect(minutesToHHMM(0)).toBe('00:00');
|
||||
expect(minutesToHHMM(1439)).toBe('23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseSchedule', () => {
|
||||
it('parses, sorts, and drops invalid blocks', () => {
|
||||
const out = normaliseSchedule({
|
||||
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
|
||||
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
|
||||
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
|
||||
});
|
||||
expect(out['1']).toEqual([
|
||||
{ start: '09:00', end: '12:00' },
|
||||
{ start: '13:00', end: '18:00' },
|
||||
]);
|
||||
expect(out['2']).toEqual([]);
|
||||
expect(out['3']).toEqual([]);
|
||||
expect(out['7']).toEqual([]);
|
||||
});
|
||||
it('accepts [start,end] pair blocks and a JSON string', () => {
|
||||
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
|
||||
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
|
||||
});
|
||||
it('garbage input → all-empty week', () => {
|
||||
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
|
||||
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — single window (Mon–Fri 09:00–18:00)', () => {
|
||||
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
|
||||
// 2026-06-02 is a Tuesday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('weekday inside window (Tue 10:30) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 10, 30);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
|
||||
it('Sunday 14:00 → Monday 09:00', () => {
|
||||
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
|
||||
// 2026-06-05 is a Friday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
|
||||
});
|
||||
|
||||
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
|
||||
const input = zurich(2026, 6, 2, 9, 0);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — lunch break (09:00–12:00, 13:00–18:00)', () => {
|
||||
it('morning block (Tue 10:30) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 10, 30);
|
||||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||||
});
|
||||
|
||||
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
|
||||
});
|
||||
|
||||
it('afternoon block (Tue 17:59) → unchanged', () => {
|
||||
const input = zurich(2026, 6, 2, 17, 59);
|
||||
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('after close (Tue 19:00) → next day 09:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — per-day differing hours', () => {
|
||||
const PERDAY = {
|
||||
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
|
||||
'2': [], // Tue closed
|
||||
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
|
||||
'4': [], '5': [], '6': [], '7': [],
|
||||
};
|
||||
|
||||
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
|
||||
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
|
||||
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
|
||||
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
|
||||
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
|
||||
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToBusinessHours — passthrough cases', () => {
|
||||
it('floor disabled → unchanged even when outside hours', () => {
|
||||
const input = zurich(2026, 6, 2, 2, 11);
|
||||
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
|
||||
.toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('empty schedule → unchanged (nothing to snap to)', () => {
|
||||
const empty = normaliseSchedule(null);
|
||||
const input = zurich(2026, 6, 7, 14, 0);
|
||||
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
|
||||
});
|
||||
|
||||
it('non-Date / invalid input is passed through untouched', () => {
|
||||
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
|
||||
const bad = new Date('not-a-date');
|
||||
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_internal round-trips', () => {
|
||||
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
|
||||
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
|
||||
const p = _internal.getZonedParts(d, TZ);
|
||||
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
|
||||
});
|
||||
|
||||
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
|
||||
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
|
||||
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const { isUniqueViolation } = require('../../src/utils/dbErrors');
|
||||
|
||||
describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => {
|
||||
it('true for Postgres SQLSTATE 23505', () => {
|
||||
expect(isUniqueViolation({ code: '23505' })).toBe(true);
|
||||
});
|
||||
it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => {
|
||||
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true);
|
||||
});
|
||||
it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => {
|
||||
expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true);
|
||||
});
|
||||
it('false for unrelated errors and nullish', () => {
|
||||
expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation
|
||||
expect(isUniqueViolation({ message: 'connection refused' })).toBe(false);
|
||||
expect(isUniqueViolation(null)).toBe(false);
|
||||
expect(isUniqueViolation(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression coverage for the identity-preserving email normalization
|
||||
* options (#574).
|
||||
*
|
||||
* express-validator's `.normalizeEmail()` applies provider-specific
|
||||
* canonicalization by default — Gmail dot-stripping, +tag stripping,
|
||||
* googlemail → gmail folding, etc. That breaks identity because login
|
||||
* lookups expect the address as the user was invited with, not the
|
||||
* canonicalized form.
|
||||
*
|
||||
* The tests below run validator.js's `normalizeEmail` (the same
|
||||
* implementation express-validator delegates to) through the
|
||||
* `IDENTITY_PRESERVING_NORMALIZE_EMAIL` options object and pin the
|
||||
* behaviour we depend on:
|
||||
* - dots preserved on Gmail
|
||||
* - +tags preserved on Gmail / Outlook / Yahoo / iCloud
|
||||
* - googlemail.com domain preserved (not folded to gmail.com)
|
||||
* - local-part lowercased (still the default — safe and consistent)
|
||||
*/
|
||||
const validator = require('validator');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../src/utils/emailNormalization');
|
||||
|
||||
const norm = (email) => validator.normalizeEmail(email, IDENTITY_PRESERVING_NORMALIZE_EMAIL);
|
||||
|
||||
describe('IDENTITY_PRESERVING_NORMALIZE_EMAIL', () => {
|
||||
it('preserves dots in the Gmail local-part (the #574 root cause)', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('preserves Gmail +tags (subaddresses)', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not fold googlemail.com to gmail.com', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('preserves Outlook +tags', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('preserves Yahoo -tags', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('preserves iCloud +tags', () => {
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('lowercases the local-part (default behaviour we keep)', () => {
|
||||
// all_lowercase defaults true in validator.js. Local-parts are
|
||||
// case-insensitive in practice on every major provider, and
|
||||
// lowercasing keeps login lookup consistent.
|
||||
expect(norm('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Unit tests for the per-guest favorite/like cap (#655).
|
||||
*
|
||||
* Pins the contract of `feedbackService.submitFeedback` around the cap:
|
||||
* - null / 0 cap means unlimited (back-compat for installs that don't
|
||||
* enable the feature).
|
||||
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
|
||||
* than inserting — the route layer translates that into the structured
|
||||
* 403 the UI listens for.
|
||||
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
|
||||
* A guest at 10/10 can still free a slot.
|
||||
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
|
||||
* grandfathers existing rows — new adds blocked, removals always allowed.
|
||||
* - Caps are per-feedback-type: filling the favorite quota doesn't block
|
||||
* likes on the same photo, and vice versa.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-limit-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const EVENT_SLUG = 'cap-test-event';
|
||||
const GUEST_A = 'guest-a-identifier';
|
||||
const GUEST_B = 'guest-b-identifier';
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventId;
|
||||
let photoIds;
|
||||
|
||||
async function setEventFeedbackSettings(overrides) {
|
||||
const base = {
|
||||
feedback_enabled: 1,
|
||||
allow_ratings: 1,
|
||||
allow_likes: 1,
|
||||
allow_comments: 0,
|
||||
allow_favorites: 1,
|
||||
require_name_email: 0,
|
||||
moderate_comments: 0,
|
||||
show_feedback_to_guests: 1,
|
||||
identity_mode: 'simple',
|
||||
max_favorites_per_guest: null,
|
||||
max_likes_per_guest: null,
|
||||
...overrides,
|
||||
};
|
||||
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||
if (existing) {
|
||||
await db('event_feedback_settings').where('event_id', eventId).update(base);
|
||||
} else {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
...base,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function favorite(photoId, guestIdentifier = GUEST_A) {
|
||||
return feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'favorite',
|
||||
ip_address: '127.0.0.1',
|
||||
user_agent: 'jest',
|
||||
}, guestIdentifier);
|
||||
}
|
||||
|
||||
async function like(photoId, guestIdentifier = GUEST_A) {
|
||||
return feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
ip_address: '127.0.0.1',
|
||||
user_agent: 'jest',
|
||||
}, guestIdentifier);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
const inserted = await db('events').insert({
|
||||
slug: EVENT_SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Cap Test',
|
||||
event_date: '2026-06-22',
|
||||
host_email: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${EVENT_SLUG}/share`,
|
||||
share_token: 'cap-test-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
// Seed 15 photos so we can test caps comfortably up to that count.
|
||||
photoIds = [];
|
||||
for (let i = 1; i <= 15; i += 1) {
|
||||
const r = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `events/cap/${i}.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoIds.push(r[0]?.id ?? r[0]);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where('event_id', eventId).del();
|
||||
});
|
||||
|
||||
describe('per-guest favorite cap (#655)', () => {
|
||||
test('null cap = unlimited (back-compat for installs without #655)', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: null });
|
||||
for (const id of photoIds.slice(0, 12)) {
|
||||
const r = await favorite(id);
|
||||
expect(r.limit_reached).toBeFalsy();
|
||||
expect(r.created).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
|
||||
for (const id of photoIds.slice(0, 12)) {
|
||||
const r = await favorite(id);
|
||||
expect(r.limit_reached).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
||||
for (const id of photoIds.slice(0, 10)) {
|
||||
const r = await favorite(id);
|
||||
expect(r.created).toBe(true);
|
||||
}
|
||||
const r11 = await favorite(photoIds[10]);
|
||||
expect(r11.limit_reached).toBe(true);
|
||||
expect(r11.limit).toBe(10);
|
||||
expect(r11.current_count).toBe(10);
|
||||
expect(r11.feedback_type).toBe('favorite');
|
||||
});
|
||||
|
||||
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
||||
for (const id of photoIds.slice(0, 5)) {
|
||||
await favorite(id);
|
||||
}
|
||||
const blocked = await favorite(photoIds[5]);
|
||||
expect(blocked.limit_reached).toBe(true);
|
||||
|
||||
// Un-favorite one — toggle off path returns { removed: true }
|
||||
const removed = await favorite(photoIds[0]);
|
||||
expect(removed.removed).toBe(true);
|
||||
|
||||
// Now the previously-blocked slot fits
|
||||
const after = await favorite(photoIds[5]);
|
||||
expect(after.created).toBe(true);
|
||||
});
|
||||
|
||||
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
||||
for (const id of photoIds.slice(0, 10)) {
|
||||
await favorite(id);
|
||||
}
|
||||
// Admin lowers the cap to 5 while the guest already has 10
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
||||
// Existing 10 stay
|
||||
const count = await db('photo_feedback')
|
||||
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
|
||||
.count('* as c').first();
|
||||
expect(parseInt(count.c, 10)).toBe(10);
|
||||
// New adds blocked
|
||||
const blocked = await favorite(photoIds[10]);
|
||||
expect(blocked.limit_reached).toBe(true);
|
||||
expect(blocked.limit).toBe(5);
|
||||
expect(blocked.current_count).toBe(10);
|
||||
// Removals still allowed
|
||||
const removed = await favorite(photoIds[0]);
|
||||
expect(removed.removed).toBe(true);
|
||||
});
|
||||
|
||||
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
|
||||
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
|
||||
for (const id of photoIds.slice(0, 3)) {
|
||||
await favorite(id, GUEST_A);
|
||||
}
|
||||
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
|
||||
|
||||
// Guest B starts at 0
|
||||
for (const id of photoIds.slice(0, 3)) {
|
||||
const r = await favorite(id, GUEST_B);
|
||||
expect(r.created).toBe(true);
|
||||
}
|
||||
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-guest like cap (#655)', () => {
|
||||
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
|
||||
await setEventFeedbackSettings({
|
||||
max_favorites_per_guest: 3,
|
||||
max_likes_per_guest: null,
|
||||
});
|
||||
for (const id of photoIds.slice(0, 3)) {
|
||||
await favorite(id);
|
||||
}
|
||||
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
|
||||
|
||||
// Likes still unlimited
|
||||
for (const id of photoIds.slice(0, 10)) {
|
||||
const r = await like(id);
|
||||
expect(r.created).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
|
||||
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
|
||||
await like(photoIds[0]);
|
||||
await like(photoIds[1]);
|
||||
const r = await like(photoIds[2]);
|
||||
expect(r.limit_reached).toBe(true);
|
||||
expect(r.feedback_type).toBe('like');
|
||||
expect(r.limit).toBe(2);
|
||||
expect(r.current_count).toBe(2);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
|
||||
* so admin-supplied welcome messages can't inject markup into customer mail.
|
||||
*/
|
||||
|
||||
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes the five HTML metacharacters', () => {
|
||||
expect(escapeHtml('& < > " \'')).toBe('& < > " '');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('coerces non-string values', () => {
|
||||
expect(escapeHtml(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('escapes & before introducing new entities', () => {
|
||||
expect(escapeHtml('<&>')).toBe('<&>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nl2br', () => {
|
||||
it('joins non-empty lines with <br />', () => {
|
||||
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('normalises CRLF and CR', () => {
|
||||
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('drops empty lines', () => {
|
||||
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(nl2br('')).toBe('');
|
||||
expect(nl2br(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWelcomeMessage', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(formatWelcomeMessage('')).toBe('');
|
||||
expect(formatWelcomeMessage(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes HTML metacharacters before nl2br', () => {
|
||||
expect(formatWelcomeMessage('Hello <b>world</b>'))
|
||||
.toBe('Hello <b>world</b>');
|
||||
});
|
||||
|
||||
it('renders newlines as <br /> while keeping content escaped', () => {
|
||||
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
|
||||
.toBe('line 1<br /><script>x</script><br />line 3');
|
||||
});
|
||||
|
||||
it('escapes ampersands and quotes that would otherwise break HTML', () => {
|
||||
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
|
||||
.toBe('Tom & Jerry's "show"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Pure-function tests for the slug validator in galleryShortUrlService.
|
||||
* The validator is the security boundary for the `/s/<slug>` public
|
||||
* route — bad shapes leak into a UNIQUE column that's used in URLs
|
||||
* without further escaping, so the rules need to be tight.
|
||||
*/
|
||||
|
||||
// Provide a minimal db stub so requiring the service doesn't crash —
|
||||
// the validator path doesn't touch the DB.
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const {
|
||||
validateSlug,
|
||||
_RESERVED_SLUGS,
|
||||
} = require('../../src/services/galleryShortUrlService');
|
||||
|
||||
describe('validateSlug', () => {
|
||||
describe('accepts', () => {
|
||||
test.each([
|
||||
'sofia-graduation',
|
||||
'sofia',
|
||||
'a', // single char (alphanumeric)
|
||||
'1', // single digit
|
||||
'abc123',
|
||||
'123-abc',
|
||||
'sofia-2026-06-05',
|
||||
'sofia-2026',
|
||||
'a-b-c-d',
|
||||
'wedding-2026',
|
||||
'xK7p2'.toLowerCase(), // lowercase 5-char
|
||||
'a'.repeat(64), // exactly at the limit
|
||||
])('%j', (slug) => {
|
||||
expect(validateSlug(slug)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejects', () => {
|
||||
test.each([
|
||||
['', 'cannot be empty'],
|
||||
[' ', 'cannot be empty'], // trimmed → empty
|
||||
['-sofia', 'lowercase letters'], // leading hyphen
|
||||
['sofia-', 'lowercase letters'], // trailing hyphen
|
||||
['Sofia', 'lowercase letters'], // uppercase
|
||||
['sofia_graduation', 'lowercase letters'], // underscore
|
||||
['sofia.graduation', 'lowercase letters'], // dot
|
||||
['sofia graduation', 'lowercase letters'], // space
|
||||
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
|
||||
['sofia%20graduation', 'lowercase letters'],
|
||||
['a'.repeat(65), 'at most 64'], // one over limit
|
||||
])('%j → %s', (slug, expectedReason) => {
|
||||
const result = validateSlug(slug);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.toLowerCase()).toContain(expectedReason);
|
||||
});
|
||||
|
||||
test('null', () => {
|
||||
expect(validateSlug(null)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('undefined', () => {
|
||||
expect(validateSlug(undefined)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('number', () => {
|
||||
expect(validateSlug(42)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('object', () => {
|
||||
expect(validateSlug({})).toContain('must be a string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved slugs', () => {
|
||||
test.each([
|
||||
'admin',
|
||||
'api',
|
||||
'auth',
|
||||
'gallery',
|
||||
'og',
|
||||
'health',
|
||||
's', // can't shadow the shortener itself
|
||||
'login',
|
||||
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
|
||||
])('reserves %j', (slug) => {
|
||||
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
|
||||
});
|
||||
|
||||
test('"admin" → rejected with "reserved" reason', () => {
|
||||
// validateSlug short-circuits at the regex for slugs containing
|
||||
// dots (favicon.ico fails the regex first). Test a clean
|
||||
// alphanumeric reserved word.
|
||||
const result = validateSlug('admin');
|
||||
expect(result).toBe('short_slug is reserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
|
||||
test.each([
|
||||
'../etc/passwd',
|
||||
'foo/../bar',
|
||||
'foo?query=1',
|
||||
'foo#fragment',
|
||||
'foo&bar',
|
||||
'foo bar',
|
||||
'foo<script>',
|
||||
'foo>',
|
||||
'foo"',
|
||||
'foo\'',
|
||||
'foo;rm -rf',
|
||||
])('%j', (slug) => {
|
||||
expect(validateSlug(slug)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests for the ISO 13616 IBAN validator.
|
||||
*
|
||||
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
|
||||
* — they are publicly published sample values used by every IBAN
|
||||
* implementation as test vectors. NOT real account numbers.
|
||||
*/
|
||||
const { validateIban, _internal } = require('../../src/utils/iban');
|
||||
|
||||
describe('validateIban', () => {
|
||||
it('accepts a canonical Swiss IBAN', () => {
|
||||
const out = validateIban('CH9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
expect(out.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a Liechtenstein IBAN', () => {
|
||||
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a German IBAN', () => {
|
||||
expect(validateIban('DE89370400440532013000').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an Austrian IBAN', () => {
|
||||
expect(validateIban('AT611904300234573201').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a British IBAN with alphanumeric BBAN', () => {
|
||||
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('normalises spaces and lowercase input', () => {
|
||||
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('normalises mixed-case input', () => {
|
||||
const out = validateIban('ch9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('rejects an empty / null / undefined value', () => {
|
||||
expect(validateIban('').reason).toBe('EMPTY');
|
||||
expect(validateIban(' ').reason).toBe('EMPTY');
|
||||
expect(validateIban(null).reason).toBe('EMPTY');
|
||||
expect(validateIban(undefined).reason).toBe('EMPTY');
|
||||
});
|
||||
|
||||
it('rejects a malformed string (numbers in the country slot)', () => {
|
||||
const out = validateIban('12930076201162385295');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-short string', () => {
|
||||
expect(validateIban('CH93').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-long string (over 34 chars)', () => {
|
||||
// 35 chars: pads beyond the ISO 13616 max
|
||||
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a known-country IBAN with the wrong length', () => {
|
||||
// CH must be 21 chars; this one is 22.
|
||||
const out = validateIban('CH9300762011623852957X');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('LENGTH');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with a broken checksum', () => {
|
||||
// Same shape, last digit altered.
|
||||
const out = validateIban('CH9300762011623852950');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with internally invalid characters', () => {
|
||||
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('accepts an unknown-country IBAN that meets the generic length range', () => {
|
||||
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
|
||||
// structural regex passes if length is in [15, 34] and the
|
||||
// checksum holds. Build a checksum-valid string:
|
||||
//
|
||||
// Format: ZZ + check + BBAN. We don't have a real ZZ template
|
||||
// so this test just confirms unknown country codes route
|
||||
// through the fallback length check rather than failing on
|
||||
// LENGTH outright. A checksum-failing ZZ value will hit
|
||||
// CHECKSUM, not LENGTH, which is the assertion below.
|
||||
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
|
||||
});
|
||||
});
|
||||
|
||||
describe('mod97', () => {
|
||||
it('returns 1 for the canonical CH test vector', () => {
|
||||
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns something other than 1 for a tampered IBAN', () => {
|
||||
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IBAN_LENGTHS table', () => {
|
||||
it('has the expected lengths for the most common European countries', () => {
|
||||
// Sanity check that the table didn't drift if someone edits it.
|
||||
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
|
||||
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
|
||||
|
||||
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
|
||||
// so each test can compare "sum of rounded lines" against cleanNetMinor.
|
||||
function roundedNet(items, parentKey = 'parent_position') {
|
||||
return items
|
||||
.filter((li) => li[parentKey] == null || li[parentKey] === '')
|
||||
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
|
||||
}
|
||||
|
||||
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
|
||||
const discount = extra.discount_percent || 0;
|
||||
return {
|
||||
position,
|
||||
quantity,
|
||||
unit_price_minor: unitPriceMinor,
|
||||
discount_percent: discount,
|
||||
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
|
||||
parent_position: extra.parent_position ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cleanNetMinor — sub-cent reconciliation', () => {
|
||||
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
|
||||
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
|
||||
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
|
||||
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
|
||||
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
|
||||
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
|
||||
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
|
||||
});
|
||||
|
||||
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
|
||||
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
|
||||
expect(cleanNetMinor(items)).toBe(roundedNet(items));
|
||||
});
|
||||
|
||||
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
|
||||
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
|
||||
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
|
||||
expect(roundedNet(items)).toBe(37557);
|
||||
expect(cleanNetMinor(items)).toBe(37556);
|
||||
});
|
||||
|
||||
it('honours per-line discounts at full precision', () => {
|
||||
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
|
||||
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
|
||||
expect(cleanNetMinor(items)).toBe(2010);
|
||||
});
|
||||
|
||||
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
|
||||
// Parent (pos 1) has two priced sub-items; parent own price ignored.
|
||||
const parent = mkLine(1, 1, 9999); // own price should NOT count
|
||||
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
|
||||
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
|
||||
const items = [parent, subA, subB];
|
||||
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
|
||||
expect(cleanNetMinor(items)).toBe(13706);
|
||||
// parent's own 9999 must not leak in
|
||||
expect(cleanNetMinor(items)).not.toBe(9999);
|
||||
});
|
||||
|
||||
it('exactLineMinor returns the un-rounded product', () => {
|
||||
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for the SSRF guard in `networkValidation.js`.
|
||||
*
|
||||
* Regression coverage for GHSA-wmjx-pc37-272r — the original `isPrivateIPv6`
|
||||
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
|
||||
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
|
||||
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
|
||||
* with NAT64/DNS64 egress.
|
||||
*/
|
||||
|
||||
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
|
||||
|
||||
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
|
||||
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
|
||||
test.each([
|
||||
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
|
||||
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
|
||||
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
|
||||
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
|
||||
])('blocks %s (%s)', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
|
||||
test.each([
|
||||
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
|
||||
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
|
||||
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
|
||||
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
|
||||
])('blocks %s (%s)', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
|
||||
test.each([
|
||||
'http://[::ffff:127.0.0.1]/',
|
||||
'http://[::ffff:7f00:1]/',
|
||||
'http://[::ffff:169.254.169.254]/',
|
||||
'http://[::ffff:a9fe:a9fe]/',
|
||||
'http://[::ffff:10.0.0.1]/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
|
||||
test('blocks ::127.0.0.1', () => {
|
||||
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
|
||||
});
|
||||
test('blocks ::169.254.169.254', () => {
|
||||
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing IPv6 private-range coverage stays intact', () => {
|
||||
test.each([
|
||||
'http://[::1]/',
|
||||
'http://[fc00::1]/',
|
||||
'http://[fd12:3456:789a::1]/',
|
||||
'http://[fe80::1]/',
|
||||
'http://[feb0::1]/',
|
||||
'http://[::]/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public IPv6 hosts stay allowed', () => {
|
||||
test.each([
|
||||
'https://[2001:4860:4860::8888]/',
|
||||
'https://[2606:4700:4700::1111]/',
|
||||
'https://[2a00:1450:4001:830::200e]/',
|
||||
])('allows %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing IPv4 private-range coverage stays intact', () => {
|
||||
test.each([
|
||||
'http://127.0.0.1/',
|
||||
'http://10.0.0.1/',
|
||||
'http://172.16.0.1/',
|
||||
'http://192.168.0.1/',
|
||||
'http://169.254.169.254/',
|
||||
'http://0.0.0.0/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked hostnames', () => {
|
||||
test.each([
|
||||
'http://localhost/',
|
||||
'http://metadata.google.internal/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed parsing', () => {
|
||||
test('isPrivateIP returns true for non-string', () => {
|
||||
expect(isPrivateIP(null)).toBe(true);
|
||||
expect(isPrivateIP(undefined)).toBe(true);
|
||||
expect(isPrivateIP(42)).toBe(true);
|
||||
});
|
||||
test('invalid URLs are rejected', () => {
|
||||
expect(validateExternalUrl('not a url').valid).toBe(false);
|
||||
expect(validateExternalUrl('').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF filename builder used on every
|
||||
* quote / invoice download endpoint + the PDF's internal Title
|
||||
* metadata. No mocks needed — all behavior is deterministic.
|
||||
*/
|
||||
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
|
||||
|
||||
describe('sanitiseSegment', () => {
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitiseSegment(null)).toBe('');
|
||||
expect(sanitiseSegment(undefined)).toBe('');
|
||||
expect(sanitiseSegment('')).toBe('');
|
||||
});
|
||||
|
||||
it('replaces filesystem-hostile characters with "-"', () => {
|
||||
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||
});
|
||||
|
||||
it('collapses spaces into single "-"', () => {
|
||||
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
|
||||
});
|
||||
|
||||
it('collapses repeat dashes', () => {
|
||||
expect(sanitiseSegment('a-----b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('trims leading + trailing dashes/dots', () => {
|
||||
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('preserves non-ASCII letters', () => {
|
||||
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
|
||||
});
|
||||
|
||||
it('caps length at 80 chars by default', () => {
|
||||
const long = 'a'.repeat(120);
|
||||
expect(sanitiseSegment(long)).toHaveLength(80);
|
||||
});
|
||||
|
||||
it('honors custom maxLen', () => {
|
||||
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customerLabel', () => {
|
||||
it('prefers company_name over person name', () => {
|
||||
expect(customerLabel({
|
||||
company_name: 'ACME GmbH',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('ACME-GmbH');
|
||||
});
|
||||
|
||||
it('falls back to first + last when company_name is empty', () => {
|
||||
expect(customerLabel({
|
||||
company_name: '',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
|
||||
it('falls back to display_name when no company + no person', () => {
|
||||
expect(customerLabel({
|
||||
display_name: 'Luca B.',
|
||||
})).toBe('Luca-B');
|
||||
});
|
||||
|
||||
it('falls back to email local-part as a last resort', () => {
|
||||
expect(customerLabel({
|
||||
email: '[email protected]',
|
||||
})).toBe('luca');
|
||||
});
|
||||
|
||||
it('uses "customer" when everything is missing', () => {
|
||||
expect(customerLabel({})).toBe('customer');
|
||||
expect(customerLabel(null)).toBe('customer');
|
||||
});
|
||||
|
||||
it('trims whitespace before evaluating truthiness', () => {
|
||||
// company_name = " " should NOT trigger the company branch.
|
||||
expect(customerLabel({
|
||||
company_name: ' ',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdfFilename', () => {
|
||||
const customer = { company_name: 'ACME GmbH' };
|
||||
|
||||
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer,
|
||||
})).toBe('R-2026-0001_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('falls back to the fallback when docNumber is null (preview)', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: null,
|
||||
customer,
|
||||
fallback: 'invoice-preview',
|
||||
})).toBe('invoice-preview_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('uses "document" when both docNumber + fallback are absent', () => {
|
||||
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('sanitises the customer half too', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: { company_name: 'Bad/Name:Inc.' },
|
||||
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
|
||||
});
|
||||
|
||||
it('always ends with .pdf', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: {},
|
||||
})).toMatch(/\.pdf$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* resolveLogoFile — verifies the path-priority chain + the
|
||||
* unsupported-format guard. fs + appSettings + storage config are
|
||||
* mocked so the test is fully deterministic.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/config/storage', () => ({
|
||||
getStoragePath: jest.fn(() => '/app/storage'),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
|
||||
const { getAppSetting } = require('../../src/utils/appSettings');
|
||||
|
||||
describe('resolveLogoFile', () => {
|
||||
let existsSpy, statSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSpy = jest.spyOn(fs, 'existsSync');
|
||||
statSpy = jest.spyOn(fs, 'statSync');
|
||||
existsSpy.mockReturnValue(false);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
getAppSetting.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns null when no sources are configured', async () => {
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers business_profile.logo_path over branding fallbacks', async () => {
|
||||
// The profile path exists, branding doesn't.
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
|
||||
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
|
||||
const out = await resolveLogoFile({
|
||||
logo_path: 'uploads/logos/profile.png',
|
||||
});
|
||||
expect(out).toBe('/app/storage/uploads/logos/profile.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_path when profile is empty', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({ logo_path: '' });
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('skips SVG (PDFKit cannot embed)', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('also rejects WebP / GIF / TIFF', async () => {
|
||||
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
|
||||
existsSpy.mockReturnValue(true);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts PNG and JPEG', async () => {
|
||||
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats absolute paths as-is when they exist', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
|
||||
expect(out).toBe('/abs/path/logo.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { neutralizeSpreadsheetFormula } = require('../../src/utils/spreadsheetSafe');
|
||||
const { _internal } = require('../../src/services/ledgerService');
|
||||
|
||||
describe('neutralizeSpreadsheetFormula — CSV/Banana formula-injection defence (PR #622 blocker 1)', () => {
|
||||
it.each([
|
||||
['=', '=cmd|"/C calc"!A1'],
|
||||
['+', '+1+1'],
|
||||
['-', '-2+3'],
|
||||
['@', '@SUM(1+1)'],
|
||||
['tab', '\tSUM(A1)'],
|
||||
['carriage-return', '\rSUM(A1)'],
|
||||
])('prefixes a single quote when the cell starts with %s', (_label, payload) => {
|
||||
const out = neutralizeSpreadsheetFormula(payload);
|
||||
expect(out).toBe(`'${payload}`);
|
||||
expect(out[0]).toBe("'");
|
||||
});
|
||||
|
||||
it('leaves safe values untouched', () => {
|
||||
expect(neutralizeSpreadsheetFormula('LBM-R-2026-0001')).toBe('LBM-R-2026-0001');
|
||||
expect(neutralizeSpreadsheetFormula('Acme GmbH')).toBe('Acme GmbH');
|
||||
expect(neutralizeSpreadsheetFormula('29.40')).toBe('29.40');
|
||||
// A minus only mid-string is fine — only a LEADING risky char matters.
|
||||
expect(neutralizeSpreadsheetFormula('Q-2026-0001')).toBe('Q-2026-0001');
|
||||
});
|
||||
|
||||
it('coerces null/undefined to empty string', () => {
|
||||
expect(neutralizeSpreadsheetFormula(null)).toBe('');
|
||||
expect(neutralizeSpreadsheetFormula(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('ledgerService.csvEscape applies the prefix AND the RFC-4180 quote wrap', () => {
|
||||
// formula cell → prefixed then quote-wrapped
|
||||
expect(_internal.csvEscape('=1+1')).toBe('"\'=1+1"');
|
||||
// embedded quotes still doubled; safe value not prefixed
|
||||
expect(_internal.csvEscape('a"b')).toBe('"a""b"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const { parseWhatsNew } = require('../../src/utils/whatsNew');
|
||||
|
||||
describe('parseWhatsNew', () => {
|
||||
it('prefers the curated <!-- whatsnew --> block', () => {
|
||||
const body = [
|
||||
'<!-- whatsnew -->',
|
||||
'- Invoice drafts in list',
|
||||
'- Bank transfer payments',
|
||||
'<!-- /whatsnew -->',
|
||||
'',
|
||||
'### Features',
|
||||
'* **invoices:** something long that should be ignored ([#1](http://x))',
|
||||
].join('\n');
|
||||
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
|
||||
});
|
||||
|
||||
it('falls back to the Features section, stripping scope + commit links', () => {
|
||||
const body = [
|
||||
'## [3.73.0-beta.0](http://x) (2026-06-29)',
|
||||
'',
|
||||
'### Features',
|
||||
'',
|
||||
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
|
||||
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
|
||||
'',
|
||||
'### Bug Fixes',
|
||||
'',
|
||||
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
|
||||
].join('\n');
|
||||
expect(parseWhatsNew(body)).toEqual([
|
||||
'revenue tile toggles 365 days',
|
||||
'surface monthly drafts in the Bills list',
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes HTML entities release-please escapes into changelog text', () => {
|
||||
const body = '### Features\n* **gallery:** supports A & B <tags> "quoted" ([#1](http://x))';
|
||||
expect(parseWhatsNew(body)).toEqual(['supports A & B <tags> "quoted"']);
|
||||
});
|
||||
|
||||
it('trims a trailing "— implementation detail" clause to the headline', () => {
|
||||
const body = '### Features\n* **gallery:** branded URL shortener — /s/<slug> with OG injection ([#699](http://x))';
|
||||
expect(parseWhatsNew(body)).toEqual(['branded URL shortener']);
|
||||
});
|
||||
|
||||
it('leaves hyphenated words and dash-free bullets intact', () => {
|
||||
const body = '### Features\n* **invoices:** mark-paid now supports bank transfer ([#2](http://x))';
|
||||
expect(parseWhatsNew(body)).toEqual(['mark-paid now supports bank transfer']);
|
||||
});
|
||||
|
||||
it('excludes Bug Fixes from the fallback', () => {
|
||||
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
|
||||
expect(parseWhatsNew(body)).toEqual(['feature one']);
|
||||
});
|
||||
|
||||
it('caps at 8 bullets and de-dups', () => {
|
||||
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
|
||||
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
|
||||
const out = parseWhatsNew(body);
|
||||
expect(out.length).toBe(8);
|
||||
expect(new Set(out).size).toBe(8);
|
||||
});
|
||||
|
||||
it('returns [] for empty / non-string input', () => {
|
||||
expect(parseWhatsNew('')).toEqual([]);
|
||||
expect(parseWhatsNew(null)).toEqual([]);
|
||||
expect(parseWhatsNew(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Unit tests for the WhatsApp template-parameter selection (#647 follow-up).
|
||||
*
|
||||
* Pins:
|
||||
* - parseTemplateParams sanitizes unknown / non-string / duplicate keys,
|
||||
* and falls back to the default 5-slot shape on empty / malformed input.
|
||||
* - buildComponents emits ONLY the listed slots, in the listed order, so
|
||||
* a 2-parameter template (event_name + gallery_link) sends exactly 2
|
||||
* positional values — the reporter's exact case from issue #647.
|
||||
* - The legacy 5-slot default still works unchanged for installs that
|
||||
* haven't reconfigured.
|
||||
*/
|
||||
const {
|
||||
buildComponents,
|
||||
parseTemplateParams,
|
||||
DEFAULT_TEMPLATE_PARAMS,
|
||||
} = require('../../src/services/whatsappProcessor');
|
||||
|
||||
const baseData = {
|
||||
customer_name: 'Aisha',
|
||||
event_name: 'Wedding 2026',
|
||||
gallery_link: 'https://picpeak.example/wedding-2026',
|
||||
gallery_password: 'StrongPass!',
|
||||
expiry_date: '2026-12-31T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('parseTemplateParams', () => {
|
||||
test('returns the default 5-slot shape for empty / null / undefined input', () => {
|
||||
expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
});
|
||||
|
||||
test('returns the default shape for malformed JSON', () => {
|
||||
expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
});
|
||||
|
||||
test('returns the default shape when JSON parses to a non-array', () => {
|
||||
expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
});
|
||||
|
||||
test('preserves the reporter\'s 2-slot shape', () => {
|
||||
const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link']));
|
||||
expect(out).toEqual(['event_name', 'gallery_link']);
|
||||
});
|
||||
|
||||
test('drops unknown slot keys', () => {
|
||||
const out = parseTemplateParams(JSON.stringify([
|
||||
'event_name', 'unknown_slot', 'gallery_link', '__proto__',
|
||||
]));
|
||||
expect(out).toEqual(['event_name', 'gallery_link']);
|
||||
});
|
||||
|
||||
test('drops duplicate slot keys (first wins)', () => {
|
||||
const out = parseTemplateParams(JSON.stringify([
|
||||
'event_name', 'gallery_link', 'event_name',
|
||||
]));
|
||||
expect(out).toEqual(['event_name', 'gallery_link']);
|
||||
});
|
||||
|
||||
test('drops non-string entries', () => {
|
||||
const out = parseTemplateParams(JSON.stringify([
|
||||
'event_name', 42, null, { a: 1 }, 'gallery_link',
|
||||
]));
|
||||
expect(out).toEqual(['event_name', 'gallery_link']);
|
||||
});
|
||||
|
||||
test('falls back to default when every entry is invalid', () => {
|
||||
const out = parseTemplateParams(JSON.stringify([
|
||||
'unknown_a', 'unknown_b', null, 7,
|
||||
]));
|
||||
expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS);
|
||||
});
|
||||
|
||||
test('also accepts an already-parsed array (defensive)', () => {
|
||||
const out = parseTemplateParams(['event_name', 'gallery_link']);
|
||||
expect(out).toEqual(['event_name', 'gallery_link']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComponents', () => {
|
||||
test('legacy default shape emits 5 positional values, gallery_ready order', () => {
|
||||
const out = buildComponents(baseData, 'en_US');
|
||||
expect(out).toHaveLength(5);
|
||||
expect(out[0]).toBe('Aisha');
|
||||
expect(out[1]).toBe('Wedding 2026');
|
||||
expect(out[2]).toBe('https://picpeak.example/wedding-2026');
|
||||
expect(out[3]).toBe('🔒 Password: StrongPass!');
|
||||
// expiry date is locale-formatted but always non-empty for a valid date
|
||||
expect(out[4]).toMatch(/\d{2}/);
|
||||
});
|
||||
|
||||
test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => {
|
||||
const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']);
|
||||
expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']);
|
||||
});
|
||||
|
||||
test('reorder: gallery_link first, event_name second', () => {
|
||||
const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']);
|
||||
expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']);
|
||||
});
|
||||
|
||||
test('empty slot list emits an empty components array (admin opted into nothing)', () => {
|
||||
const out = buildComponents(baseData, 'en_US', []);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
test('password_line uses the locale-specific label when included', () => {
|
||||
const out = buildComponents(baseData, 'ar', ['password_line']);
|
||||
expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']);
|
||||
});
|
||||
|
||||
test('password_line is empty when no real password is set', () => {
|
||||
const out = buildComponents(
|
||||
{ ...baseData, gallery_password: '' },
|
||||
'en_US',
|
||||
['password_line'],
|
||||
);
|
||||
expect(out).toEqual(['']);
|
||||
});
|
||||
|
||||
test('password_line is empty for the "No password required" sentinel', () => {
|
||||
const out = buildComponents(
|
||||
{ ...baseData, gallery_password: 'No password required' },
|
||||
'en_US',
|
||||
['password_line'],
|
||||
);
|
||||
expect(out).toEqual(['']);
|
||||
});
|
||||
|
||||
test('omits expiry_date when omitted from the slot list', () => {
|
||||
const out = buildComponents(baseData, 'en_US', ['event_name']);
|
||||
expect(out).toEqual(['Wedding 2026']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user