diff --git a/migrations/1726752966034-notification.ts b/migrations/1726752966034-notification.ts index 659fd9a6b5..4bc8e29ef3 100644 --- a/migrations/1726752966034-notification.ts +++ b/migrations/1726752966034-notification.ts @@ -8,22 +8,22 @@ export class Notification1726752966034 implements MigrationInterface { `CREATE TABLE "push_notification_devices" ("id" SERIAL NOT NULL, "device_type" character varying(255) NOT NULL, "device_uuid" uuid NOT NULL, "cloud_messaging_token" character varying(255) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "device_uuid" UNIQUE ("device_uuid"), CONSTRAINT "PK_e387f5cc5b4f66d63804d596c64" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE TABLE "notification_subscriptions" ("id" SERIAL NOT NULL, "chain_id" character varying(255) NOT NULL, "safe_address" character varying(42) NOT NULL, "signer_address" character varying(42), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "pushNotificationDeviceId" integer, CONSTRAINT "UQ_3c2531929422835e4f2717ec5db" UNIQUE ("chain_id", "safe_address", "pushNotificationDeviceId", "signer_address"), CONSTRAINT "PK_8cfec5d2a549ff20d1f4e648226" PRIMARY KEY ("id"))`, + `CREATE TABLE "notification_subscriptions" ("id" SERIAL NOT NULL, "chain_id" character varying(255) NOT NULL, "safe_address" character varying(42) NOT NULL, "signer_address" character varying(42), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "push_notification_device_id" integer, CONSTRAINT "UQ_3c2531929422835e4f2717ec5db" UNIQUE ("chain_id", "safe_address", "push_notification_device_id", "signer_address"), CONSTRAINT "PK_8cfec5d2a549ff20d1f4e648226" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE TABLE "notification_subscription_notification_types" ("id" SERIAL NOT NULL, "notificationSubscriptionId" integer, "notificationTypeId" integer, CONSTRAINT "UQ_5e7563e15aa2f994bd7b07ecec8" UNIQUE ("notificationSubscriptionId", "notificationTypeId"), CONSTRAINT "PK_3754c1a419741973072e5ed92eb" PRIMARY KEY ("id"))`, + `CREATE TABLE "notification_subscription_notification_types" ("id" SERIAL NOT NULL, "notification_subscription_id" integer, "notification_type_id" integer, CONSTRAINT "UQ_5e7563e15aa2f994bd7b07ecec8" UNIQUE ("notification_subscription_id", "notification_type_id"), CONSTRAINT "PK_3754c1a419741973072e5ed92eb" PRIMARY KEY ("id"))`, ); await queryRunner.query( `CREATE TABLE "notification_types" ("id" SERIAL NOT NULL, "name" character varying(255) NOT NULL, CONSTRAINT "name" UNIQUE ("name"), CONSTRAINT "PK_aa965e094494e2c4c5942cfb42d" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `ALTER TABLE "notification_subscriptions" ADD CONSTRAINT "FK_9f59e655926203074b833d6f909" FOREIGN KEY ("pushNotificationDeviceId") REFERENCES "push_notification_devices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "notification_subscriptions" ADD CONSTRAINT "FK_9f59e655926203074b833d6f909" FOREIGN KEY ("push_notification_device_id") REFERENCES "push_notification_devices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "notification_subscription_notification_types" ADD CONSTRAINT "FK_44702b7d6132421d2049ed994de" FOREIGN KEY ("notificationSubscriptionId") REFERENCES "notification_subscriptions"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "notification_subscription_notification_types" ADD CONSTRAINT "FK_44702b7d6132421d2049ed994de" FOREIGN KEY ("notification_subscription_id") REFERENCES "notification_subscriptions"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "notification_subscription_notification_types" ADD CONSTRAINT "FK_3e3e49a32dc1862742a322a6149" FOREIGN KEY ("notificationTypeId") REFERENCES "notification_types"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "notification_subscription_notification_types" ADD CONSTRAINT "FK_3e3e49a32dc1862742a322a6149" FOREIGN KEY ("notification_type_id") REFERENCES "notification_types"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); } diff --git a/src/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder.ts b/src/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder.ts index ee240782d7..c5629b9a4a 100644 --- a/src/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder.ts +++ b/src/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder.ts @@ -10,6 +10,10 @@ export function notificationDeviceBuilder(): IBuilder { .with('id', faker.number.int()) .with('device_uuid', faker.string.uuid() as UUID) .with('device_type', faker.helpers.enumValue(DeviceType)) + .with( + 'cloud_messaging_token', + faker.string.alphanumeric({ length: { min: 10, max: 255 } }), + ) .with('created_at', new Date()) .with('updated_at', new Date()); } diff --git a/src/domain/notifications/v2/notifications.repository.integration.spec.ts b/src/domain/notifications/v2/notifications.repository.integration.spec.ts new file mode 100644 index 0000000000..19f83ff360 --- /dev/null +++ b/src/domain/notifications/v2/notifications.repository.integration.spec.ts @@ -0,0 +1,621 @@ +import { type ILoggingService } from '@/logging/logging.interface'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository'; +import type { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import type { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; +import { DataSource, In, type EntityManager } from 'typeorm'; +import { postgresConfig } from '@/config/entities/postgres.config'; +import configuration from '@/config/entities/__tests__/configuration'; +import { NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; +import type { UUID } from 'crypto'; +import { faker } from '@faker-js/faker/.'; +import { notificationDeviceBuilder } from '@/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder'; +import { NotificationType as NotificationTypeEnum } from '@/domain/notifications/v2/entities/notification.entity'; +import { DatabaseMigrator } from '@/datasources/db/v2/database-migrator.service'; +import type { ConfigService } from '@nestjs/config'; + +describe('NotificationsRepositoryV2', () => { + const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + } as jest.MockedObjectDeep; + const mockPushNotificationsApi: IPushNotificationsApi = { + enqueueNotification: jest.fn(), + }; + const mockConfigService = { + getOrThrow: jest.fn().mockImplementation((key: string) => { + if (key === 'db.migrator.numberOfRetries') { + return config.db.migrator.numberOfRetries; + } + if (key === 'db.migrator.retryAfterMs') { + return config.db.migrator.retryAfterMs; + } + }), + } as jest.MockedObjectDeep; + + const config = configuration(); + const testDatabaseName = faker.string.alpha({ length: 10, casing: 'lower' }); + const dataSource = new DataSource({ + ...postgresConfig({ + ...config.db.connection.postgres, + type: 'postgres', + database: testDatabaseName, + }), + migrationsTableName: config.db.orm.migrationsTableName, + entities: [ + NotificationType, + NotificationSubscription, + NotificationDevice, + NotificationSubscriptionNotificationType, + ], + }); + let postgresDatabaseService: PostgresDatabaseService; + let notificationsRepositoryService: INotificationsRepositoryV2; + + /** + * Creates a new database specifically for testing purposes. + * + * TypeORM requires a database name to initialize a datasource. + * To create a new test database, this function first connects + * to the default `postgres` database, allowing the new database + * to be created for test use. + * + * @async + * @function createTestDatabase + * @returns {Promise} Resolves when migrations are complete. + */ + async function createTestDatabase(): Promise { + const testDataSource = new DataSource({ + ...postgresConfig({ + ...config.db.connection.postgres, + type: 'postgres', + database: 'postgres', + }), + }); + const testPostgresDatabaseService = new PostgresDatabaseService( + mockLoggingService, + testDataSource, + ); + await testPostgresDatabaseService.initializeDatabaseConnection(); + await testPostgresDatabaseService + .getDataSource() + .query(`CREATE DATABASE ${testDatabaseName}`); + await testPostgresDatabaseService.destroyDatabaseConnection(); + } + + /** + * Initializes the test database connection + * + * @async + * @function createDatabaseConnection + * @returns {Promise} Resolves when migrations are complete. + */ + async function createDatabaseConnection(): Promise { + postgresDatabaseService = new PostgresDatabaseService( + mockLoggingService, + dataSource, + ); + await postgresDatabaseService.initializeDatabaseConnection(); + } + + /** + * Runs database migrations for the test or application database. + * + * This function initializes a `DatabaseMigrator` instance with the necessary + * services (logging, database, and configuration) and executes the migration + * process. + * + * @async + * @function migrateDatabase + * @returns {Promise} Resolves when migrations are complete. + */ + async function migrateDatabase(): Promise { + const migrator = new DatabaseMigrator( + mockLoggingService, + postgresDatabaseService, + mockConfigService, + ); + await migrator.migrate(); + } + + /** + * Truncates data in specific tables used for notifications. + * + * This function deletes all rows from the `NotificationSubscription`, + * `NotificationDevice`, and `NotificationSubscriptionNotificationType` tables. + * It uses query builders to perform the deletions without conditions, + * effectively clearing all records for test or reset purposes. + * + * @async + * @function truncateTables + * @returns {Promise} Resolves when all specified tables are truncated. + */ + async function truncateTables(): Promise { + const subscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + const notificationDeviceRepository = + dataSource.getRepository(NotificationDevice); + const notificationSubscriptionNotificationTypeRepository = + dataSource.getRepository(NotificationSubscriptionNotificationType); + + await notificationDeviceRepository + .createQueryBuilder() + .delete() + .where('1=1') + .execute(); + + await subscriptionRepository + .createQueryBuilder() + .delete() + .where('1=1') + .execute(); + + await notificationSubscriptionNotificationTypeRepository + .createQueryBuilder() + .delete() + .where('1=1') + .execute(); + } + + beforeAll(async () => { + await createTestDatabase(); + await createDatabaseConnection(); + await migrateDatabase(); + + notificationsRepositoryService = new NotificationsRepositoryV2( + mockPushNotificationsApi, + mockLoggingService, + postgresDatabaseService, + ); + }); + + afterAll(async () => { + await postgresDatabaseService.getDataSource().dropDatabase(); + await postgresDatabaseService.destroyDatabaseConnection(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + await truncateTables(); + }); + + describe('upsertSubscription()', () => { + it('Should insert a new device when upserting a subscription', async () => { + jest.spyOn(dataSource, 'transaction'); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + + const notificationDeviceRepository = + dataSource.getRepository(NotificationDevice); + const device = await notificationDeviceRepository.findOneBy({ + device_uuid: upsertSubscriptionsDto.deviceUuid as UUID, + }); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(device).toHaveProperty('device_uuid'); + expect(device?.device_uuid).toBe(upsertSubscriptionsDto.deviceUuid); + }); + + it('Should deletePreviousSubscriptions() when upserting a subscription', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + const subscriptionBeforeRemoval = + await notificationSubscriptionRepository.findOneBy({ + signer_address: authPayload.signer_address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + safe_address: upsertSubscriptionsDto.safes[0].address, + }); + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + const subscriptionAfterRemoval = + await notificationSubscriptionRepository.findOne({ + where: { + signer_address: authPayload.signer_address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + safe_address: upsertSubscriptionsDto.safes[0].address, + push_notification_device: { + device_uuid: upsertSubscriptionsDto.deviceUuid as UUID, + }, + }, + relations: ['push_notification_device'], + }); + + expect(subscriptionBeforeRemoval).toHaveProperty('chain_id'); + expect(subscriptionBeforeRemoval?.id).not.toEqual( + subscriptionAfterRemoval?.id, + ); + }); + + it('Should upsert a new subscription object when upserting subscriptions', async () => { + jest.spyOn(dataSource, 'transaction'); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const upsertSubscriptionResult = + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + + const subscription = await notificationSubscriptionRepository.findBy({ + signer_address: authPayload.signer_address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + safe_address: upsertSubscriptionsDto.safes[0].address, + push_notification_device: { + device_uuid: upsertSubscriptionResult.deviceUuid, + }, + }); + + expect(subscription).toHaveLength(1); + }); + + it('Should upsert subscription notification types object when upserting subscriptions', async () => { + jest.spyOn(dataSource, 'transaction'); + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const upsertSubscriptionResult = + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + const notificationSubscriptionNotificationTypeRepository = + dataSource.getRepository(NotificationSubscriptionNotificationType); + + const subscriptions = await notificationSubscriptionRepository.findBy({ + signer_address: authPayload.signer_address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + safe_address: upsertSubscriptionsDto.safes[0].address, + push_notification_device: { + device_uuid: upsertSubscriptionResult.deviceUuid, + }, + }); + const subscriptionIds = subscriptions.map( + (subscription) => subscription.id, + ); + const subscriptionNotificationTypes = + await notificationSubscriptionNotificationTypeRepository.find({ + where: { + notification_subscription: { + id: In(subscriptionIds), + }, + }, + relations: ['notification_type'], + }); + + const upsertNotificationTypes: Array = []; + upsertSubscriptionsDto.safes.map((safe) => { + upsertNotificationTypes.push(...safe.notificationTypes); + }); + + for (const subscriptionNotificationType of subscriptionNotificationTypes) { + upsertNotificationTypes.includes( + subscriptionNotificationType.notification_type.name, + ); + } + }); + + it('Should not commit if a new subscription object cannot be upserted', async () => { + await dataSource.transaction( + async (entityManager: EntityManager): Promise => { + const databaseTransaction = entityManager; + + jest + .spyOn(postgresDatabaseService, 'getTransactionRunner') + .mockReturnValue(databaseTransaction); + + jest + .spyOn(databaseTransaction, 'upsert') + .mockImplementationOnce(() => { + throw new Error('Error'); + }); + + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = + upsertSubscriptionsDtoBuilder().build(); + + try { + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + } catch { + // + } + + const notificationSubscriptionRepository = + entityManager.getRepository(NotificationSubscription); + const notificationDeviceRepository = + entityManager.getRepository(NotificationDevice); + const subscription = + await notificationSubscriptionRepository.findOneBy({ + signer_address: authPayload.signer_address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + safe_address: upsertSubscriptionsDto.safes[0].address, + }); + + const device = await notificationDeviceRepository.findOneBy({ + device_uuid: upsertSubscriptionsDto.deviceUuid as UUID, + }); + + expect(device).toBeNull(); + expect(subscription).toBeNull(); + + return; + }, + ); + }); + }); + + describe('getSafeSubscription()', () => { + it('Should return a safe subscriptions successfully', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + const subscriptions = + await notificationsRepositoryService.getSafeSubscription({ + authPayload, + deviceUuid: upsertSubscriptionsDto.deviceUuid as UUID, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + for (const subscription of subscriptions) { + expect(NotificationTypeEnum).toHaveProperty(subscription.name); + } + }); + it('Should return an empty array if no subscriptions found', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const subscriptions = + await notificationsRepositoryService.getSafeSubscription({ + authPayload, + deviceUuid: upsertSubscriptionsDto.deviceUuid as UUID, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + expect(subscriptions).toHaveLength(0); + }); + }); + + describe('getSubscribersBySafe()', () => { + it('Should get safe subscribers successfully', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const secondAuthPayloadDto = authPayloadDtoBuilder().build(); + const secondAuthPayload = new AuthPayload(secondAuthPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + await notificationsRepositoryService.upsertSubscriptions({ + authPayload: secondAuthPayload, + upsertSubscriptionsDto, + }); + + const safeSubscriptions = + await notificationsRepositoryService.getSubscribersBySafe({ + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + expect(safeSubscriptions).toHaveLength(2); + + const safeSubscription = safeSubscriptions.find( + (subscription) => + subscription.subscriber === authPayload.signer_address, + ); + const secondSafeSubscription = safeSubscriptions.find( + (subscription) => + subscription.subscriber === secondAuthPayload.signer_address, + ); + + expect(safeSubscription).toHaveProperty('subscriber'); + expect(secondSafeSubscription).toHaveProperty('subscriber'); + }); + + it('Should return an empty array if no subscriber exists', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const safeSubscriptions = + await notificationsRepositoryService.getSubscribersBySafe({ + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + expect(safeSubscriptions).toHaveLength(0); + }); + }); + + describe('deleteSubscription()', () => { + it('Should delete a subscription successfully', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + + const subscriptionBeforeRemoval = + await notificationSubscriptionRepository.findBy({ + safe_address: upsertSubscriptionsDto.safes[0].address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + signer_address: authPayload.signer_address, + }); + + await notificationsRepositoryService.deleteSubscription({ + deviceUuid: upsertSubscriptionsDto.deviceUuid as UUID, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + const subscriptionAfterRemoval = + await notificationSubscriptionRepository.findBy({ + safe_address: upsertSubscriptionsDto.safes[0].address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + signer_address: authPayload.signer_address, + }); + + expect(subscriptionBeforeRemoval).toHaveLength(1); + expect(subscriptionAfterRemoval).toHaveLength(0); + }); + + it('Should not try to remove if a subscription does not exist', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + jest.spyOn(notificationSubscriptionRepository, 'remove'); + + const subscription = await notificationSubscriptionRepository.findBy({ + safe_address: upsertSubscriptionsDto.safes[0].address, + chain_id: upsertSubscriptionsDto.safes[0].chainId, + signer_address: authPayload.signer_address, + }); + + await notificationsRepositoryService.deleteSubscription({ + deviceUuid: upsertSubscriptionsDto.deviceUuid as UUID, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + expect(subscription).toHaveLength(0); + expect(notificationSubscriptionRepository.remove).not.toHaveBeenCalled(); + }); + }); + + describe('deleteDevice()', () => { + it('Should delete a device successfully', async () => { + const deviceDto = notificationDeviceBuilder() + .with('id', faker.number.int({ min: 1, max: 1999 })) + .build(); + + const notificationDeviceRepository = + dataSource.getRepository(NotificationDevice); + const device = await notificationDeviceRepository.save(deviceDto); + + await notificationsRepositoryService.deleteDevice(device.device_uuid); + + const findDevice = await notificationDeviceRepository.findOneBy({ + id: device.id, + }); + + expect(findDevice).toBeNull(); + }); + + it('Should not throw if a uuid does not exist', async () => { + const deviceDto = notificationDeviceBuilder() + .with('id', faker.number.int({ min: 1, max: 1999 })) + .build(); + + const notificationDeviceRepository = + dataSource.getRepository(NotificationDevice); + + const result = await notificationsRepositoryService.deleteDevice( + deviceDto.device_uuid, + ); + + const findDevice = await notificationDeviceRepository.findOneBy({ + device_uuid: deviceDto.device_uuid, + }); + + expect(findDevice).toBeNull(); + expect(result).toBeUndefined(); + }); + + it('Should delete a device with its subscription', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + await notificationsRepositoryService.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto, + }); + + const notificationDeviceRepository = + dataSource.getRepository(NotificationDevice); + + const notificationSubscriptionRepository = dataSource.getRepository( + NotificationSubscription, + ); + + await notificationsRepositoryService.deleteDevice( + upsertSubscriptionsDto.deviceUuid as UUID, + ); + + const device = await notificationDeviceRepository.find({ + where: { + device_uuid: upsertSubscriptionsDto.deviceUuid as UUID, + }, + }); + const subscription = await notificationSubscriptionRepository.find({ + where: { + push_notification_device: { + device_uuid: upsertSubscriptionsDto.deviceUuid as UUID, + }, + }, + }); + + expect(device).toHaveLength(0); + expect(subscription).toHaveLength(0); + }); + }); +}); diff --git a/src/domain/notifications/v2/notifications.repository.spec.ts b/src/domain/notifications/v2/notifications.repository.spec.ts index b01490af68..a956f3bb82 100644 --- a/src/domain/notifications/v2/notifications.repository.spec.ts +++ b/src/domain/notifications/v2/notifications.repository.spec.ts @@ -1,22 +1,28 @@ import type { UUID } from 'crypto'; -import type { DataSource } from 'typeorm'; import { faker } from '@faker-js/faker/.'; -import type { Repository } from 'typeorm'; import { UnauthorizedException } from '@nestjs/common'; import { type ILoggingService } from '@/logging/logging.interface'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; -import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; import { NotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository'; import type { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import type { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; import type { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; import { notificationTypeBuilder } from '@/datasources/notifications/entities/__tests__/notification-type.entity.db.builder'; import { notificationSubscriptionBuilder } from '@/datasources/notifications/entities/__tests__/notification-subscription.entity.db.builder'; -import type { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { mockEntityManager } from '@/datasources/db/v2/__tests__/entity-manager.mock'; +import { mockPostgresDatabaseService } from '@/datasources/db/v2/__tests__/postgresql-database.service.mock'; +import { mockRepository } from '@/datasources/db/v2/__tests__/repository.mock'; describe('NotificationsRepositoryV2', () => { let notificationsRepository: INotificationsRepositoryV2; - let postgresDatabaseService: PostgresDatabaseService; + const notificationTypeRepository = { ...mockRepository }; + const notificationDeviceRepository = { ...mockRepository }; + const notificationSubscriptionRepository = { ...mockRepository }; + const notificationSubscriptionsRepository = { ...mockRepository }; const mockLoggingService = { debug: jest.fn(), error: jest.fn(), @@ -26,46 +32,185 @@ describe('NotificationsRepositoryV2', () => { const mockPushNotificationsApi: IPushNotificationsApi = { enqueueNotification: jest.fn(), }; - const mockDataSource = { - getRepository: jest.fn(), - isInitialized: false, - initialize: jest.fn(), - } as jest.MockedObjectDeep; beforeEach(() => { - postgresDatabaseService = new PostgresDatabaseService( - mockLoggingService, - mockDataSource, - ); + jest.clearAllMocks(); notificationsRepository = new NotificationsRepositoryV2( mockPushNotificationsApi, mockLoggingService, - postgresDatabaseService, + mockPostgresDatabaseService, ); }); + describe('upsertSubscription()', () => { + const deviceId = 1; + beforeEach(() => { + mockEntityManager.upsert.mockResolvedValue({ + identifiers: [ + { + id: deviceId, + }, + ], + generatedMaps: [ + { + id: 1, + }, + ], + raw: jest.fn(), + }); + }); + + it('Should insert a new device when upserting a subscription', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const mockNotificationTypes = Array.from({ length: 4 }, () => + notificationTypeBuilder().build(), + ); + mockEntityManager.find.mockResolvedValue(mockNotificationTypes); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationTypeRepository, + ); + + await notificationsRepository.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto: upsertSubscriptionsDto, + }); + + expect(mockPostgresDatabaseService.transaction).toHaveBeenCalledTimes(1); + expect(mockEntityManager.upsert).toHaveBeenNthCalledWith( + 1, + NotificationDevice, + { + device_uuid: upsertSubscriptionsDto.deviceUuid, + device_type: upsertSubscriptionsDto.deviceType, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + }, + ['device_uuid'], + ); + }); + + it('Should delete previous subscriptions when upserting a new one', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepository.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto: upsertSubscriptionsDto, + }); + + expect(mockPostgresDatabaseService.transaction).toHaveBeenCalledTimes(1); + expect( + mockEntityManager.createQueryBuilder().delete().from, + ).toHaveBeenNthCalledWith(1, NotificationSubscription); + for (const [index, safe] of upsertSubscriptionsDto.safes.entries()) { + const nthTime = index + 1; // Index is zero based for that reason we need to add 1 to it + expect( + mockEntityManager + .createQueryBuilder() + .delete() + .from(NotificationSubscription).where, + ).toHaveBeenNthCalledWith( + nthTime, + `chain_id = :chainId + AND safe_address = :safeAddress + AND push_notification_device.id = :deviceId + AND ( + signer_address = :signerAddress OR signer_address IS NULL + )`, + { + chainId: safe.chainId, + safeAddress: safe.address, + deviceId: deviceId, + signerAddress: authPayload.signer_address ?? null, + }, + ); + } + }); + + it('Should insert the subscription object when upserting a new subscription', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepository.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto: upsertSubscriptionsDto, + }); + + const subscriptionsToInsert: Partial[] = []; + for (const safe of upsertSubscriptionsDto.safes) { + const device = new NotificationDevice(); + device.id = deviceId; + subscriptionsToInsert.push({ + chain_id: safe.chainId, + safe_address: safe.address, + signer_address: authPayload.signer_address ?? null, + push_notification_device: device, + }); + } + + expect(mockPostgresDatabaseService.transaction).toHaveBeenCalledTimes(1); + expect(mockEntityManager.upsert).toHaveBeenNthCalledWith( + 2, + NotificationSubscription, + subscriptionsToInsert, + [ + 'chain_id', + 'safe_address', + 'signer_address', + 'push_notification_device', + ], + ); + }); + + it('Should insert the notification subscription type object when upserting a new subscription', async () => { + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + await notificationsRepository.upsertSubscriptions({ + authPayload, + upsertSubscriptionsDto: upsertSubscriptionsDto, + }); + + const subscriptionsToInsert: Partial[] = []; + for (const safe of upsertSubscriptionsDto.safes) { + const device = new NotificationDevice(); + device.id = deviceId; + subscriptionsToInsert.push({ + chain_id: safe.chainId, + safe_address: safe.address, + signer_address: authPayload.signer_address ?? null, + push_notification_device: device, + }); + } + + expect(mockPostgresDatabaseService.transaction).toHaveBeenCalledTimes(1); + expect(mockEntityManager.upsert).toHaveBeenCalledTimes(3); + // @TODO expect(mockEntityManager.upsert).toHaveBeenCalledWith(); + }); + }); + describe('getSafeSubscription()', () => { it('Should return a notification type', async () => { const mockNotificationTypes = Array.from({ length: 4 }, () => notificationTypeBuilder().build(), ); - const notificationTypeRepository = { - find: jest.fn().mockResolvedValue(mockNotificationTypes), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationTypeRepository); - - const authPayload = new AuthPayload(); - authPayload.chain_id = faker.number.int({ min: 1, max: 100 }).toString(); - authPayload.signer_address = faker.string.hexadecimal({ - length: 32, - }) as `0x${string}`; + notificationTypeRepository.find.mockResolvedValue(mockNotificationTypes); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationTypeRepository, + ); + + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); const args = { authPayload: authPayload, deviceUuid: faker.string.uuid() as UUID, - chainId: authPayload.chain_id, + chainId: authPayload.chain_id as string, safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, }; @@ -88,14 +233,15 @@ describe('NotificationsRepositoryV2', () => { }); it('Should throw UnauthorizedException if signer_address is not passed', async () => { - const authPayload = new AuthPayload(); - authPayload.chain_id = faker.number.int({ min: 1, max: 100 }).toString(); - authPayload.signer_address = '' as `0x${string}`; + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', '' as `0x${string}`) + .build(); + const authPayload = new AuthPayload(authPayloadDto); const args = { - authPayload: authPayload, + authPayload, deviceUuid: faker.string.uuid() as UUID, - chainId: authPayload.chain_id, + chainId: authPayload.chain_id as string, safeAddress: faker.string.hexadecimal({ length: 32, }) as `0x${string}`, @@ -107,23 +253,18 @@ describe('NotificationsRepositoryV2', () => { it('Should return an empty array if there is no notification type for safe', async () => { const mockNotificationTypes: Array = []; - const notificationTypeRepository = { - find: jest.fn().mockResolvedValue(mockNotificationTypes), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationTypeRepository); - - const authPayload = new AuthPayload(); - authPayload.chain_id = faker.number.int({ min: 1, max: 100 }).toString(); - authPayload.signer_address = faker.string.hexadecimal({ - length: 32, - }) as `0x${string}`; + notificationTypeRepository.find.mockResolvedValue(mockNotificationTypes); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationTypeRepository, + ); + + const authPayloadDto = authPayloadDtoBuilder().build(); + const authPayload = new AuthPayload(authPayloadDto); const args = { authPayload: authPayload, deviceUuid: faker.string.uuid() as UUID, - chainId: authPayload.chain_id, + chainId: authPayload.chain_id as string, safeAddress: faker.string.hexadecimal({ length: 32, }) as `0x${string}`, @@ -154,12 +295,12 @@ describe('NotificationsRepositoryV2', () => { (): NotificationSubscription => notificationSubscriptionBuilder().build(), ); - const notificationSubscriptionsRepository = { - find: jest.fn().mockResolvedValue(mockSbuscribers), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationSubscriptionsRepository); + notificationSubscriptionsRepository.find.mockResolvedValue( + mockSbuscribers, + ); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionsRepository, + ); const result = await notificationsRepository.getSubscribersBySafe({ chainId: faker.number.int({ min: 1, max: 100 }).toString(), @@ -181,13 +322,10 @@ describe('NotificationsRepositoryV2', () => { }); it('Should return empty when there is no subscribers for safe', async () => { - const mockSbuscribers: Array = []; - const notificationSubscriptionsRepository = { - find: jest.fn().mockResolvedValue(mockSbuscribers), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationSubscriptionsRepository); + notificationSubscriptionsRepository.find.mockResolvedValue([]); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionsRepository, + ); const result = await notificationsRepository.getSubscribersBySafe({ chainId: faker.number.int({ min: 1, max: 100 }).toString(), @@ -203,13 +341,13 @@ describe('NotificationsRepositoryV2', () => { const mockNotificationSubscription = notificationSubscriptionBuilder().build(); - const notificationSubscriptionRepository = { - findOne: jest.fn().mockResolvedValue(mockNotificationSubscription), - remove: jest.fn(), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationSubscriptionRepository); + notificationSubscriptionRepository.findOne.mockResolvedValue( + mockNotificationSubscription, + ); + + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionRepository, + ); const args = { deviceUuid: faker.string.uuid() as UUID, @@ -240,13 +378,10 @@ describe('NotificationsRepositoryV2', () => { }); it('Should not call remove if no subscription is found', async () => { - const notificationSubscriptionRepository = { - findOne: jest.fn().mockResolvedValue(null), - remove: jest.fn(), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationSubscriptionRepository); + notificationSubscriptionRepository.findOne.mockResolvedValue(null); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionRepository, + ); const args = { deviceUuid: faker.string.uuid() as UUID, @@ -274,12 +409,9 @@ describe('NotificationsRepositoryV2', () => { describe('deleteDevice()', () => { it('Should delete a device successfully', async () => { - const notificationDeviceRepository = { - delete: jest.fn(), - } as jest.MockedObjectDeep>; - postgresDatabaseService.getRepository = jest - .fn() - .mockResolvedValue(notificationDeviceRepository); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationDeviceRepository, + ); const deviceUuid = faker.string.uuid() as UUID; diff --git a/src/domain/notifications/v2/notifications.repository.ts b/src/domain/notifications/v2/notifications.repository.ts index 40655c3d9a..ba8f59a5bb 100644 --- a/src/domain/notifications/v2/notifications.repository.ts +++ b/src/domain/notifications/v2/notifications.repository.ts @@ -180,7 +180,6 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { deviceId: number; }): Promise> { const subscriptionsToInsert: Partial[] = []; - const notificationTypes = []; for (const safe of args.upsertSubscriptionsDto.safes) { const device = new NotificationDevice(); device.id = args.deviceId; @@ -190,8 +189,6 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { signer_address: args.authPayload.signer_address ?? null, push_notification_device: device, }); - - notificationTypes.push(safe.notificationTypes); } const databaseTransaction =