feat: add admin inventory CSV export
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 00:31:00 -04:00
parent d3821e9c76
commit 7c9518923d
8 changed files with 95 additions and 2 deletions
@@ -26,4 +26,15 @@ export async function registerAdminExportRoutes(app, options) {
.header('content-disposition', 'attachment; filename="inventory.json"')
.send(JSON.stringify(data));
});
app.get('/exports/inventory.csv', async (request, reply) => {
const data = await adminExportService.inventoryCsv({
sellerId: request.admin.sellerId,
});
return reply
.type('text/csv; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.csv"')
.send(data);
});
}
@@ -1,10 +1,28 @@
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
export function createAdminExportService({ inventoryItemService }) {
async function inventoryRows(sellerId) {
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
return inventoryItems.map(toAdminInventoryItemDto);
}
return {
async inventoryJson({ sellerId }) {
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
return inventoryItems.map(toAdminInventoryItemDto);
return inventoryRows(sellerId);
},
async inventoryCsv({ sellerId }) {
const rows = await inventoryRows(sellerId);
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
return [
columns.join(','),
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
].join('\r\n');
},
};
}
function escapeCsvValue(value) {
const text = value == null ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
@@ -31,3 +31,24 @@ test('exports seller inventory through the admin DTO boundary', async () => {
updatedAt: '2026-09-10T00:00:00.000Z',
}]);
});
test('exports inventory as escaped CSV rows', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId() {
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Front, "clean"\nback',
}];
},
},
});
assert.equal(await service.inventoryCsv({ sellerId: 'seller-1' }), [
'id,productId,conditionId,status,notes,createdAt,updatedAt',
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
].join('\r\n'));
});
@@ -57,3 +57,26 @@ test('requires authentication for inventory JSON exports', async (testContext) =
assert.equal(response.statusCode, 401);
assert.equal(response.json().error.code, 'UNAUTHORIZED');
});
test('downloads seller-scoped inventory CSV for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async inventoryCsv({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return 'id,status\r\n"item-1","ACTIVE"';
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.csv',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'text/csv; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
});