feat(admin): endpoints to rotate one photo of an item (#301)

Two POST routes and the module behind them. They live on the item rather than on the draft, and that is the decision that makes the inventory editor free when it follows: an image belongs to an item whether or not a draft row exists, so the second screen to want this is the same call from a different place with no new backend at all.

A cut-out and its pristine original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then quietly un-rotate the photo, so the undo of one feature becomes a regression of another.

204 rather than 200. Rotation changes no column: the paths are identical afterwards and only the bytes differ, so there is no row worth returning, which is the same reason deleting an image is already a 204.

Only "not on this item" is a 404, and it is indistinguishable from an absent id on purpose, because an image id is a serial and this endpoint should not confirm which ones exist. Everything else stays loud as a 500, and the file is untouched in every one of those cases — rotateInPlace renames over the original only once the new file has been written.

One asymmetry is recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a failure between the two files turns the displayed one twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:46:29 -05:00
co-authored by Claude Opus 5
parent 3659608abe
commit 3891f4fd75
3 changed files with 281 additions and 0 deletions
@@ -0,0 +1,141 @@
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<Buffer> {
return sharp({
create: { width: 400, height: 200, channels: 3, background: { r: 20, g: 60, b: 120 } }
})
.jpeg()
.toBuffer();
}
async function writeUpload(name: string): Promise<string> {
await fs.writeFile(path.join(UPLOADS_DIR, name), await landscapeJpeg());
return `/uploads/${name}`;
}
async function sizeOf(storedPath: string): Promise<string> {
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/);
});
});