From 329667b833602288055c7a75e6a3a799a6a78a11 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 05:46:33 +0100 Subject: [PATCH 1/2] test(upload): 100% coverage for upload.controller, mock makePublic in storage.service.spec --- .../modules/upload/storage.service.spec.ts | 1 + .../modules/upload/upload.controller.spec.ts | 87 ++++++++++++++++++- .../src/modules/upload/upload.controller.ts | 42 ++++----- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/backend/src/modules/upload/storage.service.spec.ts b/backend/src/modules/upload/storage.service.spec.ts index b4569ad..b3a8271 100644 --- a/backend/src/modules/upload/storage.service.spec.ts +++ b/backend/src/modules/upload/storage.service.spec.ts @@ -6,6 +6,7 @@ import { Storage } from '@google-cloud/storage'; const mockBucket = { file: jest.fn().mockReturnValue({ save: jest.fn().mockResolvedValue(undefined), + makePublic: jest.fn().mockResolvedValue(undefined), }), }; const mockStorageInstance = { diff --git a/backend/src/modules/upload/upload.controller.spec.ts b/backend/src/modules/upload/upload.controller.spec.ts index 5cfcaae..bf0c100 100644 --- a/backend/src/modules/upload/upload.controller.spec.ts +++ b/backend/src/modules/upload/upload.controller.spec.ts @@ -1,12 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { UploadController } from './upload.controller'; +import * as fs from 'fs'; +import { UploadController, createImageFileFilter } from './upload.controller'; import { StorageService } from './storage.service'; describe('UploadController', () => { let controller: UploadController; - - const mockStorageService = { + const mockStorageService = { isGcsEnabled: jest.fn().mockReturnValue(false), uploadToGcs: jest.fn(), }; @@ -117,6 +117,60 @@ describe('UploadController', () => { 'image/jpeg', ); }); + + it('should use .gif extension when mimetype is image/gif', async () => { + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ mimetype: 'image/gif' }); + + const result = await controller.uploadAvatar(file, req); + + expect(result.url).toMatch(/\.gif$/); + expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/avatars\/user-1-\d+\.gif$/); + }); + + it('should use .jpg as fallback extension for unknown mimetype', async () => { + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ mimetype: 'image/bmp' } as Express.Multer.File); + + const result = await controller.uploadAvatar(file, req); + + expect(result.url).toMatch(/\.jpg$/); + }); + + it('should create avatars directory when it does not exist', async () => { + const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile(); + + const result = await controller.uploadAvatar(file, req); + + expect(existsSyncSpy).toHaveBeenCalled(); + expect(mkdirSyncSpy).toHaveBeenCalledWith(expect.stringContaining('uploads/avatars'), { recursive: true }); + expect(result.url).toMatch(/\/uploads\/avatars\//); + existsSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + writeFileSyncSpy.mockRestore(); + }); + }); + + describe('createImageFileFilter', () => { + it('should call cb with error and false for invalid mimetype', () => { + const filter = createImageFileFilter(); + const cb = jest.fn(); + filter(null, { mimetype: 'application/pdf' } as Express.Multer.File, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('should call cb with null and true for valid mimetype', () => { + const filter = createImageFileFilter(); + const cb = jest.fn(); + filter(null, { mimetype: 'image/png' } as Express.Multer.File, cb); + expect(cb).toHaveBeenCalledWith(null, true); + expect(cb).toHaveBeenCalledTimes(1); + }); }); describe('uploadBackground', () => { @@ -173,5 +227,32 @@ describe('UploadController', () => { 'image/png', ); }); + + it('should use .gif extension when mimetype is image/gif', async () => { + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ fieldname: 'background', mimetype: 'image/gif' }); + + const result = await controller.uploadBackground(file, req); + + expect(result.url).toMatch(/\.gif$/); + expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/backgrounds\/background-\d+-[a-z0-9]+\.gif$/); + }); + + it('should create backgrounds directory when it does not exist', async () => { + const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + const req = mockRequest({ user: { id: 'user-1' } }); + const file = mockFile({ fieldname: 'background', mimetype: 'image/webp' }); + + const result = await controller.uploadBackground(file, req); + + expect(existsSyncSpy).toHaveBeenCalled(); + expect(mkdirSyncSpy).toHaveBeenCalledWith(expect.stringContaining('uploads/backgrounds'), { recursive: true }); + expect(result.url).toMatch(/\/uploads\/backgrounds\//); + existsSyncSpy.mockRestore(); + mkdirSyncSpy.mockRestore(); + writeFileSyncSpy.mockRestore(); + }); }); }); diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 5c9ca05..6c0ccaa 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -16,7 +16,7 @@ import { StorageService } from './storage.service'; const AVATARS_DIR = 'uploads/avatars'; const BACKGROUNDS_DIR = 'uploads/backgrounds'; -const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +export const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; function getExtension(mimetype: string): string { const map: Record = { @@ -28,6 +28,24 @@ function getExtension(mimetype: string): string { return map[mimetype] ?? 'jpg'; } +/** Used by FileInterceptor to reject non-image uploads. Exported for tests. */ +export function createImageFileFilter(): ( + _req: unknown, + file: Express.Multer.File, + cb: (err: Error | null, accept: boolean) => void, +) => void { + return (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { + if (!ALLOWED_MIMES.includes(file.mimetype)) { + cb( + new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), + false, + ); + return; + } + cb(null, true); + }; +} + /** * Upload controller: saves files to Google Cloud Storage (or disk when GCS not configured) * and returns public URLs. Used for avatar, board/card background images. @@ -40,16 +58,7 @@ export class UploadController { @UseInterceptors( FileInterceptor('avatar', { storage: memoryStorage(), - fileFilter: (_req, file, cb) => { - if (!ALLOWED_MIMES.includes(file.mimetype)) { - cb( - new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), - false, - ); - return; - } - cb(null, true); - }, + fileFilter: createImageFileFilter(), }), ) async uploadAvatar( @@ -89,16 +98,7 @@ export class UploadController { @UseInterceptors( FileInterceptor('background', { storage: memoryStorage(), - fileFilter: (_req, file, cb) => { - if (!ALLOWED_MIMES.includes(file.mimetype)) { - cb( - new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'), - false, - ); - return; - } - cb(null, true); - }, + fileFilter: createImageFileFilter(), }), ) async uploadBackground( From 56347390d74c0af04244d82f819f4738e5f44602 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 05:51:39 +0100 Subject: [PATCH 2/2] chore: update deploy workflow and dashboard page --- .github/workflows/deploy.yml | 1 + frontend/app/dashboard/page.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0785b42..bff17cb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -679,6 +679,7 @@ jobs: environment: ${{ needs.changes.outputs.environment }} region: ${{ env.GCP_REGION }} project_id: ${{ secrets.GCP_PROJECT_ID }} + staging_api_url: ${{ secrets.STAGING_API_URL }} - name: Get Frontend URL id: frontend-url diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 889a7f8..5c28cdb 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -270,7 +270,7 @@ export default function DashboardPage() { >