diff --git a/apps/api/tests/integration/transactions-pricing-sales.test.js b/apps/api/tests/integration/transactions-pricing-sales.test.js new file mode 100644 index 0000000..1f353bb --- /dev/null +++ b/apps/api/tests/integration/transactions-pricing-sales.test.js @@ -0,0 +1,157 @@ +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, + products, + securityAuditEvents, + sellers, + users, +} from '../../src/infrastructure/database/schema.js'; +import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js'; +import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js'; +import { createSaleRepository } from '../../src/modules/sales/repositories/sale-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 acquisitionRepository = createAcquisitionRepository(database.db); +const pricingRepository = createPricingRepository(database.db); +const saleRepository = createSaleRepository(database.db); + +async function createFixture() { + const [user] = await database.db.insert(users).values({ + email: 'transactions@example.test', + passwordHash: 'not-a-real-password', + }).returning(); + const [seller] = await database.db.insert(sellers).values({ + userId: user.id, + displayName: 'Transactions Owner', + }).returning(); + const [category] = await database.db.insert(categories).values({ + name: 'Transactions Cards', + slug: 'transactions-cards', + }).returning(); + const [profile] = await database.db.insert(conditionProfiles).values({ + categoryId: category.id, + name: 'Card', + }).returning(); + const [condition] = await database.db.insert(conditions).values({ + conditionProfileId: profile.id, + name: 'Near Mint', + code: 'NM', + qualityRank: 1, + }).returning(); + const [product] = await database.db.insert(products).values({ + sellerId: seller.id, + categoryId: category.id, + name: 'Integration Card', + slug: 'integration-card', + }).returning(); + const inventory = await database.db.insert(inventoryItems).values([ + { productId: product.id, conditionId: condition.id }, + { productId: product.id, conditionId: condition.id }, + ]).returning(); + + return { user, seller, product, condition, inventory }; +} + +beforeEach(async () => { + await database.db.execute(sql`TRUNCATE market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`); +}); + +after(async () => { + await database.close(); +}); + +test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => { + const { user, seller, product, condition, inventory } = await createFixture(); + const [firstItem, secondItem] = inventory; + + const acquisition = await acquisitionRepository.createForSeller({ + sellerId: seller.id, + acquisition: { + inventoryItemId: firstItem.id, + method: 'PURCHASE', + costMinor: 1000, + currency: 'USD', + shippingMinor: 100, + taxMinor: 50, + otherFeesMinor: 0, + estimatedTradeValueMinor: 0, + }, + }); + assert.equal(acquisition.status, 'CREATED'); + + for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) { + const observation = await pricingRepository.addObservationForSeller({ + sellerId: seller.id, + observation: { + productId: product.id, + inventoryItemId: item.id, + providerId: 'integration', + priceMinor: price, + currency: 'USD', + sourceIdentifier: `source-${item.id}`, + conditionId: condition.id, + }, + }); + assert.equal(observation.status, 'CREATED'); + } + + const valuation = await pricingRepository.calculateForSeller({ + sellerId: seller.id, + inventoryItemId: firstItem.id, + }); + assert.equal(valuation.status, 'CALCULATED'); + assert.equal(valuation.valuation.valueMinor, 2500); + assert.equal(valuation.valuation.sourceCount, 1); + + const sale = await saleRepository.createForInventoryItems({ + sale: { + sellerId: seller.id, + saleDate: '2026-09-09', + soldPriceMinor: 5500, + currency: 'USD', + platformFeesMinor: 200, + paymentProcessingFeesMinor: 0, + shippingCostMinor: 0, + taxesMinor: 0, + otherFeesMinor: 0, + }, + items: [ + { inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 }, + { inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 }, + ], + }); + assert.equal(sale.status, 'CREATED'); + assert.equal(sale.saleItems.length, 2); + assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']); + + const voided = await saleRepository.voidForSeller({ + saleId: sale.sale.id, + sellerId: seller.id, + actorUserId: user.id, + reason: 'Integration correction', + }); + assert.equal(voided.status, 'VOIDED'); + assert.equal(voided.sale.status, 'VOIDED'); + assert.equal(voided.saleItems.length, 2); + + const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`); + assert.equal(auditEvent.userId, user.id); +}); diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index ce479ad..f8146e6 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -118,7 +118,7 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: API, web, build, Storybook build, and Storybook interaction/accessibility checks pass locally. The authenticated E2E workflow covers acquisition, image upload, pricing, listing publication, sale recording, dashboard loading, and audited sale voiding. +Latest result: API, web, build, Storybook build, and Storybook interaction/accessibility checks pass locally. The isolated integration suite passes 20 tests, including acquisition, pricing, multi-item sales, audited sale voiding, and seller ownership persistence. The authenticated E2E workflow covers acquisition, image upload, pricing, listing publication, sale recording, dashboard loading, and audited sale voiding. ## Next Milestone diff --git a/docs/TESTING.md b/docs/TESTING.md index 22e44d4..a0f09fa 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -25,6 +25,21 @@ Test: - sales transactions - public/private DTO boundaries +Run the isolated PostgreSQL/Redis integration suite with the test environment +loaded into the shell: + +```sh +set -a +. ./.env +set +a +npm run test:integration +``` + +The PostgreSQL database name must end in `_test`. The suite covers catalog, +session, image derivative, listing visibility, Redis login throttling, +acquisition, pricing observations/valuations, multi-item sales, audited sale +voiding, and seller ownership persistence. + ## Component Test important Vue components and composables. Every new or changed visual component must include a colocated Storybook story or an update to an existing story before its vertical slice is committed.