338 lines
14 KiB
JavaScript
338 lines
14 KiB
JavaScript
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('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]);
|
|
});
|