|
| 1 | +import { describe, it, expect, beforeAll } from 'vitest'; |
| 2 | +import { testRequest } from './helpers'; |
| 3 | +import { createTestDb, makeDbEnv, seedSettings, seedMenu, seedCategory, seedEntry, signTestJwt, installJwksMock } from './helpers/db'; |
| 4 | + |
| 5 | +beforeAll(() => installJwksMock()); |
| 6 | + |
| 7 | +const ADMIN_UID = 'admin-1'; |
| 8 | +const R2_BASE = 'https://cdn.test.local'; |
| 9 | + |
| 10 | +// Minimal in-memory R2 stub recording put/delete calls. |
| 11 | +function makeR2() { |
| 12 | + const objects = new Map<string, unknown>(); |
| 13 | + const calls = { put: [] as string[], delete: [] as string[] }; |
| 14 | + const bucket = { |
| 15 | + async put(key: string, value: unknown) { |
| 16 | + calls.put.push(key); |
| 17 | + objects.set(key, value); |
| 18 | + return { key } as R2Object; |
| 19 | + }, |
| 20 | + async delete(key: string) { |
| 21 | + calls.delete.push(key); |
| 22 | + objects.delete(key); |
| 23 | + }, |
| 24 | + async get() { return null; }, |
| 25 | + async head() { return null; }, |
| 26 | + async list() { return { objects: [], truncated: false } as unknown as R2Objects; }, |
| 27 | + } as unknown as R2Bucket; |
| 28 | + return { bucket, objects, calls }; |
| 29 | +} |
| 30 | + |
| 31 | +function jpegBody(): ArrayBuffer { |
| 32 | + const buf = new Uint8Array(64); |
| 33 | + buf[0] = 0xff; buf[1] = 0xd8; buf[2] = 0xff; |
| 34 | + return buf.buffer; |
| 35 | +} |
| 36 | + |
| 37 | +async function adminEnv(r2?: ReturnType<typeof makeR2>, db = createTestDb()) { |
| 38 | + seedSettings(db); |
| 39 | + const overrides: Record<string, unknown> = { ADMIN_EMAILS: ADMIN_UID }; |
| 40 | + if (r2) { |
| 41 | + overrides.PUBLIC_MENU_BUCKET = r2.bucket; |
| 42 | + overrides.R2_PUBLIC_URL = R2_BASE; |
| 43 | + } |
| 44 | + const env = makeDbEnv(db, overrides); |
| 45 | + const token = await signTestJwt(ADMIN_UID); |
| 46 | + return { db, env, headers: { 'Cf-Access-Jwt-Assertion': token } }; |
| 47 | +} |
| 48 | + |
| 49 | +describe('POST /admin/entries/:id/image', () => { |
| 50 | + it('puts to R2 and stores image_url on the entry', async () => { |
| 51 | + const r2 = makeR2(); |
| 52 | + const { db, env, headers } = await adminEnv(r2); |
| 53 | + seedCategory(db, 'cat-1'); |
| 54 | + seedEntry(db, 'entry-1', 'cat-1'); |
| 55 | + |
| 56 | + const res = await testRequest('/admin/entries/entry-1/image', { |
| 57 | + method: 'POST', headers, env, body: jpegBody(), |
| 58 | + }); |
| 59 | + expect(res.status).toBe(200); |
| 60 | + const body = await res.json() as { imageUrl: string }; |
| 61 | + expect(r2.calls.put.some(k => k.startsWith('images/entries/entry-1-'))).toBe(true); |
| 62 | + expect(body.imageUrl.startsWith(`${R2_BASE}/images/entries/entry-1-`)).toBe(true); |
| 63 | + |
| 64 | + const row = db.raw.prepare('SELECT image_url FROM menu_entries WHERE id = ?').get('entry-1') as { image_url: string }; |
| 65 | + expect(row.image_url).toBe(body.imageUrl); |
| 66 | + }); |
| 67 | + |
| 68 | + it('returns 404 for a missing entry', async () => { |
| 69 | + const r2 = makeR2(); |
| 70 | + const { env, headers } = await adminEnv(r2); |
| 71 | + const res = await testRequest('/admin/entries/nope/image', { |
| 72 | + method: 'POST', headers, env, body: jpegBody(), |
| 73 | + }); |
| 74 | + expect(res.status).toBe(404); |
| 75 | + }); |
| 76 | +}); |
| 77 | + |
| 78 | +describe('DELETE /admin/entries/:id/image', () => { |
| 79 | + it('deletes the old R2 object and nulls image_url', async () => { |
| 80 | + const r2 = makeR2(); |
| 81 | + const { db, env, headers } = await adminEnv(r2); |
| 82 | + seedCategory(db, 'cat-1'); |
| 83 | + seedEntry(db, 'entry-1', 'cat-1'); |
| 84 | + const key = 'images/entries/entry-1-123.jpg'; |
| 85 | + db.raw.prepare('UPDATE menu_entries SET image_url = ? WHERE id = ?').run(`${R2_BASE}/${key}`, 'entry-1'); |
| 86 | + |
| 87 | + const res = await testRequest('/admin/entries/entry-1/image', { method: 'DELETE', headers, env }); |
| 88 | + expect(res.status).toBe(200); |
| 89 | + expect(r2.calls.delete).toContain(key); |
| 90 | + const row = db.raw.prepare('SELECT image_url FROM menu_entries WHERE id = ?').get('entry-1') as { image_url: string | null }; |
| 91 | + expect(row.image_url).toBeNull(); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +describe('POST /admin/header-image', () => { |
| 96 | + it('uploads and stores headerImage on settings.info', async () => { |
| 97 | + const r2 = makeR2(); |
| 98 | + const { db, env, headers } = await adminEnv(r2); |
| 99 | + |
| 100 | + const res = await testRequest('/admin/header-image', { |
| 101 | + method: 'POST', headers, env, body: jpegBody(), |
| 102 | + }); |
| 103 | + expect(res.status).toBe(200); |
| 104 | + const body = await res.json() as { imageUrl: string }; |
| 105 | + expect(r2.calls.put.some(k => k.startsWith('images/settings/header-'))).toBe(true); |
| 106 | + |
| 107 | + const row = db.raw.prepare('SELECT info FROM settings WHERE id = 1').get() as { info: string }; |
| 108 | + expect(JSON.parse(row.info).headerImage).toBe(body.imageUrl); |
| 109 | + }); |
| 110 | +}); |
| 111 | + |
| 112 | +describe('POST /admin/promotion-image', () => { |
| 113 | + it('uploads and stores url on settings.promotion_alert', async () => { |
| 114 | + const r2 = makeR2(); |
| 115 | + const { db, env, headers } = await adminEnv(r2); |
| 116 | + |
| 117 | + const res = await testRequest('/admin/promotion-image', { |
| 118 | + method: 'POST', headers, env, body: jpegBody(), |
| 119 | + }); |
| 120 | + expect(res.status).toBe(200); |
| 121 | + const body = await res.json() as { imageUrl: string }; |
| 122 | + |
| 123 | + const row = db.raw.prepare('SELECT promotion_alert FROM settings WHERE id = 1').get() as { promotion_alert: string }; |
| 124 | + expect(JSON.parse(row.promotion_alert).url).toBe(body.imageUrl); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('DELETE /admin/entries/:id removes its R2 image', () => { |
| 129 | + it('deletes the entry image object on entry delete', async () => { |
| 130 | + const r2 = makeR2(); |
| 131 | + const { db, env, headers } = await adminEnv(r2); |
| 132 | + seedCategory(db, 'cat-1'); |
| 133 | + seedEntry(db, 'entry-1', 'cat-1'); |
| 134 | + const key = 'images/entries/entry-1-9.jpg'; |
| 135 | + db.raw.prepare('UPDATE menu_entries SET image_url = ? WHERE id = ?').run(`${R2_BASE}/${key}`, 'entry-1'); |
| 136 | + |
| 137 | + const res = await testRequest('/admin/entries/entry-1', { method: 'DELETE', headers, env }); |
| 138 | + expect(res.status).toBe(200); |
| 139 | + expect(r2.calls.delete).toContain(key); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +describe('DELETE /admin/menus/:menuId FK cascade', () => { |
| 144 | + it('removes the menu and its memberships, keeps the entry', async () => { |
| 145 | + const { db, env, headers } = await adminEnv(); |
| 146 | + seedMenu(db, 'm-1', 'food'); |
| 147 | + seedCategory(db, 'cat-1'); |
| 148 | + seedEntry(db, 'entry-1', 'cat-1'); |
| 149 | + db.raw.prepare('INSERT INTO menu_entry_memberships (menu_id, entry_id) VALUES (?, ?)').run('m-1', 'entry-1'); |
| 150 | + |
| 151 | + const res = await testRequest('/admin/menus/m-1', { method: 'DELETE', headers, env }); |
| 152 | + expect(res.status).toBe(200); |
| 153 | + expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menus').get()).toEqual({ n: 0 }); |
| 154 | + expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menu_entry_memberships').get()).toEqual({ n: 0 }); |
| 155 | + expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menu_entries').get()).toEqual({ n: 1 }); |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe('PUT /admin/hours', () => { |
| 160 | + it('persists a valid opening schedule', async () => { |
| 161 | + const { db, env, headers } = await adminEnv(); |
| 162 | + const openingSchedule = { |
| 163 | + open: true, |
| 164 | + minWaitSlot: 0, |
| 165 | + slotDuration: 30, |
| 166 | + maxDaysLookAhead: 7, |
| 167 | + schedule: [[{ start: '09:00', end: '12:00' }], [], [], [], [], [], []], |
| 168 | + }; |
| 169 | + const res = await testRequest('/admin/hours', { |
| 170 | + method: 'PUT', headers, env, body: { openingSchedule }, |
| 171 | + }); |
| 172 | + expect(res.status).toBe(200); |
| 173 | + const row = db.raw.prepare('SELECT opening_schedule FROM settings WHERE id = 1').get() as { opening_schedule: string }; |
| 174 | + expect(JSON.parse(row.opening_schedule)).toEqual(openingSchedule); |
| 175 | + }); |
| 176 | + |
| 177 | + it('rejects a malformed schedule with 400', async () => { |
| 178 | + const { env, headers } = await adminEnv(); |
| 179 | + const res = await testRequest('/admin/hours', { |
| 180 | + method: 'PUT', headers, env, body: { openingSchedule: { open: true } }, |
| 181 | + }); |
| 182 | + expect(res.status).toBe(400); |
| 183 | + }); |
| 184 | +}); |
| 185 | + |
| 186 | +describe('POST /admin/entries/:id/move (404 path)', () => { |
| 187 | + it('returns 404 when target category is missing', async () => { |
| 188 | + const { db, env, headers } = await adminEnv(); |
| 189 | + seedCategory(db, 'cat-1'); |
| 190 | + seedEntry(db, 'entry-1', 'cat-1'); |
| 191 | + const res = await testRequest('/admin/entries/entry-1/move', { |
| 192 | + method: 'POST', headers, env, body: { targetCategoryId: 'ghost' }, |
| 193 | + }); |
| 194 | + expect(res.status).toBe(404); |
| 195 | + }); |
| 196 | +}); |
| 197 | + |
| 198 | +describe('PUT /admin/publication validation', () => { |
| 199 | + it('rejects a malformed body with 400 instead of 500', async () => { |
| 200 | + const { env, headers } = await adminEnv(); |
| 201 | + const res = await testRequest('/admin/publication', { |
| 202 | + method: 'PUT', headers, env, body: { published: 'yes' }, |
| 203 | + }); |
| 204 | + expect(res.status).toBe(400); |
| 205 | + }); |
| 206 | +}); |
0 commit comments