feat(events): gallery QR code + printable table-card/poster PDFs (#847)
* feat(events): gallery QR code + printable table-card/poster PDFs (#836) - GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or SVG, inline or attachment; adminAuth + events.view + ownership. - GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card / A4 poster with event name, QR, localized caption (8 locales; Cyrillic falls back to English — built-in Helvetica has no Cyrillic glyphs) and the share URL as footer. - Event detail: QR section in ShareLinkCard with live preview (blob fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print language follows the admin UI language. i18n keys in all 8 locales. - qrcode + pdfkit were already dependencies (MFA / CRM PDFs). * fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847) - QR URLs: prefer the configured public base, but fall back to the admin browser's origin (passed as ?origin=, validated) when the base is missing or localhost — mirrors buildShareLinkUrl so the QR encodes the same URL the card displays instead of an unusable localhost target. - PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek) instead of WinAnsi-only Helvetica: Cyrillic event names no longer silently disappear, and the caption's English-fallback hack is gone. - Fixed vertical layout: title gets a bounded two-line ellipsis region and all positions derive from constants, so long event names can't push the QR/caption over the footer; URL footer bounded too. - ShareLinkCard preview: stale-response guard — a late blob response after unmount/event-switch is revoked instead of leaking and overwriting the newer event's QR. * fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2) Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix Cyrillic titles and regressed the ru caption. Now bundling the complete IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap verified via fontkit, rendering verified on a generated PDF) under assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside. ~400 KB total; source: IBM/plex release zip @ibm/[email protected]. Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like the frontend's own URL logic does. Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only subsets and share the Cyrillic gap. * fix(events): responsive QR card that survives preview failures (codex review of #847, round 3) - The QR section keys off share-link availability instead of a loaded preview: a transient failure of the preview request no longer hides every download button until reload; a placeholder tile renders in place of the image. - Preview + actions stack on phone widths and the button grid drops to one column below sm, so 'Tischkarte (A6)'-length labels don't overflow. * fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round) - The QR target is now the STORED share_link — exactly what the card displays and the admin copies. Rebuilding from current slug/token/ short-URL setting could diverge for legacy absolute links or events created under a different short-URL setting; a printed QR encoding a different URL than the card is a permanent mistake. Rebuild remains only as fallback when no share_link is stored. - QR margin back to the library's 4-module default for all generated assets — the spec's quiet zone; margin 2 risks scan failures when the printout sits against colored surroundings. * fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round) Quote-/contract-converted events persist share_link as the raw token — the frontend's buildShareLinkUrl prefixes those with /gallery/, but the QR path normalization only added a leading slash, encoding <origin>/<token> into every image/PDF for such events. Now mirrors the frontend exactly. * test(events): 30s timeout for the print-PDF cases (CI fix) The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each); font parsing + subsetting exceeds jest's 5s default on slower CI runners — the suite went red on exactly that test after the font commit.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* HTTP tests for the gallery QR endpoints (#836):
|
||||
* GET /api/admin/events/:id/qr (PNG / SVG)
|
||||
* GET /api/admin/events/:id/qr-print (table-card / poster PDF)
|
||||
* Same real-SQLite harness as adminEvents.smoke.test.js.
|
||||
*/
|
||||
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-events-qr-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-qr-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'QR Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: '[email protected]',
|
||||
admin_email: '[email protected]',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin event QR endpoints', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => { await db('events').del(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('401s without an admin token', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await request(app).get(`/api/admin/events/${eventId}/qr`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns a PNG QR by default', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
// PNG magic bytes
|
||||
expect(res.body.slice(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
it('returns an SVG QR when requested', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
// supertest doesn't text-parse image/svg+xml — buffer and decode manually.
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?format=svg`)).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
|
||||
expect(Buffer.from(res.body).toString('utf8')).toContain('<svg');
|
||||
});
|
||||
|
||||
it('sets attachment disposition with download=1', async () => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?download=1`)).buffer();
|
||||
expect(res.headers['content-disposition']).toMatch(/^attachment/);
|
||||
});
|
||||
|
||||
// 30s: the print PDFs embed the full IBM Plex Sans TTFs (~200 KB each) —
|
||||
// font parsing + subsetting exceeds jest's 5s default on slower CI runners.
|
||||
it.each(['table-card', 'poster'])('renders the %s print PDF', async (template) => {
|
||||
const eventId = await insertEvent(db, adminId);
|
||||
const res = await auth(
|
||||
request(app).get(`/api/admin/events/${eventId}/qr-print?template=${template}&lang=de`)
|
||||
).buffer();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/pdf');
|
||||
expect(res.body.slice(0, 4).toString()).toBe('%PDF');
|
||||
}, 30000);
|
||||
|
||||
it('409s when the event has no share link', async () => {
|
||||
// events.share_link is NOT NULL — an empty string is the closest real-world
|
||||
// "no share link" shape (no token extractable from it either).
|
||||
const eventId = await insertEvent(db, adminId, { share_link: '', share_token: null });
|
||||
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('404s for a non-existent event', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/events/999999/qr'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user