import request from 'supertest'; import sharp from 'sharp'; import { promises as fs } from 'fs'; import path from 'path'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; const UPLOADS_DIR = process.env.UPLOADS_DIR as string; beforeAll(async () => { await fs.mkdir(UPLOADS_DIR, { recursive: true }); }); beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); /** A 400x200 landscape JPEG — wide enough that a quarter turn is unmistakable. */ async function landscapeJpeg(): Promise { return sharp({ create: { width: 400, height: 200, channels: 3, background: { r: 20, g: 60, b: 120 } } }) .jpeg() .toBuffer(); } async function writeUpload(name: string): Promise { await fs.writeFile(path.join(UPLOADS_DIR, name), await landscapeJpeg()); return `/uploads/${name}`; } async function sizeOf(storedPath: string): Promise { const meta = await sharp(path.join(UPLOADS_DIR, path.basename(storedPath))).metadata(); return `${meta.width}x${meta.height}`; } /** An item with one photo on disk. The prefix keeps each test's file its own. */ async function seedItem(prefix: string): Promise<{ itemId: number; imageId: number }> { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name, status) VALUES ('Blue vase', 'available') RETURNING id` ); const itemId = rows[0]!.id; const imagePath = await writeUpload(`${prefix}-front.jpg`); const image = await pool.query<{ id: number }>( `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, 0) RETURNING id`, [itemId, imagePath] ); return { itemId, imageId: image.rows[0]!.id }; } describe('rotating one photo of an item', () => { it('turns the file and answers 204', async () => { const { itemId, imageId } = await seedItem('turns'); const res = await request(app).post( `/api/admin/items/${itemId}/images/${imageId}/rotate-right` ); expect(res.status).toBe(204); expect(await sizeOf('/uploads/turns-front.jpg')).toBe('200x400'); }); // The undo story. One press back is why the control offers both directions // rather than making somebody turn the same button three times, each turn a // further generation of JPEG loss. it('turns back the other way', async () => { const { itemId, imageId } = await seedItem('back'); await request(app).post(`/api/admin/items/${itemId}/images/${imageId}/rotate-right`); await request(app).post(`/api/admin/items/${itemId}/images/${imageId}/rotate-left`); expect(await sizeOf('/uploads/back-front.jpg')).toBe('400x200'); }); // Without this, Restore original would silently un-rotate the photo, turning // the undo of #281 into a regression of #301. it('turns the pristine original too', async () => { const { itemId, imageId } = await seedItem('cutout'); const originalPath = await writeUpload('cutout-original.jpg'); await pool.query(`UPDATE item_images SET original_image_path = $2 WHERE id = $1`, [ imageId, originalPath ]); const res = await request(app).post( `/api/admin/items/${itemId}/images/${imageId}/rotate-right` ); expect(res.status).toBe(204); expect(await sizeOf('/uploads/cutout-front.jpg')).toBe('200x400'); expect(await sizeOf(originalPath)).toBe('200x400'); }); // An image id is a serial, so guessing one is easy — the photo has to belong // to the item named in the path (#207). it('refuses a photo belonging to another item', async () => { const mine = await seedItem('mine'); const theirs = await seedItem('theirs'); const res = await request(app).post( `/api/admin/items/${mine.itemId}/images/${theirs.imageId}/rotate-right` ); expect(res.status).toBe(404); expect(await sizeOf('/uploads/theirs-front.jpg')).toBe('400x200'); }); it('answers 404 for an unreadable id', async () => { const { itemId } = await seedItem('unreadable'); const res = await request(app).post(`/api/admin/items/${itemId}/images/abc/rotate-right`); expect(res.status).toBe(404); }); it('answers 404 for an absent item', async () => { const res = await request(app).post('/api/admin/items/999999/images/1/rotate-left'); expect(res.status).toBe(404); }); // Reported rather than swallowed: a row pointing at a file that is not there // is a real fault, and answering "done" would hide it. it('answers 500 when the file is missing from disk', async () => { const { itemId, imageId } = await seedItem('missing'); await fs.rm(path.join(UPLOADS_DIR, 'missing-front.jpg')); const res = await request(app).post( `/api/admin/items/${itemId}/images/${imageId}/rotate-right` ); expect(res.status).toBe(500); expect(res.body.error).toMatch(/could not be rotated/); }); });