This commit is contained in:
@@ -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 === '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');
|
||||
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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user