This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
|
export function validateImageMove(direction) {
|
||||||
|
if (direction !== 'up' && direction !== 'down') {
|
||||||
|
throw createApplicationError('IMAGE_MOVE_INVALID', 'Image move must be up or down.', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is the persisted display order. Return a reordered copy; boundary moves
|
||||||
|
// are no-ops. Persistence assigns contiguous positions after an actual move.
|
||||||
|
export function moveImageInOrder(images, imageId, direction) {
|
||||||
|
validateImageMove(direction);
|
||||||
|
const index = images.findIndex((image) => image.id === imageId);
|
||||||
|
const neighbor = index + (direction === 'up' ? -1 : 1);
|
||||||
|
if (index < 0 || neighbor < 0 || neighbor >= images.length) return images;
|
||||||
|
const reordered = [...images];
|
||||||
|
[reordered[index], reordered[neighbor]] = [reordered[neighbor], reordered[index]];
|
||||||
|
return reordered;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { moveImageInOrder } from '../domain/image-order.js';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -53,10 +54,42 @@ export function createInventoryImageRepository(db) {
|
|||||||
eq(inventoryImages.inventoryItemId, inventoryItemId),
|
eq(inventoryImages.inventoryItemId, inventoryItemId),
|
||||||
eq(products.sellerId, sellerId),
|
eq(products.sellerId, sellerId),
|
||||||
))
|
))
|
||||||
.orderBy(inventoryImages.sortOrder)
|
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
|
||||||
.then((rows) => rows.map(({ image }) => image));
|
.then((rows) => rows.map(({ image }) => image));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async moveForSeller(imageId, direction, sellerId) {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
// Lock the owning physical item first, serializing moves within a gallery.
|
||||||
|
const [owned] = await tx
|
||||||
|
.select({ inventoryItemId: inventoryItems.id })
|
||||||
|
.from(inventoryImages)
|
||||||
|
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
|
||||||
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
|
.where(and(eq(inventoryImages.id, imageId), eq(products.sellerId, sellerId)))
|
||||||
|
.for('update', { of: inventoryItems });
|
||||||
|
if (!owned) return null;
|
||||||
|
|
||||||
|
const images = await tx.select().from(inventoryImages)
|
||||||
|
.where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId))
|
||||||
|
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
|
||||||
|
.for('update');
|
||||||
|
const target = images.find((image) => image.id === imageId);
|
||||||
|
if (!target) return null;
|
||||||
|
const reordered = moveImageInOrder(images, imageId, direction);
|
||||||
|
if (reordered === images) return target;
|
||||||
|
const updatedAt = new Date();
|
||||||
|
for (const [sortOrder, image] of reordered.entries()) {
|
||||||
|
if (image.sortOrder !== sortOrder) {
|
||||||
|
await tx.update(inventoryImages).set({ sortOrder, updatedAt })
|
||||||
|
.where(eq(inventoryImages.id, image.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [moved] = await tx.select().from(inventoryImages).where(eq(inventoryImages.id, imageId));
|
||||||
|
return moved;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async updateOrderForSeller(imageId, sortOrder, sellerId) {
|
async updateOrderForSeller(imageId, sortOrder, sellerId) {
|
||||||
const owned = await this.findByIdForSeller(imageId, sellerId);
|
const owned = await this.findByIdForSeller(imageId, sellerId);
|
||||||
if (!owned) return null;
|
if (!owned) return null;
|
||||||
@@ -135,7 +168,7 @@ export function createInventoryImageRepository(db) {
|
|||||||
eq(inventoryImages.processingStatus, 'COMPLETED'),
|
eq(inventoryImages.processingStatus, 'COMPLETED'),
|
||||||
eq(imageDerivativeJobs.status, 'COMPLETED'),
|
eq(imageDerivativeJobs.status, 'COMPLETED'),
|
||||||
))
|
))
|
||||||
.orderBy(inventoryImages.sortOrder);
|
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
async findPublicById(imageId) {
|
async findPublicById(imageId) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
||||||
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image';
|
import { createInventoryImageSchema, moveInventoryImageSchema } from '@pokemon-inventory/validation/image';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
@@ -98,6 +98,18 @@ export async function registerAdminImageRoutes(app, options) {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.post('/images/:id/move', async (request, reply) => {
|
||||||
|
const admin = await authorizeAdminMutation(request);
|
||||||
|
const result = moveInventoryImageSchema.safeParse(request.body);
|
||||||
|
if (!result.success) return validationError(reply, result);
|
||||||
|
const image = await inventoryImageService.move({
|
||||||
|
imageId: request.params.id,
|
||||||
|
direction: result.data.direction,
|
||||||
|
sellerId: admin.sellerId,
|
||||||
|
});
|
||||||
|
return { data: toAdminInventoryImageDto(image) };
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/images/:id/order', async (request) => {
|
app.patch('/images/:id/order', async (request) => {
|
||||||
const admin = await authorizeAdminMutation(request);
|
const admin = await authorizeAdminMutation(request);
|
||||||
const image = await inventoryImageService.updateOrder({
|
const image = await inventoryImageService.updateOrder({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validateImageMove } from '../domain/image-order.js';
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
|
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async move({ imageId, direction, sellerId }) {
|
||||||
|
validateImageMove(direction);
|
||||||
|
return requireOwnedImage(inventoryImageRepository.moveForSeller(imageId, direction, sellerId));
|
||||||
|
},
|
||||||
|
|
||||||
async updateOrder({ imageId, sortOrder, sellerId }) {
|
async updateOrder({ imageId, sortOrder, sellerId }) {
|
||||||
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
|
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,3 +35,16 @@ test('manages owned image order, primary state, alt text, retry, and deletion',
|
|||||||
assert.ok(calls.some(([action]) => action === 'delete-job'));
|
assert.ok(calls.some(([action]) => action === 'delete-job'));
|
||||||
assert.ok(calls.some(([action]) => action === 'storage-delete'));
|
assert.ok(calls.some(([action]) => action === 'storage-delete'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('moves an image through the owned atomic repository operation', async () => {
|
||||||
|
const service = createInventoryImageService({ inventoryImageRepository: {
|
||||||
|
async moveForSeller(id, direction, sellerId) {
|
||||||
|
assert.equal(direction, 'down');
|
||||||
|
assert.equal(sellerId, 'seller-1');
|
||||||
|
return id === 'owned' ? { id, sortOrder: 1 } : null;
|
||||||
|
},
|
||||||
|
} });
|
||||||
|
assert.deepEqual(await service.move({ imageId: 'owned', direction: 'down', sellerId: 'seller-1' }), { id: 'owned', sortOrder: 1 });
|
||||||
|
await assert.rejects(() => service.move({ imageId: 'missing', direction: 'down', sellerId: 'seller-1' }), { code: 'IMAGE_NOT_FOUND' });
|
||||||
|
await assert.rejects(() => service.move({ imageId: 'owned', direction: 'left', sellerId: 'seller-1' }), { code: 'IMAGE_MOVE_INVALID' });
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { moveImageInOrder } from '../../src/modules/inventory/domain/image-order.js';
|
||||||
|
|
||||||
|
test('moves one image next to its neighbor without changing the input order', () => {
|
||||||
|
const images = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
assert.deepEqual(moveImageInOrder(images, 'b', 'up').map(({ id }) => id), ['b', 'a', 'c']);
|
||||||
|
assert.deepEqual(moveImageInOrder(images, 'b', 'down').map(({ id }) => id), ['a', 'c', 'b']);
|
||||||
|
assert.deepEqual(images.map(({ id }) => id), ['a', 'b', 'c']);
|
||||||
|
assert.equal(moveImageInOrder(images, 'a', 'up'), images);
|
||||||
|
assert.equal(moveImageInOrder(images, 'c', 'down'), images);
|
||||||
|
assert.throws(() => moveImageInOrder(images, 'a', 'left'), { code: 'IMAGE_MOVE_INVALID' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildApp } from '../../src/app/build-app.js';
|
||||||
|
|
||||||
|
test('validates and authorizes image moves and exposes only the image DTO', async (context) => {
|
||||||
|
const calls = [];
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
authorizeUnsafeRequest: async () => { calls.push('csrf'); },
|
||||||
|
inventoryImageService: { async move(input) { calls.push(input); return { id: input.imageId, sortOrder: 1, privateData: 'hidden' }; } },
|
||||||
|
});
|
||||||
|
context.after(() => app.close());
|
||||||
|
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'down' } });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(calls, ['csrf', { imageId: 'image-1', direction: 'down', sellerId: 'seller-1' }]);
|
||||||
|
assert.equal(response.json().data.sortOrder, 1);
|
||||||
|
assert.equal(response.json().data.privateData, undefined);
|
||||||
|
for (const payload of [{}, { direction: 'left' }, { direction: 'up', sellerId: 'other' }]) {
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload })).statusCode, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unauthenticated and CSRF-blocked moves without calling the service', async (context) => {
|
||||||
|
for (const csrf of [false, true]) {
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => csrf ? { sellerId: 'seller-1' } : null,
|
||||||
|
authorizeUnsafeRequest: async () => { throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); },
|
||||||
|
inventoryImageService: { async move() { assert.fail('unauthorized move'); } },
|
||||||
|
});
|
||||||
|
context.after(() => app.close());
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'up' } })).statusCode, csrf ? 403 : 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -273,3 +273,65 @@ test('reads and updates listings only through the owning seller scope', async ()
|
|||||||
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
|
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
|
||||||
assert.equal(archivedListing.status, 'ARCHIVED');
|
assert.equal(archivedListing.status, 'ARCHIVED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createImageOrderFixture() {
|
||||||
|
const { seller, inventoryItem } = await createInventoryFixture();
|
||||||
|
const images = [];
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
images.push(await inventoryImageRepository.createForSeller({
|
||||||
|
inventoryItemId: inventoryItem.id, storageKey: `move-${index}.jpg`, originalFilename: `move-${index}.jpg`,
|
||||||
|
mimeType: 'image/jpeg', fileSize: 100, width: 10, height: 10, sortOrder: 0,
|
||||||
|
isPrimary: index === 0,
|
||||||
|
}, seller.id));
|
||||||
|
}
|
||||||
|
return { seller, inventoryItem, images: await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('moves images atomically with tied positions, ownership checks and boundary no-ops', async () => {
|
||||||
|
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||||
|
const read = () => inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
|
||||||
|
assert.equal(await inventoryImageRepository.moveForSeller(images[0].id, 'down', '00000000-0000-0000-0000-000000000000'), null);
|
||||||
|
assert.equal(await inventoryImageRepository.moveForSeller('00000000-0000-0000-0000-000000000000', 'down', seller.id), null);
|
||||||
|
assert.deepEqual(await read(), images);
|
||||||
|
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
|
||||||
|
assert.deepEqual(await read(), images);
|
||||||
|
await inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id);
|
||||||
|
const moved = await read();
|
||||||
|
assert.deepEqual(moved.map(({ id }) => id), [images[1].id, images[0].id, images[2].id]);
|
||||||
|
assert.deepEqual(moved.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
|
||||||
|
assert.equal(moved.find(({ id }) => id === images[0].id).isPrimary, images[0].isPrimary);
|
||||||
|
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
|
||||||
|
assert.deepEqual((await read()).map(({ id }) => id), images.map(({ id }) => id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back every position if an image move fails after its first write', async () => {
|
||||||
|
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||||
|
let writes = 0;
|
||||||
|
const failingRepository = createInventoryImageRepository({
|
||||||
|
transaction: (action) => database.db.transaction((tx) => action(new Proxy(tx, {
|
||||||
|
get(target, property) {
|
||||||
|
if (property === 'update') return (...args) => {
|
||||||
|
writes += 1;
|
||||||
|
if (writes === 2) throw new Error('Injected second write failure');
|
||||||
|
return target.update(...args);
|
||||||
|
};
|
||||||
|
const value = target[property];
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
}))),
|
||||||
|
});
|
||||||
|
await assert.rejects(() => failingRepository.moveForSeller(images[0].id, 'down', seller.id), /Injected second write failure/);
|
||||||
|
assert.equal(writes, 2);
|
||||||
|
assert.deepEqual(await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id), images);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializes concurrent moves within the same gallery', async () => {
|
||||||
|
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||||
|
await Promise.all([
|
||||||
|
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
|
||||||
|
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
|
||||||
|
]);
|
||||||
|
const result = await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
|
||||||
|
assert.deepEqual(result.map(({ id }) => id), [images[1].id, images[2].id, images[0].id]);
|
||||||
|
assert.deepEqual(result.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref } from 'vue';
|
|||||||
import {
|
import {
|
||||||
deleteImage,
|
deleteImage,
|
||||||
listAdminImages,
|
listAdminImages,
|
||||||
reorderImage,
|
moveImage,
|
||||||
retryImageProcessing,
|
retryImageProcessing,
|
||||||
setPrimaryImage,
|
setPrimaryImage,
|
||||||
updateImageAltText,
|
updateImageAltText,
|
||||||
@@ -76,18 +76,6 @@ export function useAdminImages(inventoryItemId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveImage(imageId, direction) {
|
|
||||||
const ordered = [...images.value].sort((left, right) => left.sortOrder - right.sortOrder);
|
|
||||||
const index = ordered.findIndex((image) => image.id === imageId);
|
|
||||||
const neighborIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
if (index === -1 || neighborIndex < 0 || neighborIndex >= ordered.length) return;
|
|
||||||
const current = ordered[index];
|
|
||||||
const neighbor = ordered[neighborIndex];
|
|
||||||
await apply(async (token) => {
|
|
||||||
await reorderImage(current.id, neighbor.sortOrder, token);
|
|
||||||
await reorderImage(neighbor.id, current.sortOrder, token);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
images,
|
images,
|
||||||
@@ -98,7 +86,7 @@ export function useAdminImages(inventoryItemId) {
|
|||||||
hasLoaded,
|
hasLoaded,
|
||||||
load,
|
load,
|
||||||
upload,
|
upload,
|
||||||
move: moveImage,
|
move: (imageId, direction) => apply((token) => moveImage(imageId, direction, token)),
|
||||||
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
|
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
|
||||||
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
|
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
|
||||||
retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)),
|
retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
let reads = 0;
|
let reads = 0;
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
|
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
|
||||||
|
if (scenario === 'move' || scenario === 'move-error') images.push({ id: 'image-2', altText: 'Back of card', sortOrder: 1, isPrimary: false, processingStatus: 'COMPLETED' });
|
||||||
const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } });
|
const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } });
|
||||||
const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 });
|
const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 });
|
||||||
globalThis.fetch = async (url, options = {}) => {
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
@@ -21,6 +22,15 @@ export default {
|
|||||||
return reply(images);
|
return reply(images);
|
||||||
}
|
}
|
||||||
writes += 1;
|
writes += 1;
|
||||||
|
if (scenario === 'move' || scenario === 'move-error') {
|
||||||
|
await expect(String(url)).toMatch(/\/images\/image-[12]\/move$/);
|
||||||
|
await expect(options.method).toBe('POST');
|
||||||
|
const { direction } = JSON.parse(options.body);
|
||||||
|
await expect(['up', 'down']).toContain(direction);
|
||||||
|
if (scenario === 'move-error' && writes === 1) return fail('Move failed.');
|
||||||
|
images = [...images].reverse().map((image, sortOrder) => ({ ...image, sortOrder }));
|
||||||
|
return reply(images[0]);
|
||||||
|
}
|
||||||
if (scenario === 'saving') return new Promise(() => {});
|
if (scenario === 'saving') return new Promise(() => {});
|
||||||
if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.');
|
if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.');
|
||||||
if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }];
|
if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }];
|
||||||
@@ -137,3 +147,29 @@ export const PartialUploadFailure = {
|
|||||||
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
|
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AtomicMove = {
|
||||||
|
parameters: { imageScenario: 'move' },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const down = await canvas.findAllByRole('button', { name: 'Move down' });
|
||||||
|
await userEvent.click(down[0]);
|
||||||
|
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
|
||||||
|
await userEvent.click(canvas.getAllByRole('button', { name: 'Move up' })[1]);
|
||||||
|
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card'));
|
||||||
|
await expect(canvas.getAllByRole('button', { name: 'Move up' })[0]).toBeDisabled();
|
||||||
|
await expect(canvas.getAllByRole('button', { name: 'Move down' })[1]).toBeDisabled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const FailedAtomicMove = {
|
||||||
|
parameters: { imageScenario: 'move-error' },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click((await canvas.findAllByRole('button', { name: 'Move down' }))[0]);
|
||||||
|
await expect(await canvas.findByRole('alert')).toHaveTextContent('Move failed.');
|
||||||
|
await waitFor(() => expect(canvas.getAllByRole('button', { name: 'Move down' })[0]).toBeEnabled());
|
||||||
|
await expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card');
|
||||||
|
await userEvent.click(canvas.getAllByRole('button', { name: 'Move down' })[0]);
|
||||||
|
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -80,3 +80,11 @@ export async function listAdminImages(inventoryItemId) {
|
|||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveImage(imageId, direction, csrfToken) {
|
||||||
|
return request(`/admin/images/${encodeURIComponent(imageId)}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-csrf-token': csrfToken },
|
||||||
|
body: JSON.stringify({ direction }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+10
@@ -197,3 +197,13 @@ Seller contact removal uses the same explicit-empty-string convention:
|
|||||||
whitespace-only strings clear the contact. Omitted contact fields retain their
|
whitespace-only strings clear the contact. Omitted contact fields retain their
|
||||||
saved values. `null` is not a settings input. Public listing/product detail DTOs
|
saved values. `null` is not a settings input. Public listing/product detail DTOs
|
||||||
return `null` for empty or absent contacts, so no contact link is rendered.
|
return `null` for empty or absent contacts, so no contact link is rendered.
|
||||||
|
|
||||||
|
`POST /api/v1/admin/images/:id/move` accepts exactly `{ "direction": "up" }`
|
||||||
|
or `{ "direction": "down" }`. Authentication and session CSRF validation are
|
||||||
|
required; ownership comes from the authenticated Seller. The server selects the
|
||||||
|
adjacent image within the same physical Inventory Item and commits all resulting
|
||||||
|
positions atomically. At the first/last position the corresponding move is a
|
||||||
|
successful no-op. Missing/unowned images return `404 IMAGE_NOT_FOUND`; invalid
|
||||||
|
bodies return `400 VALIDATION_ERROR`. The response uses the existing admin image
|
||||||
|
DTO. The legacy absolute `/order` endpoint remains available, but UI moves use
|
||||||
|
this new operation exclusively.
|
||||||
|
|||||||
@@ -119,3 +119,13 @@ retain the last loaded gallery but disable mutations until an explicit reload
|
|||||||
succeeds. Failed initial loads show a reload action instead of an empty manager.
|
succeeds. Failed initial loads show a reload action instead of an empty manager.
|
||||||
Reload restores keyboard focus to the manager or the retry control. Pending
|
Reload restores keyboard focus to the manager or the retry control. Pending
|
||||||
operations block conflicting writes and uploads, including drag-and-drop.
|
operations block conflicting writes and uploads, including drag-and-drop.
|
||||||
|
|
||||||
|
## Atomic adjacent moves
|
||||||
|
|
||||||
|
Move up/down uses one server operation. The repository locks the owning Inventory
|
||||||
|
Item and its images, selects current order by `sortOrder`, `createdAt`, then `id`,
|
||||||
|
and uses the domain move rule to choose the new order. An actual move assigns
|
||||||
|
contiguous zero-based positions, resolving tied legacy/upload positions, within
|
||||||
|
one PostgreSQL transaction. Any failure rolls back all changes. Concurrent moves
|
||||||
|
within one item serialize; primary selection and image metadata are unchanged.
|
||||||
|
Boundary moves are no-ops. No database migration is required.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Status
|
# Implementation Status
|
||||||
|
|
||||||
Last updated: 2026-09-10
|
Last updated: 2026-09-11
|
||||||
|
|
||||||
## Resume Here
|
## Resume Here
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ docker context use orbstack
|
|||||||
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
||||||
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
||||||
|
|
||||||
Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 application, and 65 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
Verified locally: `npm test` passes 171 tests with no failures (24 domain, 80 application, and 67 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
||||||
|
|
||||||
### Public Seller Description
|
### Public Seller Description
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
|
|||||||
- Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery.
|
- Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery.
|
||||||
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged.
|
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged.
|
||||||
|
|
||||||
### Latest Vertical Slice: Image Manager Error Recovery
|
### Image Manager Error Recovery
|
||||||
|
|
||||||
- Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds.
|
- Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds.
|
||||||
- Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops.
|
- Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops.
|
||||||
@@ -177,7 +177,14 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
|
|||||||
- Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color.
|
- Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color.
|
||||||
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes.
|
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes.
|
||||||
|
|
||||||
Next bounded slice: atomic image reordering. Move up/down currently sends two separate order updates; failure between them can leave a partial reorder. Add a seller-scoped API operation that updates both positions transactionally, connect the controls to it, and verify rollback and ownership with integration tests.
|
### Latest Vertical Slice: Atomic Image Reordering
|
||||||
|
|
||||||
|
- Move up/down now calls a single authenticated, CSRF-protected `POST /api/v1/admin/images/:id/move` operation. The server derives the adjacent image from current persisted order.
|
||||||
|
- The repository locks the owning Inventory Item and its images, applies the domain move rule, and commits contiguous positions in one transaction. Ties are resolved deterministically; boundary moves are no-ops; primary state is preserved.
|
||||||
|
- Tests cover invalid requests, ownership, CSRF, missing images, boundary moves, tied positions, rollback after the first write, concurrent moves, and UI success/failure recovery.
|
||||||
|
- Validation: 171 API tests, 26 isolated PostgreSQL/Redis integration tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 126 tests. No migration required.
|
||||||
|
|
||||||
|
Next bounded slice: image mutation payload validation. The legacy absolute-order and alt-text endpoints currently pass unchecked request fields into the service. Add strict schemas and domain validation for these updates, provide clear UI validation feedback, and test malformed/oversized requests without changing valid image workflows.
|
||||||
|
|
||||||
## Next Milestone
|
## Next Milestone
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ export const createInventoryImageSchema = z.object({
|
|||||||
altText: z.string().trim().max(1000).optional(),
|
altText: z.string().trim().max(1000).optional(),
|
||||||
sortOrder: z.int().nonnegative().optional(),
|
sortOrder: z.int().nonnegative().optional(),
|
||||||
isPrimary: z.boolean().optional(),
|
isPrimary: z.boolean().optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
export const moveInventoryImageSchema = z.object({
|
||||||
|
direction: z.enum(['up', 'down']),
|
||||||
|
}).strict();
|
||||||
|
|||||||
Reference in New Issue
Block a user