56c2386c90
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short URL per event that bots scrape for OG previews and browsers redirect to the underlying gallery. WhatsApp / iMessage / Facebook cache the OG metadata by the URL they crawl, so the SHORT URL becomes the cache key — admins can rotate or split-test underlying gallery URLs without re-pushing a fresh link to clients. Additive feature; no existing route, table, or column is modified. ## Backend - `gallery_short_urls` table (migration 150): id, short_slug UNIQUE, event_id FK CASCADE, target_path TEXT, created_by/at, hit_count, last_hit_at, deleted_at/by. hasTable-guarded so the migration is idempotent on re-run. - `src/services/galleryShortUrlService.js` — validator + CRUD + resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`, reserved blocklist (admin, api, auth, gallery, og, s, login, ...). target_path snapshots at create-time from the event + global short-URL toggle, so a later flip of the toggle does NOT silently change where existing short URLs resolve. - `src/routes/adminShortUrls.js` — `GET/POST /api/admin/events/:eventId/short-urls`, `DELETE /api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG, 409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by events.view / events.edit + requireEventOwnership. - `server.js` /s/:shortSlug public route. Bot UA → server-render the same OG metadata the existing /og/gallery/<slug> handler produces, then override og:url to point at /s/<shortSlug> itself (cache-key invariant — social platforms key by the URL they scrape). Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone (intentional-delete signal, distinct from 404 unknown slug). Hit accounting is fire-and-forget. ## Frontend - `services/shortUrls.service.ts` — list/create/remove. - `components/admin/ShortUrlsCard.tsx` — per-event card on the EventDetailsPage. Form for custom or auto-generated slug, list with copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the service's `suggested` slug with a "use suggested" button. - i18n: events.shortUrls.* added to EN + DE. ## Tests 78 new tests, all passing: - `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure- function tests for validateSlug: accepts/rejects, reserved-slug blocklist, path-traversal + URL-injection vectors. - `__tests__/integration/galleryShortUrls.test.js` (19) — service layer against a real SQLite DB. Covers custom + auto-generated slugs, collision + SLUG_TAKEN + suggested, target_path snapshotting (backward-compat invariant), soft-delete + slug rotation, hit counting. - `__tests__/integration/galleryShortUrlRoute.test.js` (11) — HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA, og:url canonical points at /s/<slug>, 410 for soft-deleted + orphaned events, 404 unknown + malformed. Regression sweep: 47 existing migration-chain integration tests still pass; migration 150 is additive only. ## Backward compatibility - Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`, `/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`, `/og/gallery/<slug>/cover` routes are untouched. - The `/s/` namespace is new; no existing route lives there. - Migration 150 only ADDs the new table — no ALTERs on existing schema, no destructive changes. - target_path is snapshotted at create-time so flipping the global "Use short gallery URLs" setting after a short URL exists does NOT change where that short URL resolves.
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
/**
|
|
* Integration tests for the branded short-URL service (#699).
|
|
*
|
|
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
|
|
* + recordHit against a real SQLite DB, including the contracts that
|
|
* matter for production correctness:
|
|
*
|
|
* - Custom slug + collision detection (409 with `suggested`)
|
|
* - Auto-generated slug from event slug + year
|
|
* - Soft-delete preserves the row (admin can audit)
|
|
* - target_path snapshots at create time (toggling the global
|
|
* "Use short gallery URLs" setting later doesn't change existing
|
|
* short URLs — backward-compat invariant from #699)
|
|
* - hit_count increments idempotently
|
|
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
|
|
*
|
|
* Boots one DB for the whole file (cheap on SQLite); each test seeds
|
|
* its own event row to keep scope clean.
|
|
*/
|
|
const { bootCrmDb } = require('./helpers/crmDb');
|
|
|
|
jest.setTimeout(60000);
|
|
|
|
let db; let cleanup; let service; let adminId;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
|
|
// Minimal admin for created_by audit.
|
|
const adminInsert = await db('admin_users').insert({
|
|
username: 'shorturl-test',
|
|
email: 'shorturl@example.com',
|
|
password_hash: 'x',
|
|
must_change_password: false,
|
|
created_at: new Date(),
|
|
}).returning('id');
|
|
adminId = adminInsert[0]?.id ?? adminInsert[0];
|
|
|
|
service = require('../../src/services/galleryShortUrlService');
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
// Each test seeds a fresh event so collisions / counter state don't leak.
|
|
async function seedEvent(overrides = {}) {
|
|
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
|
const [id] = await db('events').insert({
|
|
slug,
|
|
event_type: 'wedding',
|
|
event_name: overrides.event_name || 'Test Wedding',
|
|
event_date: overrides.event_date || '2026-06-05',
|
|
password_hash: 'x',
|
|
expires_at: farFuture,
|
|
is_active: true,
|
|
is_archived: false,
|
|
share_link: slug,
|
|
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
|
|
welcome_message: null,
|
|
});
|
|
const event = await db('events').where({ id }).first();
|
|
return event;
|
|
}
|
|
|
|
describe('createShortUrl — custom slug', () => {
|
|
it('creates with a custom slug', async () => {
|
|
const event = await seedEvent({ slug: 'sofia-grad-1' });
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id,
|
|
customSlug: 'sofia-graduation-1',
|
|
createdBy: adminId,
|
|
});
|
|
expect(row.short_slug).toBe('sofia-graduation-1');
|
|
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
|
expect(row.event_id).toBe(event.id);
|
|
expect(row.hit_count).toBe(0);
|
|
});
|
|
|
|
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
|
|
const event = await seedEvent({ slug: 'sofia-grad-2' });
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id,
|
|
customSlug: 'Sofia-GraduAtion-2', // mixed case
|
|
createdBy: adminId,
|
|
});
|
|
expect(row.short_slug).toBe('sofia-graduation-2');
|
|
});
|
|
|
|
it('rejects an invalid slug with INVALID_SLUG code', async () => {
|
|
const event = await seedEvent({ slug: 'invalid-test' });
|
|
await expect(service.createShortUrl({
|
|
eventId: event.id,
|
|
customSlug: 'invalid slug with spaces',
|
|
createdBy: adminId,
|
|
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
|
});
|
|
|
|
it('rejects a reserved slug with INVALID_SLUG code', async () => {
|
|
const event = await seedEvent({ slug: 'reserved-test' });
|
|
await expect(service.createShortUrl({
|
|
eventId: event.id,
|
|
customSlug: 'admin',
|
|
createdBy: adminId,
|
|
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
|
});
|
|
|
|
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
|
|
const event1 = await seedEvent({ slug: 'dup-test-1' });
|
|
const event2 = await seedEvent({ slug: 'dup-test-2' });
|
|
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
|
|
await expect(service.createShortUrl({
|
|
eventId: event2.id, customSlug: 'collide-me',
|
|
})).rejects.toMatchObject({
|
|
code: 'SLUG_TAKEN',
|
|
suggested: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
|
|
await expect(service.createShortUrl({
|
|
eventId: 9999999, customSlug: 'no-event',
|
|
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
|
|
});
|
|
});
|
|
|
|
describe('createShortUrl — auto-generated slug', () => {
|
|
it('uses event slug + year when no custom slug provided', async () => {
|
|
const event = await seedEvent({
|
|
slug: 'autogen-wedding', event_date: '2026-06-05',
|
|
});
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id,
|
|
createdBy: adminId,
|
|
});
|
|
// First-choice candidate is just the slug; takes that.
|
|
expect(row.short_slug).toBe('autogen-wedding');
|
|
});
|
|
|
|
it('falls back to slug-year when the bare slug is already taken', async () => {
|
|
// Both events SHARE the same canonical slug so the first-choice
|
|
// bare-slug candidate is burned, forcing autoGen to try the
|
|
// year-suffixed variant.
|
|
const event1 = await seedEvent({
|
|
slug: 'collide-base', event_date: '2026-07-01',
|
|
});
|
|
await service.createShortUrl({
|
|
eventId: event1.id, customSlug: 'collide-base',
|
|
});
|
|
const event2 = await seedEvent({
|
|
slug: 'collide-base-2', event_date: '2026-07-01',
|
|
});
|
|
// Force the bare candidate of event2 to also collide by burning it.
|
|
await service.createShortUrl({
|
|
eventId: event1.id, customSlug: 'collide-base-2',
|
|
});
|
|
const row = await service.createShortUrl({
|
|
eventId: event2.id, // No custom — auto-gen from event2.slug
|
|
});
|
|
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
|
|
expect(row.short_slug).toBe('collide-base-2-2026');
|
|
});
|
|
});
|
|
|
|
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
|
|
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
|
|
const event = await seedEvent({ slug: 'snapshot-off' });
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'snap-off',
|
|
});
|
|
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
|
});
|
|
|
|
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
|
|
// Persist the setting.
|
|
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
|
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
|
|
try {
|
|
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'snap-on',
|
|
});
|
|
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
|
|
|
|
// CRITICAL backward-compat invariant: now flip the setting OFF.
|
|
// Existing short URLs must still resolve to the same target_path
|
|
// they were created with — operator's existing share links don't
|
|
// silently change behaviour.
|
|
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
|
const refetched = await service.findByShortSlug('snap-on');
|
|
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
|
|
} finally {
|
|
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('findByShortSlug + listForEvent', () => {
|
|
it('returns null for an unknown slug', async () => {
|
|
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
|
|
});
|
|
|
|
it('returns null for a malformed slug (no DB hit)', async () => {
|
|
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
|
|
expect(await service.findByShortSlug('with spaces')).toBeNull();
|
|
expect(await service.findByShortSlug('')).toBeNull();
|
|
});
|
|
|
|
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
|
|
const event = await seedEvent({ slug: 'softdel-find' });
|
|
const created = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'find-deleted',
|
|
});
|
|
await service.softDelete(created.id, adminId);
|
|
const fetched = await service.findByShortSlug('find-deleted');
|
|
expect(fetched).not.toBeNull();
|
|
expect(fetched.deleted_at).toBeTruthy();
|
|
});
|
|
|
|
it('listForEvent excludes soft-deleted rows', async () => {
|
|
const event = await seedEvent({ slug: 'list-test' });
|
|
const live = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'list-live',
|
|
});
|
|
const deleted = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'list-deleted',
|
|
});
|
|
await service.softDelete(deleted.id, adminId);
|
|
const list = await service.listForEvent(event.id);
|
|
const ids = list.map((r) => r.id);
|
|
expect(ids).toContain(live.id);
|
|
expect(ids).not.toContain(deleted.id);
|
|
});
|
|
});
|
|
|
|
describe('softDelete', () => {
|
|
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
|
|
const event = await seedEvent({ slug: 'softdel-idem' });
|
|
const created = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'idem-delete',
|
|
});
|
|
expect(await service.softDelete(created.id, adminId)).toBe(true);
|
|
expect(await service.softDelete(created.id, adminId)).toBe(false);
|
|
});
|
|
|
|
it('returns false for an unknown id (caller maps to 404)', async () => {
|
|
expect(await service.softDelete(9999999, adminId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('createShortUrl after soft-delete — slug rotation', () => {
|
|
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
|
|
const event = await seedEvent({ slug: 'rotate' });
|
|
const first = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'rotate-me',
|
|
});
|
|
await service.softDelete(first.id, adminId);
|
|
// The slug is now reclaimable for a fresh row.
|
|
const second = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'rotate-me',
|
|
});
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(second.short_slug).toBe('rotate-me');
|
|
});
|
|
});
|
|
|
|
describe('recordHit', () => {
|
|
it('increments hit_count + stamps last_hit_at', async () => {
|
|
const event = await seedEvent({ slug: 'hit-counter' });
|
|
const row = await service.createShortUrl({
|
|
eventId: event.id, customSlug: 'count-me',
|
|
});
|
|
await service.recordHit(row.id);
|
|
await service.recordHit(row.id);
|
|
const fetched = await service.findByShortSlug('count-me');
|
|
expect(fetched.hit_count).toBe(2);
|
|
expect(fetched.last_hit_at).toBeTruthy();
|
|
});
|
|
|
|
it('is fire-and-forget — invalid id does not throw', async () => {
|
|
await expect(service.recordHit(9999999)).resolves.not.toThrow();
|
|
});
|
|
});
|