import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js'; import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js'; import assert from 'node:assert/strict'; import test, { after, beforeEach } from 'node:test'; import { sql } from 'drizzle-orm'; import { createDatabase } from '../../src/infrastructure/database/client.js'; import { categories, conditionProfiles, conditions, inventoryItems, listings, products, sellers, sellerSettings, users, } from '../../src/infrastructure/database/schema.js'; import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryItemRepository } from '../../src/modules/inventory/repositories/inventory-item-repository.js'; import { createListingRepository } from '../../src/modules/listings/repositories/listing-repository.js'; const connectionString = process.env.TEST_DATABASE_URL; if (!connectionString) { throw new Error('TEST_DATABASE_URL is required to run integration tests.'); } if (!new URL(connectionString).pathname.endsWith('_test')) { throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".'); } const database = createDatabase(connectionString); const listingRepository = createListingRepository(database.db); const inventoryItemRepository = createInventoryItemRepository(database.db); const inventoryImageRepository = createInventoryImageRepository(database.db); async function createInventoryFixture() { const [user] = await database.db .insert(users) .values({ email: 'listing-admin@example.test', passwordHash: 'hashed-password' }) .returning(); const [seller] = await database.db .insert(sellers) .values({ userId: user.id, displayName: 'Listing Owner' }) .returning(); const [category] = await database.db .insert(categories) .values({ name: 'Trading Cards', slug: 'trading-cards' }) .returning(); const [conditionProfile] = await database.db .insert(conditionProfiles) .values({ categoryId: category.id, name: 'Card' }) .returning(); const [condition] = await database.db .insert(conditions) .values({ conditionProfileId: conditionProfile.id, name: 'Near Mint', code: 'NM', qualityRank: 1, }) .returning(); const [product] = await database.db .insert(products) .values({ sellerId: seller.id, categoryId: category.id, name: 'Charizard ex', slug: 'charizard-ex-sv151-199', }) .returning(); const [inventoryItem] = await database.db .insert(inventoryItems) .values({ productId: product.id, conditionId: condition.id, notes: 'Original box included', }) .returning(); return { seller, category, condition, product, inventoryItem }; } beforeEach(async () => { await database.db.execute( sql`TRUNCATE listings, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`, ); }); after(async () => { await database.close(); }); test('persists a draft listing for a physical inventory item', async () => { const { category, condition, inventoryItem } = await createInventoryFixture(); const listing = await listingRepository.create({ inventoryItemId: inventoryItem.id, productName: 'Charizard ex', categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, status: 'DRAFT', notes: 'Near mint', }); assert.equal(listing.inventoryItemId, inventoryItem.id); assert.equal(listing.status, 'DRAFT'); assert.equal(listing.askingPriceMinor, 2499); assert.ok(listing.id); }); test('lists the most recent draft entries for an inventory item', async () => { const { category, condition, inventoryItem } = await createInventoryFixture(); await listingRepository.create({ inventoryItemId: inventoryItem.id, productName: 'Charizard ex', categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, status: 'DRAFT', }); const listingsForItem = await listingRepository.listByInventoryItemId(inventoryItem.id); assert.equal(listingsForItem.length, 1); assert.equal(listingsForItem[0].inventoryItemId, inventoryItem.id); }); test('searches public listings with pagination and hides sold items by default', async () => { const { category, condition, inventoryItem, product } = await createInventoryFixture(); await listingRepository.create({ inventoryItemId: inventoryItem.id, productName: product.name, categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, status: 'PENDING', }); const visible = await listingRepository.searchPublic({ page: 1, limit: 10 }); assert.equal(visible.total, 1); assert.equal(visible.items.length, 1); assert.equal(visible.items[0].status, 'PENDING'); assert.equal( (await listingRepository.findPublicById(visible.items[0].id))?.id, visible.items[0].id, ); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null); await database.db.insert(sellerSettings).values({ sellerId: product.sellerId, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } }); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.'); const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) }); await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } }); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, 'owner@example.test'); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100'); await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: '' } } }); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, null); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100'); await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactPhone: ' ' } } }); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, null); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.'); await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } }); assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null); await database.db .update(listings) .set({ status: 'CLOSED' }) .where(sql`inventory_item_id = ${inventoryItem.id}`); await database.db .update(inventoryItems) .set({ status: 'SOLD' }) .where(sql`id = ${inventoryItem.id}`); const hidden = await listingRepository.searchPublic({ page: 1, limit: 10 }); assert.equal(hidden.total, 0); assert.equal(await listingRepository.findPublicById(visible.items[0].id), null); const included = await listingRepository.searchPublic({ page: 1, limit: 10, includeSold: true, }); assert.equal(included.total, 1); assert.equal(included.items[0].status, 'CLOSED'); }); test('searches public inventory with product and listing fields', async () => { const { category, condition, inventoryItem, product } = await createInventoryFixture(); await listingRepository.create({ inventoryItemId: inventoryItem.id, productName: product.name, categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, status: 'AVAILABLE', }); const visible = await inventoryItemRepository.searchPublic({ page: 1, limit: 10 }); assert.equal(visible.total, 1); assert.equal(visible.items[0].productSlug, product.slug); assert.equal(visible.items[0].askingPriceMinor, 2499); assert.equal((await inventoryItemRepository.findPublicById(inventoryItem.id)).id, inventoryItem.id); await database.db.update(inventoryItems).set({ status: 'SOLD' }).where(sql`id = ${inventoryItem.id}`); assert.equal(await inventoryItemRepository.findPublicById(inventoryItem.id), null); }); test('persists image metadata only for the owning seller and counts item images', async () => { const { seller, inventoryItem } = await createInventoryFixture(); const image = await inventoryImageRepository.createForSeller({ inventoryItemId: inventoryItem.id, storageKey: 'inventory/front.jpg', originalFilename: 'front.jpg', mimeType: 'image/jpeg', fileSize: 12000, width: 1200, height: 900, altText: 'Front image', sortOrder: 0, isPrimary: true, }, seller.id); assert.equal(image.processingStatus, 'PENDING'); assert.equal(await inventoryImageRepository.countByInventoryItemId(inventoryItem.id), 1); assert.equal( await inventoryImageRepository.createForSeller({ inventoryItemId: inventoryItem.id, storageKey: 'inventory/other.jpg', originalFilename: 'other.jpg', mimeType: 'image/jpeg', fileSize: 12000, width: 1200, height: 900, }, '00000000-0000-0000-0000-000000000000'), null, ); }); 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(); const createdListing = await listingRepository.create({ inventoryItemId: inventoryItem.id, productName: 'Charizard ex', categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, status: 'DRAFT', }); const ownedListing = await listingRepository.findByIdForSeller(createdListing.id, seller.id); const sellerListings = await listingRepository.listBySellerId(seller.id); assert.equal(ownedListing.id, createdListing.id); assert.equal(ownedListing.inventoryStatus, 'ACTIVE'); assert.equal(sellerListings.length, 1); assert.equal( await listingRepository.findByIdForSeller( createdListing.id, '00000000-0000-0000-0000-000000000000', ), null, ); 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]); });