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' })