diff --git a/backend/__tests__/integration/galleryShortUrlRoute.test.js b/backend/__tests__/integration/galleryShortUrlRoute.test.js
new file mode 100644
index 00000000..fd157c84
--- /dev/null
+++ b/backend/__tests__/integration/galleryShortUrlRoute.test.js
@@ -0,0 +1,231 @@
+/**
+ * HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
+ *
+ * Verifies the contract the public route is expected to honour:
+ * - Browser UA → 302 to target_path
+ * - Social crawler UA → 200 with OG , canonical = /s/
+ * - Soft-deleted slug → 410 Gone (intentional-delete signal)
+ * - Unknown slug → 404 Not Found
+ * - Hit count increments after successful resolutions (both shapes)
+ *
+ * Mirrors the production server.js wiring but doesn't load the whole
+ * server — the surrounding middleware (CORS, helmet, rate limiters)
+ * isn't part of this route's contract.
+ */
+const express = require('express');
+const request = require('supertest');
+
+const { bootCrmDb } = require('./helpers/crmDb');
+
+jest.setTimeout(60000);
+
+let db; let cleanup; let service; let app;
+
+beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+
+ // Persist a business_profile + business_name so buildOgMetadata's
+ // settings-based fields populate consistently.
+ const { upsertAppSetting } = require('../../src/utils/appSettings');
+ await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
+
+ service = require('../../src/services/galleryShortUrlService');
+ const {
+ isSocialCrawler, buildOgMetadata, renderOgHtml,
+ } = require('../../src/services/galleryOgService');
+
+ app = express();
+ app.get('/s/:shortSlug', async (req, res) => {
+ try {
+ const row = await service.findByShortSlug(req.params.shortSlug);
+ if (!row) return res.status(404).type('text/plain').send('Short URL not found');
+ if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
+
+ if (isSocialCrawler(req.get('user-agent'))) {
+ const event = await db('events').where({ id: row.event_id }).first('slug');
+ if (event?.slug) {
+ const meta = await buildOgMetadata(event.slug, req.originalUrl);
+ const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
+ meta.url = `${base}/s/${row.short_slug}`;
+ res.set('Cache-Control', 'public, max-age=300');
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.send(renderOgHtml(meta));
+ service.recordHit(row.id).catch(() => {});
+ return;
+ }
+ return res.status(410).type('text/plain').send('Short URL points at a deleted event');
+ }
+
+ service.recordHit(row.id).catch(() => {});
+ return res.redirect(302, row.target_path);
+ } catch (err) {
+ return res.status(500).type('text/plain').send(err.message);
+ }
+ });
+}, 120000);
+
+afterAll(async () => { if (cleanup) await cleanup(); });
+
+async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
+ const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
+ const [eventId] = await db('events').insert({
+ slug,
+ event_type: 'wedding',
+ event_name: 'Test Event',
+ event_date: '2026-06-05',
+ password_hash: 'x',
+ expires_at: farFuture,
+ is_active: true,
+ is_archived: false,
+ share_link: slug,
+ share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
+ welcome_message: null,
+ });
+ const row = await service.createShortUrl({
+ eventId, customSlug: shortSlug,
+ });
+ return { eventId, shortUrl: row };
+}
+
+// User-agent strings the production `isSocialCrawler` helper matches.
+// Snapshot known-true samples here so the test stays in sync if the
+// helper's allowlist evolves.
+const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
+const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
+const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
+
+describe('GET /s/:shortSlug — browser (302 redirect)', () => {
+ it('redirects to the snapshotted target_path with a 302', async () => {
+ const { shortUrl } = await seedEventAndShortUrl({
+ slug: 'browser-redirect', shortSlug: 'go-here',
+ });
+ const res = await request(app)
+ .get('/s/go-here')
+ .set('User-Agent', BROWSER_UA);
+ expect(res.status).toBe(302);
+ expect(res.headers.location).toBe(shortUrl.target_path);
+ expect(res.headers.location).toMatch(/^\/gallery\//);
+ });
+
+ it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
+ await seedEventAndShortUrl({
+ slug: 'hit-browser', shortSlug: 'hit-from-browser',
+ });
+ await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
+ await new Promise((r) => setTimeout(r, 50));
+ const row = await service.findByShortSlug('hit-from-browser');
+ expect(row.hit_count).toBe(1);
+ expect(row.last_hit_at).toBeTruthy();
+ });
+});
+
+describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
+ it('returns 200 with OG HTML for WhatsApp UA', async () => {
+ await seedEventAndShortUrl({
+ slug: 'whatsapp-og', shortSlug: 'wa-preview',
+ });
+ const res = await request(app)
+ .get('/s/wa-preview')
+ .set('User-Agent', BOT_UA_WHATSAPP);
+ expect(res.status).toBe(200);
+ expect(res.headers['content-type']).toMatch(/text\/html/);
+ expect(res.text).toContain(', not the underlying gallery URL', async () => {
+ await seedEventAndShortUrl({
+ slug: 'canonical-test', shortSlug: 'canonical-short',
+ });
+ const res = await request(app)
+ .get('/s/canonical-short')
+ .set('User-Agent', BOT_UA_FACEBOOK);
+ expect(res.status).toBe(200);
+ // The og:url meta tag must contain the short-URL path, not the
+ // /gallery/ path — this is the cache-key invariant from #699.
+ expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
+ expect(res.text).not.toMatch(
+ /property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
+ );
+ });
+
+ it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
+ await seedEventAndShortUrl({
+ slug: 'cache-header', shortSlug: 'cache-test',
+ });
+ const res = await request(app)
+ .get('/s/cache-test')
+ .set('User-Agent', BOT_UA_WHATSAPP);
+ expect(res.headers['cache-control']).toMatch(/public/);
+ expect(res.headers['cache-control']).toMatch(/max-age=300/);
+ });
+
+ it('increments hit_count on a crawler hit as well', async () => {
+ await seedEventAndShortUrl({
+ slug: 'hit-bot', shortSlug: 'hit-from-bot',
+ });
+ await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
+ await new Promise((r) => setTimeout(r, 50));
+ const row = await service.findByShortSlug('hit-from-bot');
+ expect(row.hit_count).toBe(1);
+ });
+});
+
+describe('GET /s/:shortSlug — error states', () => {
+ it('404 for an unknown slug', async () => {
+ const res = await request(app)
+ .get('/s/never-existed')
+ .set('User-Agent', BROWSER_UA);
+ expect(res.status).toBe(404);
+ });
+
+ it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
+ const { shortUrl } = await seedEventAndShortUrl({
+ slug: 'gone-test', shortSlug: 'gone-slug',
+ });
+ await service.softDelete(shortUrl.id, null);
+ const res = await request(app)
+ .get('/s/gone-slug')
+ .set('User-Agent', BROWSER_UA);
+ expect(res.status).toBe(410);
+ });
+
+ it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
+ const { eventId } = await seedEventAndShortUrl({
+ slug: 'orphan-test', shortSlug: 'orphan-slug',
+ });
+ // Hard-delete the event row (FK CASCADE would normally clean up the
+ // short URL too — but if CASCADE didn't fire for whatever reason
+ // (e.g. SQLite foreign_keys pragma off in a particular runtime), the
+ // resolver should still degrade safely).
+ // SQLite's foreign_keys pragma is OFF by default; the migration
+ // doesn't toggle it, so this delete leaves the short URL row.
+ await db('events').where({ id: eventId }).delete();
+ const res = await request(app)
+ .get('/s/orphan-slug')
+ .set('User-Agent', BOT_UA_WHATSAPP);
+ expect(res.status).toBe(410);
+ });
+
+ it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
+ const res = await request(app)
+ .get('/s/UPPER_CASE')
+ .set('User-Agent', BROWSER_UA);
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('Regression — existing URL paths must still respond the same', () => {
+ // The /s/* namespace is additive: it must NOT shadow /gallery/*
+ // or any of the OG routes. We don't load the whole app here, but we
+ // can at least pin that the route param doesn't accept slashes —
+ // i.e. /s/foo/bar must NOT be matched by our handler.
+ it('the /s/:shortSlug route does not match nested paths', async () => {
+ const res = await request(app)
+ .get('/s/foo/bar')
+ .set('User-Agent', BROWSER_UA);
+ // Express returns its default 404 when no route matches the path.
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/backend/__tests__/integration/galleryShortUrls.test.js b/backend/__tests__/integration/galleryShortUrls.test.js
new file mode 100644
index 00000000..cc3b40ec
--- /dev/null
+++ b/backend/__tests__/integration/galleryShortUrls.test.js
@@ -0,0 +1,282 @@
+/**
+ * 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/ 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/ 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();
+ });
+});
diff --git a/backend/__tests__/utils/galleryShortUrlValidation.test.js b/backend/__tests__/utils/galleryShortUrlValidation.test.js
new file mode 100644
index 00000000..cc66d1e2
--- /dev/null
+++ b/backend/__tests__/utils/galleryShortUrlValidation.test.js
@@ -0,0 +1,120 @@
+/**
+ * Pure-function tests for the slug validator in galleryShortUrlService.
+ * The validator is the security boundary for the `/s/` 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