feat: add pricing provider boundary
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-09 11:19:27 -04:00
parent 7c4a299838
commit 4effebd2f8
8 changed files with 135 additions and 1 deletions
@@ -0,0 +1,33 @@
export function createPricingProvider({ id, observe }) {
if (typeof id !== 'string' || id.trim().length === 0) {
throw new Error('Pricing provider id is required.');
}
if (typeof observe !== 'function') {
throw new TypeError('Pricing provider observe function is required.');
}
return Object.freeze({
id: id.trim(),
async observe(input) {
const observations = await observe(input);
if (!Array.isArray(observations)) {
throw new Error(`Pricing provider ${id} must return an observation array.`);
}
return observations;
},
});
}
export function createPricingProviderRegistry(providers = []) {
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
return {
get(providerId) {
return providerMap.get(providerId) ?? null;
},
list() {
return [...providerMap.values()];
},
};
}
@@ -53,4 +53,17 @@ export async function registerAdminPricingRoutes(app, options) {
}); });
return reply.code(201).send({ data: override }); return reply.code(201).send({ data: override });
}); });
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const { providerId, productId, conditionId } = request.body ?? {};
const result = await pricingService.refresh({
sellerId: request.admin.sellerId,
inventoryItemId: request.params.id,
providerId,
productId,
conditionId,
});
return reply.code(200).send({ data: result });
});
} }
@@ -1,7 +1,7 @@
import { createPricingObservation } from '../domain/pricing-observation.js'; import { createPricingObservation } from '../domain/pricing-observation.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createPricingService({ pricingRepository }) { export function createPricingService({ pricingRepository, pricingProviderRegistry }) {
return { return {
async addObservation({ sellerId, ...input }) { async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input); const observation = createPricingObservation(input);
@@ -31,5 +31,26 @@ export function createPricingService({ pricingRepository }) {
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404); if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
return result; return result;
}, },
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
const provider = pricingProviderRegistry?.get(providerId);
if (!provider) {
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503);
}
let providerObservations;
try {
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
} catch (error) {
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
}
const observations = [];
for (const input of providerObservations) {
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId }));
}
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) };
},
}; };
} }
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js';
test('registers pricing providers behind a validated adapter contract', async () => {
const provider = createPricingProvider({
id: 'example',
async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; },
});
const registry = createPricingProviderRegistry([provider]);
assert.equal(registry.get('example'), provider);
assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]);
assert.deepEqual(registry.list(), [provider]);
});
test('rejects malformed provider adapters and responses', async () => {
assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/);
const provider = createPricingProvider({ id: 'broken', async observe() { return null; } });
await assert.rejects(() => provider.observe({}), /must return an observation array/);
});
@@ -34,4 +34,16 @@ test('requires a reason for an audited market-value override', async () => {
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }), () => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }),
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400, (error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400,
); );
});
test('fails clearly when a requested pricing provider is unavailable', async () => {
const service = createPricingService({
pricingRepository: {},
pricingProviderRegistry: { get() { return null; } },
});
await assert.rejects(
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }),
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503,
);
}); });
@@ -44,4 +44,30 @@ test('accepts a CSRF-protected market-value override', async (testContext) => {
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(received.actorUserId, 'user-1'); assert.equal(received.actorUserId, 'user-1');
assert.equal(response.json().data.valueMinor, 2600); assert.equal(response.json().data.valueMinor, 2600);
});
test('returns a clear unavailable response when no provider is configured', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest: async () => {},
pricingService: {
async refresh() {
const error = new Error('Pricing provider is not configured.');
error.code = 'PRICING_PROVIDER_UNAVAILABLE';
error.statusCode = 503;
throw error;
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory/inventory-1/pricing/refresh',
headers: { 'x-csrf-token': 'csrf' },
payload: { providerId: 'missing' },
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error.code, 'PRICING_PROVIDER_UNAVAILABLE');
}); });
+1
View File
@@ -89,6 +89,7 @@ POST /api/v1/admin/sales/:id/void
POST /api/v1/admin/pricing/observations POST /api/v1/admin/pricing/observations
POST /api/v1/admin/inventory/:id/valuation POST /api/v1/admin/inventory/:id/valuation
POST /api/v1/admin/inventory/:id/valuation/override POST /api/v1/admin/inventory/:id/valuation/override
POST /api/v1/admin/inventory/:id/pricing/refresh
POST /api/v1/admin/bulk POST /api/v1/admin/bulk
GET /api/v1/admin/dashboard GET /api/v1/admin/dashboard
+6
View File
@@ -22,6 +22,12 @@ A provider returns normalized observations containing:
Provider adapters own provider-specific API behavior and normalization. Provider adapters own provider-specific API behavior and normalization.
The application uses a validated provider registry. A refresh request selects a
registered provider, normalizes its observation array through the existing
pricing service, and recalculates the item valuation. Missing providers return
an explicit unavailable response; the application never fabricates observations
or values.
Use officially supported APIs or permitted sources. Do not invent APIs or bypass restrictions. Use officially supported APIs or permitted sources. Do not invent APIs or bypass restrictions.
## Provider Product Mapping ## Provider Product Mapping