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:
Ho Ngoc Hai
2026-04-21 04:58:44 +07:00
parent e2e748f0c7
commit 606fa0bd4e
2 changed files with 105 additions and 18 deletions

View File

@@ -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);
});
});
});

View File

@@ -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, 501000, 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' })