feat(listings): rename QR endpoint to GET /listings/:id/qr + add size/format params
- Rename route from :id/qr-code to :id/qr per TEC-3071 spec - Add ?size=N (50-1000, default 300) query param for PNG width control - Add ?format=png|svg query param; SVG path uses QRCode.toString with type:svg - Set correct Content-Type (image/png or image/svg+xml) and Cache-Control headers - Add 4 unit tests covering PNG/SVG dispatch, cache header, and 404 path - OG meta tags on listing detail SSR already complete (no changes needed) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ListingsController } from '../controllers/listings.controller';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QRCode mock — avoids canvas / native binary deps in test environment
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('qrcode', () => ({
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from('PNG_BYTES')),
|
||||
toString: vi.fn().mockResolvedValue('<svg></svg>'),
|
||||
}));
|
||||
|
||||
describe('ListingsController', () => {
|
||||
let controller: ListingsController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
@@ -216,4 +224,61 @@ describe('ListingsController', () => {
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQrCode', () => {
|
||||
function makeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
let body: unknown;
|
||||
return {
|
||||
set: vi.fn((h: Record<string, string>) => Object.assign(headers, h)),
|
||||
send: vi.fn((b: unknown) => { body = b; }),
|
||||
_headers: headers,
|
||||
_body: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
it('returns a PNG buffer and correct headers for format=png (default)', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ id: 'listing-1', status: 'ACTIVE' });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getQrCode('listing-1', res as any, 300, undefined);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'Content-Type': 'image/png' }),
|
||||
);
|
||||
expect(res.send).toHaveBeenCalledWith(expect.any(Buffer));
|
||||
});
|
||||
|
||||
it('returns SVG string and correct headers for format=svg', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ id: 'listing-1', status: 'ACTIVE' });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getQrCode('listing-1', res as any, 300, 'svg');
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'Content-Type': 'image/svg+xml' }),
|
||||
);
|
||||
expect(res.send).toHaveBeenCalledWith('<svg></svg>');
|
||||
});
|
||||
|
||||
it('sets Cache-Control: public, max-age=86400 on QR response', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ id: 'listing-1', status: 'ACTIVE' });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getQrCode('listing-1', res as any, 300, undefined);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'Cache-Control': 'public, max-age=86400' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when listing does not exist', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue(null);
|
||||
const res = makeRes();
|
||||
|
||||
await expect(
|
||||
controller.getQrCode('nonexistent', res as any, 300, undefined),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
@@ -177,12 +179,16 @@ export class ListingsController {
|
||||
|
||||
@ApiOperation({ summary: 'Generate QR code image linking to a listing' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||
@ApiResponse({ status: 200, description: 'QR code PNG image', content: { 'image/png': {} } })
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 300, description: 'QR image size in pixels (PNG only, 50–1000, default 300)' })
|
||||
@ApiQuery({ name: 'format', required: false, enum: ['png', 'svg'], example: 'png', description: 'Output format: png (default) or svg' })
|
||||
@ApiResponse({ status: 200, description: 'QR code PNG image', content: { 'image/png': {}, 'image/svg+xml': {} } })
|
||||
@ApiResponse({ status: 404, description: 'Listing not found' })
|
||||
@Get(':id/qr-code')
|
||||
@Get(':id/qr')
|
||||
async getQrCode(
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
@Query('size', new DefaultValuePipe(300), ParseIntPipe) size: number,
|
||||
@Query('format') format?: string,
|
||||
): Promise<void> {
|
||||
const listing = await this.queryBus.execute(new GetListingQuery(id));
|
||||
if (!listing) {
|
||||
@@ -192,23 +198,39 @@ export class ListingsController {
|
||||
const siteUrl = process.env['SITE_URL'] || 'https://goodgo.vn';
|
||||
const listingUrl = `${siteUrl}/vi/listings/${id}`;
|
||||
|
||||
const qrBuffer = await QRCode.toBuffer(listingUrl, {
|
||||
type: 'png',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
const safeSize = Math.min(Math.max(size, 50), 1000);
|
||||
const useSvg = format === 'svg';
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': qrBuffer.length.toString(),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.send(qrBuffer);
|
||||
if (useSvg) {
|
||||
const svgString = await QRCode.toString(listingUrl, {
|
||||
type: 'svg',
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
res.set({
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Content-Length': Buffer.byteLength(svgString).toString(),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.send(svgString);
|
||||
} else {
|
||||
const qrBuffer = await QRCode.toBuffer(listingUrl, {
|
||||
type: 'png',
|
||||
width: safeSize,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': qrBuffer.length.toString(),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.send(qrBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get price change history for a listing' })
|
||||
|
||||
Reference in New Issue
Block a user