diff --git a/backend/src/migrations/1775200000000-AddSoftDeleteToNotifications.ts b/backend/src/migrations/1775200000000-AddSoftDeleteToNotifications.ts new file mode 100644 index 00000000..33986480 --- /dev/null +++ b/backend/src/migrations/1775200000000-AddSoftDeleteToNotifications.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSoftDeleteToNotifications1775200000000 implements MigrationInterface { + name = 'AddSoftDeleteToNotifications1775200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notifications" ADD "deleted_at" TIMESTAMP`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notifications" DROP COLUMN "deleted_at"`, + ); + } +} diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index 7b87358f..635cf136 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -6,6 +6,7 @@ import { ManyToOne, JoinColumn, Index, + DeleteDateColumn, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; @@ -47,4 +48,7 @@ export class Notification { @CreateDateColumn() created_at: Date; + + @DeleteDateColumn() + deleted_at: Date | null; } diff --git a/backend/src/notifications/notifications.controller.spec.ts b/backend/src/notifications/notifications.controller.spec.ts index 0d5f3c7d..f9fd42d5 100644 --- a/backend/src/notifications/notifications.controller.spec.ts +++ b/backend/src/notifications/notifications.controller.spec.ts @@ -34,6 +34,7 @@ describe('NotificationsController', () => { findAllForUser: jest.fn(), markAsRead: jest.fn(), markAllAsRead: jest.fn().mockResolvedValue({ updated: 0 }), + remove: jest.fn(), }, }, ], @@ -111,4 +112,14 @@ describe('NotificationsController', () => { expect(result).toEqual({ updated: 3 }); }); }); + + describe('remove', () => { + it('should call service remove with id and userId', async () => { + const spy = jest.spyOn(service, 'remove').mockResolvedValue(); + + await controller.remove('notif-uuid-1', mockUser as User); + + expect(spy).toHaveBeenCalledWith('notif-uuid-1', 'user-uuid-1'); + }); + }); }); diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts index 73927b41..f9d7f98d 100644 --- a/backend/src/notifications/notifications.controller.ts +++ b/backend/src/notifications/notifications.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Patch, + Delete, Param, Query, HttpCode, @@ -63,4 +64,15 @@ export class NotificationsController { async markAllAsRead(@CurrentUser() user: User): Promise<{ updated: number }> { return this.notificationsService.markAllAsRead(user.id); } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a notification' }) + @ApiResponse({ status: 204, description: 'Notification deleted' }) + async remove( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.notificationsService.remove(id, user.id); + } } diff --git a/backend/src/notifications/notifications.service.spec.ts b/backend/src/notifications/notifications.service.spec.ts index d88b38d8..82f95928 100644 --- a/backend/src/notifications/notifications.service.spec.ts +++ b/backend/src/notifications/notifications.service.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { NotificationsService } from './notifications.service'; import { Notification, NotificationType } from './entities/notification.entity'; @@ -21,6 +22,8 @@ describe('NotificationsService', () => { save: jest.fn(), findAndCount: jest.fn(), update: jest.fn(), + softDelete: jest.fn(), + findOne: jest.fn(), }; beforeEach(async () => { @@ -145,4 +148,28 @@ describe('NotificationsService', () => { expect(result).toEqual({ updated: 3 }); }); }); + + describe('remove', () => { + it('should soft delete notification when found and owned by user', async () => { + mockRepository.findOne.mockResolvedValue(mockNotification); + mockRepository.softDelete.mockResolvedValue({ affected: 1 }); + + await service.remove('notif-uuid-1', 'user-uuid-1'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'notif-uuid-1', user_id: 'user-uuid-1' }, + }); + expect(mockRepository.softDelete).toHaveBeenCalledWith('notif-uuid-1'); + }); + + it('should throw NotFoundException when notification not found or not owned', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.remove('notif-uuid-1', 'user-uuid-1'), + ).rejects.toThrow(NotFoundException); + + expect(mockRepository.softDelete).not.toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index f9326d68..ec9fbba5 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Notification, NotificationType } from './entities/notification.entity'; @@ -68,4 +68,16 @@ export class NotificationsService { return { updated: result.affected ?? 0 }; } + + async remove(id: string, userId: string): Promise { + const notification = await this.notificationsRepository.findOne({ + where: { id, user_id: userId }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + await this.notificationsRepository.softDelete(id); + } } diff --git a/backend/test/notifications-delete.e2e-spec.ts b/backend/test/notifications-delete.e2e-spec.ts new file mode 100644 index 00000000..39311c3c --- /dev/null +++ b/backend/test/notifications-delete.e2e-spec.ts @@ -0,0 +1,106 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, HttpStatus, ExecutionContext } from '@nestjs/common'; +import request from 'supertest'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotificationsController } from '../src/notifications/notifications.controller'; +import { NotificationsService } from '../src/notifications/notifications.service'; +import { + Notification, + NotificationType, +} from '../src/notifications/entities/notification.entity'; +import { JwtAuthGuard } from '../src/common/guards/jwt-auth.guard'; +import { ResponseInterceptor } from '../src/common/interceptors/response.interceptor'; +import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; +import { User } from '../src/users/entities/user.entity'; + +describe('DELETE /notifications/:id (E2E)', () => { + let app: INestApplication; + let notificationsService: NotificationsService; + + const mockUser: Partial = { + id: 'user-uuid-1', + stellar_address: 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + username: 'testuser', + }; + + const mockNotification: Partial = { + id: 'notif-uuid-1', + user_id: 'user-uuid-1', + type: NotificationType.System, + title: 'Test', + message: 'Test message', + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [ + { + provide: NotificationsService, + useValue: { + remove: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Notification), + useValue: {}, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context + .switchToHttp() + .getRequest<{ user: Partial }>(); + req.user = mockUser; + return true; + }, + }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new HttpExceptionFilter()); + await app.init(); + + notificationsService = + moduleFixture.get(NotificationsService); + }); + + afterEach(async () => { + await app.close(); + }); + + it('should return 204 when notification is successfully deleted', async () => { + jest.spyOn(notificationsService, 'remove').mockResolvedValue(undefined); + + await request(app.getHttpServer()) + .delete(`/notifications/${mockNotification.id}`) + .expect(HttpStatus.NO_CONTENT); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(notificationsService.remove).toHaveBeenCalledWith( + mockNotification.id, + mockUser.id, + ); + }); + + it('should return 404 when notification is not found or not owned', async () => { + const errorMsg = 'Notification not found'; + + jest.spyOn(notificationsService, 'remove').mockRejectedValue({ + status: HttpStatus.NOT_FOUND, + message: errorMsg, + getResponse: () => ({ message: errorMsg }), + getStatus: () => HttpStatus.NOT_FOUND, + }); + + const res = await request(app.getHttpServer()) + .delete('/notifications/invalid-id') + .expect(HttpStatus.NOT_FOUND); + + const body = res.body as { error: { message: string } }; + expect(body.error.message).toBe(errorMsg); + }); +});