feat: enforce per-item image count limits
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-11 00:39:27 -04:00
parent e6510c0317
commit f54223966c
7 changed files with 62 additions and 6 deletions
+1
View File
@@ -198,6 +198,7 @@ async function startServer() {
imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository,
inventoryImageRepository,
sellerSettingsRepository,
}),
listingService: createListingService({
listingRepository,
@@ -1,5 +1,6 @@
import { moveImageInOrder } from '../domain/image-order.js';
import { and, eq, inArray } from 'drizzle-orm';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import {
imageDerivativeJobs,
@@ -24,6 +25,24 @@ export function createInventoryImageRepository(db) {
return createdImage;
},
async createForSellerWithLimit(image, sellerId, maxImages) {
return db.transaction(async (tx) => {
const [ownedItem] = await tx.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!ownedItem) return null;
const existing = await tx.select({ id: inventoryImages.id }).from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, image.inventoryItemId));
if (existing.length >= maxImages) {
throw createApplicationError('IMAGE_COUNT_LIMIT_REACHED', 'Maximum images per item reached.', 409);
}
const [createdImage] = await tx.insert(inventoryImages).values(image).returning();
return createdImage;
});
},
async countByInventoryItemId(inventoryItemId) {
const rows = await db
.select({ id: inventoryImages.id })
@@ -2,13 +2,20 @@ import { validateImageMove } from '../domain/image-order.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector }) {
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector, sellerSettingsRepository }) {
return {
async create(input) {
validateInventoryImageMetadata(input);
const image = await inventoryImageRepository.createForSeller(
const settingsRecord = await sellerSettingsRepository?.findBySellerId(input.sellerId);
const maxImages = settingsRecord?.settings?.inventory?.maxImagesPerItem ?? 10;
if (!Number.isInteger(maxImages) || maxImages < 1 || maxImages > 100) {
throw createApplicationError('IMAGE_COUNT_LIMIT_INVALID', 'Image count limit is invalid.', 500);
}
const createImage = inventoryImageRepository.createForSellerWithLimit ?? inventoryImageRepository.createForSeller;
const image = await createImage.call(inventoryImageRepository,
{ ...input, processingStatus: 'PENDING' },
input.sellerId,
maxImages,
);
if (!image) {
@@ -99,4 +106,4 @@ async function requireOwnedImage(imagePromise) {
const image = await imagePromise;
if (!image) throw imageNotFoundError();
return image;
}
}
@@ -69,4 +69,21 @@ test('removes stored bytes when metadata persistence fails', async () => {
(error) => error.code === 'INVENTORY_ITEM_NOT_FOUND',
);
assert.deepEqual(deleted, [input.storageKey]);
});
});
test('passes the seller image limit to the atomic repository operation', async () => {
let limit;
const service = createInventoryImageService({
sellerSettingsRepository: { async findBySellerId() { return { settings: { inventory: { maxImagesPerItem: 2 } } }; } },
inventoryImageRepository: {
async createForSellerWithLimit(image, sellerId, maxImages) {
assert.equal(sellerId, 'seller-id');
limit = maxImages;
return { id: 'image-1', ...image };
},
},
});
const image = await service.create({ ...input, data: undefined });
assert.equal(image.id, 'image-1');
assert.equal(limit, 2);
});
@@ -244,6 +244,17 @@ test('persists image metadata only for the owning seller and counts item images'
);
});
test('enforces per-item image limits across concurrent inserts', async () => {
const { seller, inventoryItem } = await createInventoryFixture();
const image = (suffix) => ({ inventoryItemId: inventoryItem.id, storageKey: `inventory/${suffix}.jpg`, originalFilename: `${suffix}.jpg`, mimeType: 'image/jpeg', fileSize: 4, width: 2, height: 2, sortOrder: 0, isPrimary: false, processingStatus: 'PENDING' });
const results = await Promise.allSettled([
inventoryImageRepository.createForSellerWithLimit(image('a'), seller.id, 1),
inventoryImageRepository.createForSellerWithLimit(image('b'), seller.id, 1),
]);
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
assert.equal(results.filter((result) => result.status === 'rejected' && result.reason.code === 'IMAGE_COUNT_LIMIT_REACHED').length, 1);
});
test('reads and updates listings only through the owning seller scope', async () => {
const { seller, category, condition, inventoryItem } = await createInventoryFixture();
+1 -1
View File
@@ -25,7 +25,7 @@ Support:
- delete
- add images later
Image count is configurable.
Image count is configurable per seller through `inventory.maxImagesPerItem` (default 10). The API enforces this limit atomically while locking the physical inventory item, so concurrent uploads cannot exceed the configured cap.
## Storage
+2 -1
View File
@@ -266,6 +266,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
- Storybook now installs a lightweight router mirroring the app's named routes plus a mobile-viewport-forcing test-runner hook, enabling isolated page stories (including the public catalog) that use `useRoute`/`useRouter` or narrow-viewport CSS breakpoints.
- The public catalog now has its own Storybook coverage, including an interaction test proving the mobile filter disclosure is keyboard-operable and correctly hides/reveals filter controls via `aria-expanded`. This review also found and fixed a real color-contrast failure in the catalog error-state text.
- `ListingDetailPage`, `ProductDetailPage`, and `AdminImagePage` now have Storybook coverage. Adding it surfaced and fixed a color-contrast bug shared by all three: the "Back to catalog" link, pagination controls, and the selected image-gallery thumbnail were using the non-text-safe `--color-interactive` token instead of `--color-interactive-text`.
- Per-item image limits are now enforced atomically for seller image creation and uploads, using `inventory.maxImagesPerItem` (default 10); concurrent inserts are serialized on the physical inventory item and return `IMAGE_COUNT_LIMIT_REACHED` at the configured cap.
## Local Commands
@@ -287,4 +288,4 @@ Then migrate it using its `TEST_DATABASE_URL` value as `DATABASE_URL`, and run i
## Suggested Resume Prompt
`Read docs/IMPLEMENTATION-STATUS.md and continue the next bounded milestone. Verify the existing baseline first, follow AGENTS.md, and keep the change focused with tests.`
`Read docs/IMPLEMENTATION-STATUS.md and continue the next bounded milestone. Verify the existing baseline first, follow AGENTS.md, and keep the change focused with tests.`