Remove the temporary listing-level image count from the schema, validation, DTO, and draft contract. Publication now requires the persisted inventory image repository count. Add migration 0009 and update listing tests and implementation status. Verification: npm test passed 62 tests and integration passed 14 tests. No build script is configured in this repository.
201 lines
6.1 KiB
JavaScript
201 lines
6.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { buildApp } from '../../src/app/build-app.js';
|
|
import { createListingService } from '../../src/modules/listings/services/listing-service.js';
|
|
|
|
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = {}) {
|
|
const app = buildApp({
|
|
authenticateAdmin,
|
|
authorizeUnsafeRequest,
|
|
listingService: createListingService({
|
|
listingRepository: {
|
|
async create(listing) {
|
|
return {
|
|
id: 'listing-1',
|
|
...listing,
|
|
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
|
};
|
|
},
|
|
async findByIdForSeller() {
|
|
return listing ?? {
|
|
id: 'listing-1',
|
|
inventoryItemId: 'inventory-1',
|
|
productName: 'Charizard ex',
|
|
categoryId: 'category-1',
|
|
conditionId: 'condition-1',
|
|
askingPriceMinor: 2499,
|
|
status: 'DRAFT',
|
|
};
|
|
},
|
|
async listBySellerId() {
|
|
return [{
|
|
id: 'listing-1',
|
|
inventoryItemId: 'inventory-1',
|
|
productName: 'Charizard ex',
|
|
categoryId: 'category-1',
|
|
conditionId: 'condition-1',
|
|
askingPriceMinor: 2499,
|
|
status: 'DRAFT',
|
|
}];
|
|
},
|
|
async updateStatus(id, status) {
|
|
return {
|
|
...(listing ?? {
|
|
id,
|
|
inventoryItemId: 'inventory-1',
|
|
productName: 'Charizard ex',
|
|
categoryId: 'category-1',
|
|
conditionId: 'condition-1',
|
|
askingPriceMinor: 2499,
|
|
}),
|
|
id,
|
|
status,
|
|
};
|
|
},
|
|
},
|
|
inventoryImageRepository: {
|
|
async countByInventoryItemId() {
|
|
return 2;
|
|
},
|
|
},
|
|
}),
|
|
inventoryImageService: {
|
|
async create(input) {
|
|
return {
|
|
id: 'image-1',
|
|
...input,
|
|
processingStatus: 'PENDING',
|
|
createdAt: new Date('2026-09-08T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-08T00:00:00.000Z'),
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
test('creates a draft listing for the authenticated seller', async (testContext) => {
|
|
const app = createTestApp({
|
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/listings',
|
|
payload: {
|
|
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
|
productName: 'Charizard ex',
|
|
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
|
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
|
askingPriceMinor: 2499,
|
|
notes: ' Near mint ',
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 201);
|
|
assert.equal(response.json().data.status, 'DRAFT');
|
|
assert.equal(response.json().data.productName, 'Charizard ex');
|
|
});
|
|
|
|
test('rejects a listing write without CSRF validation', async (testContext) => {
|
|
const app = createTestApp({
|
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
|
authorizeUnsafeRequest: async () => {
|
|
const error = new Error('CSRF validation failed.');
|
|
error.code = 'FORBIDDEN';
|
|
error.statusCode = 403;
|
|
throw error;
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/listings',
|
|
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
|
payload: {
|
|
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
|
productName: 'Charizard ex',
|
|
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
|
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
|
askingPriceMinor: 2499,
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 403);
|
|
assert.equal(response.json().error.code, 'FORBIDDEN');
|
|
});
|
|
|
|
test('publishes a listing through the authenticated, CSRF-protected lifecycle route', 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/listings/listing-1/publish',
|
|
headers: { 'x-csrf-token': 'csrf-token' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.json().data.status, 'AVAILABLE');
|
|
assert.equal(csrfChecks, 1);
|
|
});
|
|
|
|
test('reads seller-owned listings without requiring CSRF validation', async (testContext) => {
|
|
const app = createTestApp({
|
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
|
authorizeUnsafeRequest: async () => {
|
|
throw new Error('CSRF should not be checked for reads.');
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const collectionResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/listings',
|
|
});
|
|
const detailResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/listings/listing-1',
|
|
});
|
|
|
|
assert.equal(collectionResponse.statusCode, 200);
|
|
assert.equal(collectionResponse.json().data[0].id, 'listing-1');
|
|
assert.equal(detailResponse.statusCode, 200);
|
|
assert.equal(detailResponse.json().data.status, 'DRAFT');
|
|
});
|
|
|
|
test('adds inventory image metadata for the authenticated seller', async (testContext) => {
|
|
const app = createTestApp({
|
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/inventory/inventory-1/images',
|
|
payload: {
|
|
storageKey: 'inventory/inventory-1/front.jpg',
|
|
originalFilename: 'front.jpg',
|
|
mimeType: 'image/jpeg',
|
|
fileSize: 12000,
|
|
width: 1200,
|
|
height: 900,
|
|
altText: 'Front of Charizard ex card',
|
|
isPrimary: true,
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 201);
|
|
assert.equal(response.json().data.inventoryItemId, 'inventory-1');
|
|
assert.equal(response.json().data.processingStatus, 'PENDING');
|
|
});
|