feat: establish auth listing and image vertical slices
Includes MFA-capable authentication, session and CSRF security, password recovery, listing lifecycle and ownership rules, inventory image metadata, local ImageStorage, migrations, tests, and implementation status documentation. Verification: npm test (62 passing) and integration suite (14 passing).
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-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 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,
|
||||
imageCount: 2,
|
||||
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,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
const listingsForItem = await listingRepository.listByInventoryItemId(inventoryItem.id);
|
||||
|
||||
assert.equal(listingsForItem.length, 1);
|
||||
assert.equal(listingsForItem[0].inventoryItemId, inventoryItem.id);
|
||||
});
|
||||
|
||||
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,
|
||||
imageCount: 2,
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user