diff --git a/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts
index 1d8a306..9f7b474 100644
--- a/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts
+++ b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts
@@ -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(''),
+}));
+
describe('ListingsController', () => {
let controller: ListingsController;
let mockCommandBus: { execute: ReturnType };
@@ -216,4 +224,61 @@ describe('ListingsController', () => {
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});
+
+ describe('getQrCode', () => {
+ function makeRes() {
+ const headers: Record = {};
+ let body: unknown;
+ return {
+ set: vi.fn((h: Record) => 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('');
+ });
+
+ 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);
+ });
+ });
});
diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts
index 018e44c..5ed2b6a 100644
--- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts
+++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts
@@ -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 {
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' })