Commit the working vertical slices for inventory image processing and the Sale workflow, including Sale history, audited voiding, multi-item persistence, admin UI, migrations, documentation, and tests.
This commit is contained in:
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
||||
import { createLocalImageStorage } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
|
||||
test('local image storage round-trips and deletes binary data', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
@@ -40,6 +41,16 @@ test('local image storage rejects traversal and duplicate writes', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('relative storage roots resolve from the project root regardless of the process cwd', () => {
|
||||
const apiSource = new URL('../../src/app/server.js', import.meta.url);
|
||||
const workerSource = new URL('../../../worker/src/worker-runtime.js', import.meta.url);
|
||||
const expectedRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
|
||||
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', apiSource), path.join(expectedRoot, '.local/image-storage'));
|
||||
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', workerSource), path.join(expectedRoot, '.local/image-storage'));
|
||||
assert.equal(resolveProjectImageStorageRoot('/tmp/custom-image-storage', apiSource), '/tmp/custom-image-storage');
|
||||
});
|
||||
|
||||
test('image storage contracts reject incomplete adapters', () => {
|
||||
assert.throws(
|
||||
() => createImageStorageContract({ put() {} }),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSaleService } from '../../src/modules/sales/services/sale-service.js';
|
||||
|
||||
function validInput(overrides = {}) {
|
||||
return {
|
||||
sellerId: 'seller-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a sale through the repository with validated money values', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItem(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: { costBasisMinor: 1200 },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.create(validInput()), {
|
||||
status: 'CREATED',
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: { costBasisMinor: 1200 },
|
||||
netProceedsMinor: 2399,
|
||||
profitMinor: 1199,
|
||||
});
|
||||
assert.equal(received.sale.sellerId, 'seller-1');
|
||||
assert.equal(received.sale.soldPriceMinor, 2499);
|
||||
assert.equal(received.costBasisMinor, 1200);
|
||||
});
|
||||
|
||||
test('lists seller sales with derived net proceeds and profit', async () => {
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [{
|
||||
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
}];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [sale] = await service.list({ sellerId: 'seller-1' });
|
||||
assert.equal(sale.netProceedsMinor, 2800);
|
||||
assert.equal(sale.profitMinor, 1300);
|
||||
});
|
||||
|
||||
test('voids an owned sale with a required reason', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async voidForSeller(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'VOIDED',
|
||||
sale: { id: 'sale-1', soldPriceMinor: 3000 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.void({
|
||||
saleId: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
actorUserId: 'user-1',
|
||||
reason: ' Duplicate sale ',
|
||||
});
|
||||
|
||||
assert.equal(received.reason, 'Duplicate sale');
|
||||
assert.equal(result.inventoryItem.status, 'ACTIVE');
|
||||
await assert.rejects(
|
||||
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }),
|
||||
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates one sale containing multiple inventory items', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItems(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
sale: { id: 'sale-2', soldPriceMinor: 3000 },
|
||||
saleItems: input.items,
|
||||
saleItem: input.items[0],
|
||||
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })),
|
||||
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
items: [
|
||||
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 },
|
||||
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(received.sale.soldPriceMinor, 3000);
|
||||
assert.equal(received.items.length, 2);
|
||||
assert.equal(result.profitMinor, 1800);
|
||||
});
|
||||
|
||||
test('maps missing and already-sold inventory to application errors', async () => {
|
||||
for (const result of [
|
||||
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 },
|
||||
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 },
|
||||
]) {
|
||||
const service = createSaleService({
|
||||
saleRepository: { async createForInventoryItem() { return result; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.create(validInput()),
|
||||
(error) => error.code === result.code && error.statusCode === result.statusCode,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -54,4 +54,23 @@ test('rejects invalid sales and negative fee values', () => {
|
||||
}),
|
||||
/must be a non-negative integer minor unit/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-02-30',
|
||||
soldPriceMinor: 2000,
|
||||
}),
|
||||
/must be a valid ISO date/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-09-08',
|
||||
soldPriceMinor: 2000,
|
||||
currency: 'US dollars',
|
||||
}),
|
||||
/must be a three-letter ISO currency code/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
const saleResult = {
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
currency: 'USD',
|
||||
platformFeesMinor: 0,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: {
|
||||
id: 'sale-item-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
},
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
};
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
return buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
saleService: {
|
||||
async create(input) {
|
||||
assert.equal(input.sellerId, 'seller-1');
|
||||
return saleResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
costBasisMinor: 1200,
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => {
|
||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const invalidResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
payload: {
|
||||
inventoryItemId: 'not-a-uuid',
|
||||
costBasisMinor: -1,
|
||||
saleDate: 'tomorrow',
|
||||
soldPriceMinor: 0,
|
||||
},
|
||||
});
|
||||
assert.equal(invalidResponse.statusCode, 400);
|
||||
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR');
|
||||
|
||||
const unauthenticatedApp = createTestApp();
|
||||
testContext.after(() => unauthenticatedApp.close());
|
||||
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(unauthenticatedResponse.statusCode, 401);
|
||||
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
test('voids a sale with CSRF protection and actor identity', async (testContext) => {
|
||||
let voidInput;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
saleService: {
|
||||
async void(input) {
|
||||
voidInput = input;
|
||||
return saleResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales/sale-1/void',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
payload: { reason: 'Duplicate entry' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(voidInput.actorUserId, 'user-1');
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
});
|
||||
Reference in New Issue
Block a user