diff --git a/migrations/1726452966034-notification_cleanup.ts b/migrations/1726452966034-notification_cleanup.ts new file mode 100644 index 0000000000..7a9183b7b5 --- /dev/null +++ b/migrations/1726452966034-notification_cleanup.ts @@ -0,0 +1,35 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NotificationCleanup1726452966034 implements MigrationInterface { + name = 'NotificationCleanup1726452966034'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TRIGGER IF EXISTS "update_push_notification_devices_updated_at" ON "push_notification_devices"`, + ); + await queryRunner.query( + `DROP TRIGGER IF EXISTS "update_notification_subscriptions_updated_at" ON "notification_subscriptions"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "notification_types_name_enum" CASCADE`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "push_notification_devices_device_type_enum" CASCADE`, + ); + await queryRunner.query(`DROP FUNCTION IF EXISTS update_updated_at()`); + await queryRunner.query( + `DROP TABLE IF EXISTS "push_notification_devices" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "notification_subscriptions" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "notification_subscription_notification_types" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "notification_types" CASCADE`, + ); + } + + public async down(): Promise {} +} 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/migrations/1727451367471-notifications_enum.ts b/migrations/1727451367471-notifications_enum.ts index 6510d85075..be4487d805 100644 --- a/migrations/1727451367471-notifications_enum.ts +++ b/migrations/1727451367471-notifications_enum.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class NotificationsEnum1727451367471 implements MigrationInterface { name = 'NotificationsEnum1727451367471'; diff --git a/migrations/1727701600427-update_timestamp_trigger.ts b/migrations/1727701600427-update_timestamp_trigger.ts index d1f7f152e8..d8a7ae5b7a 100644 --- a/migrations/1727701600427-update_timestamp_trigger.ts +++ b/migrations/1727701600427-update_timestamp_trigger.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateTimestampTrigger1727701600427 implements MigrationInterface { name = 'UpdateTimestampTrigger1727701600427'; diff --git a/migrations/1727701873513-notification_update_updated_at.ts b/migrations/1727701873513-notification_update_updated_at.ts index debdce42b1..53956e482b 100644 --- a/migrations/1727701873513-notification_update_updated_at.ts +++ b/migrations/1727701873513-notification_update_updated_at.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class NotificationUpdateUpdatedAt1727701873513 implements MigrationInterface diff --git a/src/datasources/db/v1/entities/row.entity.ts b/src/datasources/db/v1/entities/row.entity.ts index 072ec04e12..c76cf7a951 100644 --- a/src/datasources/db/v1/entities/row.entity.ts +++ b/src/datasources/db/v1/entities/row.entity.ts @@ -9,6 +9,6 @@ export type Row = z.infer; */ export const RowSchema = z.object({ id: z.number().int(), - created_at: z.coerce.date(), + created_at: z.coerce.date(), // @TODO when migrated all the entities to TypeOrm Remove `.coerce` updated_at: z.coerce.date(), }); diff --git a/src/datasources/notifications/__tests__/test.notifications.datasource.module.ts b/src/datasources/notifications/__tests__/test.notifications.datasource.module.ts deleted file mode 100644 index 4283fea477..0000000000 --- a/src/datasources/notifications/__tests__/test.notifications.datasource.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; -import { Module } from '@nestjs/common'; - -const accountsDatasource: INotificationsDatasource = { - deleteDevice: jest.fn(), - deleteSubscription: jest.fn(), - getSafeSubscription: jest.fn(), - getSubscribersBySafe: jest.fn(), - upsertSubscriptions: jest.fn(), -}; - -@Module({ - providers: [ - { - provide: INotificationsDatasource, - useFactory: (): jest.MockedObjectDeep => { - return jest.mocked(accountsDatasource); - }, - }, - ], - exports: [INotificationsDatasource], -}) -export class TestNotificationsDatasourceModule {} 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 new file mode 100644 index 0000000000..c5629b9a4a --- /dev/null +++ b/src/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder.ts @@ -0,0 +1,19 @@ +import type { IBuilder } from '@/__tests__/builder'; +import { Builder } from '@/__tests__/builder'; +import type { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; +import { faker } from '@faker-js/faker/.'; +import type { UUID } from 'crypto'; + +export function notificationDeviceBuilder(): IBuilder { + return new Builder() + .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/datasources/notifications/entities/__tests__/notification-subscription-notification-type.entity.db.builder.ts b/src/datasources/notifications/entities/__tests__/notification-subscription-notification-type.entity.db.builder.ts new file mode 100644 index 0000000000..e4ec1cc3df --- /dev/null +++ b/src/datasources/notifications/entities/__tests__/notification-subscription-notification-type.entity.db.builder.ts @@ -0,0 +1,16 @@ +import type { IBuilder } from '@/__tests__/builder'; +import { Builder } from '@/__tests__/builder'; +import { faker } from '@faker-js/faker/.'; +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 { NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; + +export function notificationSubscriptionNotificationTypeTypeBuilder(): IBuilder { + return new Builder() + .with('id', faker.number.int()) + .with( + 'notification_subscription', + notificationSubscriptionBuilder().build(), + ) + .with('notification_type', notificationTypeBuilder().build()); +} diff --git a/src/datasources/notifications/entities/__tests__/notification-subscription.entity.db.builder.ts b/src/datasources/notifications/entities/__tests__/notification-subscription.entity.db.builder.ts new file mode 100644 index 0000000000..09155cb029 --- /dev/null +++ b/src/datasources/notifications/entities/__tests__/notification-subscription.entity.db.builder.ts @@ -0,0 +1,18 @@ +import type { IBuilder } from '@/__tests__/builder'; +import { Builder } from '@/__tests__/builder'; +import { notificationDeviceBuilder } from '@/datasources/notifications/entities/__tests__/notification-devices.entity.db.builder'; +import type { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { faker } from '@faker-js/faker/.'; +import { getAddress } from 'viem'; + +export function notificationSubscriptionBuilder(): IBuilder { + return new Builder() + .with('id', faker.number.int()) + .with('chain_id', faker.number.int({ min: 1, max: 100 }).toString()) + .with('safe_address', getAddress(faker.finance.ethereumAddress())) + .with('signer_address', getAddress(faker.finance.ethereumAddress())) + .with('created_at', new Date()) + .with('updated_at', new Date()) + .with('notification_subscription_notification_type', []) + .with('push_notification_device', notificationDeviceBuilder().build()); +} diff --git a/src/datasources/notifications/entities/__tests__/notification-type.entity.db.builder.ts b/src/datasources/notifications/entities/__tests__/notification-type.entity.db.builder.ts new file mode 100644 index 0000000000..7340d91af5 --- /dev/null +++ b/src/datasources/notifications/entities/__tests__/notification-type.entity.db.builder.ts @@ -0,0 +1,12 @@ +import type { IBuilder } from '@/__tests__/builder'; +import { Builder } from '@/__tests__/builder'; +import type { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import { faker } from '@faker-js/faker/.'; +import { NotificationType as NotificationTypeEnum } from '@/domain/notifications/v2/entities/notification.entity'; + +export function notificationTypeBuilder(): IBuilder { + return new Builder() + .with('id', faker.number.int()) + .with('name', faker.helpers.enumValue(NotificationTypeEnum)) + .with('notification_subscription_notification_type', []); +} diff --git a/src/datasources/notifications/entities/notification-devices.entity.ts b/src/datasources/notifications/entities/notification-devices.entity.db.ts similarity index 51% rename from src/datasources/notifications/entities/notification-devices.entity.ts rename to src/datasources/notifications/entities/notification-devices.entity.db.ts index ca75897910..ec4ccc8548 100644 --- a/src/datasources/notifications/entities/notification-devices.entity.ts +++ b/src/datasources/notifications/entities/notification-devices.entity.db.ts @@ -1,26 +1,36 @@ -import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { RowSchema } from '@/datasources/db/v1/entities/row.entity'; +import { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; +import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; import type { UUID } from 'crypto'; import { - Check, Column, Entity, Unique, OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { z } from 'zod'; + +export const NotificationDeviceSchema = RowSchema.extend({ + device_type: z.nativeEnum(DeviceType), + device_uuid: UuidSchema, + cloud_messaging_token: z.string(), +}); @Entity('push_notification_devices') @Unique('device_uuid', ['device_uuid']) -@Check('device_type', 'device_type IN ("ANDROID", "IOS", "WEB")') -export class NotificationDevice { +export class NotificationDevice + implements z.infer +{ @PrimaryGeneratedColumn() id!: number; @Column({ - type: 'varchar', - length: 255, + type: 'enum', + enum: DeviceType, }) - device_type!: string; + device_type!: DeviceType; @Column({ type: 'uuid' }) device_uuid!: UUID; @@ -41,8 +51,12 @@ export class NotificationDevice { }) updated_at!: Date; - @OneToMany(() => NotificationSubscription, (device) => device.id, { - onDelete: 'CASCADE', - }) + @OneToMany( + () => NotificationSubscription, + (subscription) => subscription.id, + { + onDelete: 'CASCADE', + }, + ) notification_subscriptions!: NotificationSubscription[]; } diff --git a/src/datasources/notifications/entities/notification-subscription-notification-type.entity.db.ts b/src/datasources/notifications/entities/notification-subscription-notification-type.entity.db.ts new file mode 100644 index 0000000000..4732811f45 --- /dev/null +++ b/src/datasources/notifications/entities/notification-subscription-notification-type.entity.db.ts @@ -0,0 +1,44 @@ +import { + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { z } from 'zod'; + +export const NotificationSubscriptionNotificationTypeSchema = z.object({ + id: z.number(), +}); + +@Entity('notification_subscription_notification_types') +@Unique(['notification_subscription', 'notification_type']) +export class NotificationSubscriptionNotificationType + implements z.infer +{ + @PrimaryGeneratedColumn() + id!: number; + + @ManyToOne( + () => NotificationSubscription, + (subscription) => subscription.id, + { onDelete: 'CASCADE' }, + ) + @JoinColumn({ + name: 'notification_subscription_id', + }) + notification_subscription!: NotificationSubscription; + + @ManyToOne( + () => NotificationType, + (notificationType) => + notificationType.notification_subscription_notification_type, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'notification_type_id' }) + notification_type!: NotificationType; +} diff --git a/src/datasources/notifications/entities/notification-subscription-notification-type.entity.ts b/src/datasources/notifications/entities/notification-subscription-notification-type.entity.ts deleted file mode 100644 index 0b7cbc6b40..0000000000 --- a/src/datasources/notifications/entities/notification-subscription-notification-type.entity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; -import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity'; -import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity'; - -@Entity('notification_subscription_notification_types') -@Unique(['notification_subscription', 'notification_type']) -export class NotificationSubscriptionNotificationType { - @PrimaryGeneratedColumn() - id!: number; - - @ManyToOne( - () => NotificationSubscription, - (subscription) => subscription.id, - { onDelete: 'CASCADE' }, - ) - notification_subscription!: NotificationSubscription; - - @ManyToOne(() => NotificationType, (type) => type.id, { onDelete: 'CASCADE' }) - notification_type!: NotificationType; -} diff --git a/src/datasources/notifications/entities/notification-subscription.entity.db.ts b/src/datasources/notifications/entities/notification-subscription.entity.db.ts new file mode 100644 index 0000000000..aa2d91b747 --- /dev/null +++ b/src/datasources/notifications/entities/notification-subscription.entity.db.ts @@ -0,0 +1,103 @@ +import { RowSchema } from '@/datasources/db/v1/entities/row.entity'; +import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { + NotificationSubscriptionNotificationType, + NotificationSubscriptionNotificationTypeSchema, +} from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { + Column, + Entity, + Unique, + ManyToOne, + PrimaryGeneratedColumn, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { getAddress } from 'viem'; +import { z } from 'zod'; + +export const NotificationSubscriptionSchema = RowSchema.extend({ + chain_id: NumericStringSchema, + safe_address: AddressSchema, + signer_address: AddressSchema.nullable(), + notification_subscription_notification_type: z.array( + NotificationSubscriptionNotificationTypeSchema, + ), +}); + +@Entity('notification_subscriptions') +@Unique([ + 'chain_id', + 'safe_address', + 'push_notification_device', + 'signer_address', +]) +export class NotificationSubscription + implements z.infer +{ + @PrimaryGeneratedColumn() + id!: number; + + @ManyToOne(() => NotificationDevice, (device) => device.id, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'push_notification_device_id' }) + push_notification_device!: NotificationDevice; + + @Column({ + type: 'varchar', + length: 255, + }) + chain_id!: string; + + @Column({ + type: 'varchar', + length: 42, + transformer: { + from(value: string): `0x${string}` { + return getAddress(value); + }, + to(value: string): `0x${string}` { + return getAddress(value); + }, + }, + }) + safe_address!: `0x${string}`; + + @Column({ + type: 'varchar', + nullable: true, + length: 42, + transformer: { + from(value?: string): string | null { + return value ? getAddress(value) : null; + }, + to(value?: string): string | null { + return value ? getAddress(value) : null; + }, + }, + }) + signer_address!: `0x${string}` | null; + + @Column({ + type: 'timestamp with time zone', + default: () => 'CURRENT_TIMESTAMP', + }) + created_at!: Date; + + @Column({ + type: 'timestamp with time zone', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updated_at!: Date; + + @OneToMany( + () => NotificationSubscriptionNotificationType, + (notificationSubscriptionNotificationType) => + notificationSubscriptionNotificationType.id, + ) + notification_subscription_notification_type!: NotificationSubscriptionNotificationType[]; +} diff --git a/src/datasources/notifications/entities/notification-subscription.entity.ts b/src/datasources/notifications/entities/notification-subscription.entity.ts deleted file mode 100644 index 34867ffa16..0000000000 --- a/src/datasources/notifications/entities/notification-subscription.entity.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity'; -import { type NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity'; -import { - Column, - Entity, - Unique, - ManyToOne, - PrimaryGeneratedColumn, - OneToMany, -} from 'typeorm'; - -@Entity('notification_subscriptions') -@Unique([ - 'chain_id', - 'safe_address', - 'push_notification_device', - 'signer_address', -]) -export class NotificationSubscription { - @PrimaryGeneratedColumn() - id!: number; - - @ManyToOne(() => NotificationDevice, (device) => device.id, { - onDelete: 'CASCADE', - }) - push_notification_device!: NotificationDevice; - - @Column({ - type: 'varchar', - length: 255, - }) - chain_id!: string; - - @Column({ - type: 'varchar', - length: 42, - }) - safe_address!: string; - - @Column({ - type: 'varchar', - nullable: true, - length: 42, - }) - signer_address!: string | null; - - @Column({ - type: 'timestamp with time zone', - default: () => 'CURRENT_TIMESTAMP', - }) - created_at!: Date; - - @Column({ - type: 'timestamp with time zone', - default: () => 'CURRENT_TIMESTAMP', - onUpdate: 'CURRENT_TIMESTAMP', - }) - updated_at!: Date; - - @OneToMany( - () => NotificationSubscription, - (notificationSubscription) => notificationSubscription.id, - ) - notification_subscription_notification_type!: NotificationSubscriptionNotificationType[]; -} diff --git a/src/datasources/notifications/entities/notification-type.entity.db.ts b/src/datasources/notifications/entities/notification-type.entity.db.ts new file mode 100644 index 0000000000..ddc80753a6 --- /dev/null +++ b/src/datasources/notifications/entities/notification-type.entity.db.ts @@ -0,0 +1,44 @@ +import { + NotificationSubscriptionNotificationType, + NotificationSubscriptionNotificationTypeSchema, +} from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; +import { NotificationType as NotificationTypeEnum } from '@/domain/notifications/v2/entities/notification.entity'; +import { + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { z } from 'zod'; + +export const NotificationTypeSchema = z.object({ + id: z.number(), + name: z.nativeEnum(NotificationTypeEnum), + notification_subscription_notification_type: z.array( + NotificationSubscriptionNotificationTypeSchema, + ), +}); + +@Entity('notification_types') +@Unique('name', ['name']) +export class NotificationType + implements z.infer +{ + @PrimaryGeneratedColumn() + id!: number; + + @Column({ + type: 'enum', + enum: NotificationTypeEnum, + }) + name!: NotificationTypeEnum; + + @OneToMany( + () => NotificationSubscriptionNotificationType, + (notificationSubscriptionType) => + notificationSubscriptionType.notification_type, + { onDelete: 'CASCADE' }, + ) + notification_subscription_notification_type!: NotificationSubscriptionNotificationType[]; +} diff --git a/src/datasources/notifications/entities/notification-type.entity.ts b/src/datasources/notifications/entities/notification-type.entity.ts deleted file mode 100644 index d2e6c75a20..0000000000 --- a/src/datasources/notifications/entities/notification-type.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity'; -import { - Column, - Entity, - OneToMany, - PrimaryGeneratedColumn, - Unique, -} from 'typeorm'; - -@Entity('notification_types') -@Unique('name', ['name']) -export class NotificationType { - @PrimaryGeneratedColumn() - id!: number; - - @Column({ - type: 'varchar', - length: 255, - }) - name!: string; - - @OneToMany( - () => NotificationSubscriptionNotificationType, - (notificationType) => notificationType.id, - { onDelete: 'CASCADE' }, - ) - notification_subscription_notification_type!: NotificationSubscriptionNotificationType[]; -} diff --git a/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts index ac09dd70a0..1b9fde48a2 100644 --- a/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts +++ b/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts @@ -1,6 +1,6 @@ import type { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; import type { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; -import type { Uuid } from '@/domain/notifications/v2/entities/uuid.entity'; +import type { UUID } from 'crypto'; // TODO: Move to domain export type UpsertSubscriptionsDto = { @@ -11,5 +11,5 @@ export type UpsertSubscriptionsDto = { notificationTypes: Array; }>; deviceType: DeviceType; - deviceUuid?: Uuid; + deviceUuid?: UUID; }; diff --git a/src/datasources/notifications/notifications.datasource.module.ts b/src/datasources/notifications/notifications.datasource.module.ts deleted file mode 100644 index 76835757d6..0000000000 --- a/src/datasources/notifications/notifications.datasource.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; -import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; -import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; -import { Module } from '@nestjs/common'; - -@Module({ - imports: [PostgresDatabaseModule, AccountsDatasourceModule], - providers: [ - { provide: INotificationsDatasource, useClass: NotificationsDatasource }, - ], - exports: [INotificationsDatasource], -}) -export class NotificationsDatasourceModule {} diff --git a/src/datasources/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts deleted file mode 100644 index d8e9aaa13d..0000000000 --- a/src/datasources/notifications/notifications.datasource.spec.ts +++ /dev/null @@ -1,948 +0,0 @@ -import { TestDbFactory } from '@/__tests__/db.factory'; -import type { IConfigurationService } from '@/config/configuration.service.interface'; -import { upsertSubscriptionsDtoBuilder } from '@/routes/notifications/v1/entities/__tests__/upsert-subscriptions.dto.entity.builder'; -import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; -import { PostgresDatabaseMigrator } from '@/datasources/db/v1/postgres-database.migrator'; -import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; -import type { UUID } from 'crypto'; -import type { ILoggingService } from '@/logging/logging.interface'; -import { faker } from '@faker-js/faker'; -import type postgres from 'postgres'; -import { getAddress } from 'viem'; - -const mockLoggingService = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), -} as jest.MockedObjectDeep; - -const mockConfigurationService = jest.mocked({ - getOrThrow: jest.fn(), -} as jest.MockedObjectDeep); - -describe('NotificationsDatasource', () => { - let migrator: PostgresDatabaseMigrator; - let sql: postgres.Sql; - const testDbFactory = new TestDbFactory(); - let target: NotificationsDatasource; - - beforeAll(async () => { - sql = await testDbFactory.createTestDatabase(faker.string.uuid()); - migrator = new PostgresDatabaseMigrator(sql); - await migrator.migrate(); - mockConfigurationService.getOrThrow.mockImplementation((key) => { - if (key === 'expirationTimeInSeconds.default') return faker.number.int(); - }); - target = new NotificationsDatasource(sql, mockLoggingService); - }); - - afterEach(async () => { - // Don't truncate notification_types as it has predefined rows - await sql`TRUNCATE TABLE push_notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; - }); - - afterAll(async () => { - await testDbFactory.destroyTestDatabase(sql); - }); - - describe('upsertSubscriptions', () => { - describe('with signer', () => { - it('should insert a subscription', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', null) - .build(); - - const actual = await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: actual.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: i + 1, - signer_address: signerAddress, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ); - }); - }); - - it('should always update the deviceType/cloudMessagingToken', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) - .build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - // Insert should not throw despite it being the same device UUID - await expect( - target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto: secondSubscriptionsDto, - }), - ).resolves.not.toThrow(); - // Device UUID should have updated - await expect( - sql`SELECT * FROM push_notification_devices`, - ).resolves.toStrictEqual([ - { - id: 1, - device_type: secondSubscriptionsDto.deviceType, - device_uuid: expect.any(String), - cloud_messaging_token: secondSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - }); - - it('should update a subscription, setting only the newly subscribed notification types', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - ]) - .build(); - const newNotificationTypes = faker.helpers.arrayElements( - Object.values(NotificationType), - ); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto: { - ...upsertSubscriptionsDto, - safes: [ - { - ...upsertSubscriptionsDto.safes[0], - notificationTypes: newNotificationTypes, - }, - ], - }, - }); - - await Promise.all([ - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([notificationTypes, subscribedNotifications]) => { - // Only new notification types should be subscribed to - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - newNotificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: expect.any(Number), - notification_type_id: notificationTypes.find( - (t) => t.name === type, - )?.id, - }; - }), - ), - ); - }); - }); - - it('should allow multiple subscriptions, varying by device UUID', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondDeviceUuid = faker.string.uuid() as UUID; - const secondUpsertSubscriptionsDto = { - ...upsertSubscriptionsDto, - deviceUuid: secondDeviceUuid, - }; - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto: secondUpsertSubscriptionsDto, - }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 2, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: secondDeviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes - .map((safe, i) => { - return { - id: i + 1, - signer_address: signerAddress, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }) - .concat( - secondUpsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: upsertSubscriptionsDto.safes.length + i + 1, - signer_address: signerAddress, - push_notification_device_id: devices[1].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes - .flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }) - .concat( - secondUpsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: - upsertSubscriptionsDto.safes.length + i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ), - ); - }); - }); - - it('should allow multiple subscriptions, varying by signer', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondSignerAddress = getAddress(faker.finance.ethereumAddress()); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress: secondSignerAddress, - upsertSubscriptionsDto: upsertSubscriptionsDto, - }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes - .map((safe, i) => { - return { - id: i + 1, - signer_address: signerAddress, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }) - .concat( - upsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: upsertSubscriptionsDto.safes.length + i + 1, - signer_address: secondSignerAddress, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes - .flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }) - .concat( - upsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: - upsertSubscriptionsDto.safes.length + i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ), - ); - }); - }); - }); - - describe('without signer', () => { - it('should insert a subscription', async () => { - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', null) - .build(); - - const actual = await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto, - }); - - expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: actual.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: i + 1, - signer_address: null, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ); - }); - }); - - it('should always update the deviceType/cloudMessagingToken', async () => { - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) - .build(); - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto, - }); - - // Insert should not throw despite it being the same device UUID - await expect( - target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto: secondSubscriptionsDto, - }), - ).resolves.not.toThrow(); - // Device UUID should have updated - await expect( - sql`SELECT * FROM push_notification_devices`, - ).resolves.toStrictEqual([ - { - id: 1, - device_type: secondSubscriptionsDto.deviceType, - device_uuid: expect.any(String), - cloud_messaging_token: secondSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - }); - - it('should update a subscription, setting only the newly subscribed notification types', async () => { - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - ]) - .build(); - const newNotificationTypes = faker.helpers.arrayElements( - Object.values(NotificationType), - ); - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto: { - ...upsertSubscriptionsDto, - safes: [ - { - ...upsertSubscriptionsDto.safes[0], - notificationTypes: newNotificationTypes, - }, - ], - }, - }); - - await Promise.all([ - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([notificationTypes, subscribedNotifications]) => { - // Only new notification types should be subscribed to - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - newNotificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: 2, - notification_type_id: notificationTypes.find( - (t) => t.name === type, - )?.id, - }; - }), - ), - ); - }); - }); - - it('should allow multiple subscriptions, varying by device UUID', async () => { - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondDeviceUuid = faker.string.uuid() as UUID; - const secondUpsertSubscriptionsDto = { - ...upsertSubscriptionsDto, - deviceUuid: secondDeviceUuid, - }; - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto: secondUpsertSubscriptionsDto, - }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 2, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: secondDeviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes - .map((safe, i) => { - return { - id: i + 1, - signer_address: null, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }) - .concat( - secondUpsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: upsertSubscriptionsDto.safes.length + i + 1, - signer_address: null, - push_notification_device_id: devices[1].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes - .flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }) - .concat( - secondUpsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: - upsertSubscriptionsDto.safes.length + i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ), - ); - }); - }); - - it('should assign an unknown subscription of a device to a signer', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await target.upsertSubscriptions({ - signerAddress: undefined, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto: upsertSubscriptionsDto, - }); - - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM push_notification_devices`, - sql`SELECT * FROM notification_types`, - sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - ]).then(([devices, types, subscriptions, subscribedNotifications]) => { - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: upsertSubscriptionsDto.safes.length + i + 1, - signer_address: signerAddress, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: - upsertSubscriptionsDto.safes.length + i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ); - }); - }); - }); - }); - - describe('getSafeSubscription', () => { - it('should return a subscription for a Safe', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - const safe = upsertSubscriptionsDto.safes[0]; - await expect( - target.getSafeSubscription({ - signerAddress, - deviceUuid: upsertSubscriptionsDto.deviceUuid!, - chainId: safe.chainId, - safeAddress: safe.address, - }), - ).resolves.toStrictEqual(expect.arrayContaining(safe.notificationTypes)); - }); - }); - - describe('getSubscribersBySafe', () => { - it('should return a list of subscribers with tokens for a Safe', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondSignerAddress = getAddress(faker.finance.ethereumAddress()); - const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', upsertSubscriptionsDto.safes) - .build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress: secondSignerAddress, - upsertSubscriptionsDto: secondUpsertSubscriptionsDto, - }); - - const safe = upsertSubscriptionsDto.safes[0]; - await expect( - target.getSubscribersBySafe({ - chainId: safe.chainId, - safeAddress: safe.address, - }), - ).resolves.toStrictEqual([ - { - subscriber: signerAddress, - deviceUuid: upsertSubscriptionsDto.deviceUuid!, - cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, - }, - { - subscriber: secondSignerAddress, - deviceUuid: secondUpsertSubscriptionsDto.deviceUuid!, - cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken, - }, - ]); - }); - }); - - describe('deleteSubscription', () => { - it('should delete a subscription and orphaned device', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - ]) - .build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - const safe = upsertSubscriptionsDto.safes[0]; - await target.deleteSubscription({ - deviceUuid: upsertSubscriptionsDto.deviceUuid!, - chainId: safe.chainId, - safeAddress: safe.address, - }); - - await expect( - sql`SELECT * FROM notification_subscriptions WHERE chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, - ).resolves.toStrictEqual([]); - await expect( - sql`SELECT * FROM push_notification_devices WHERE device_uuid = ${upsertSubscriptionsDto.deviceUuid as UUID}`, - ).resolves.toStrictEqual([]); - }); - - it('should not delete subscriptions of other device UUIDs', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - ]) - .build(); - const secondDeviceUuid = faker.string.uuid() as UUID; - const secondUpsertSubscriptionsDto = { - ...upsertSubscriptionsDto, - deviceUuid: secondDeviceUuid, - }; - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto: secondUpsertSubscriptionsDto, - }); - - const safe = upsertSubscriptionsDto.safes[0]; - await target.deleteSubscription({ - deviceUuid: upsertSubscriptionsDto.deviceUuid!, - chainId: safe.chainId, - safeAddress: safe.address, - }); - - // The second subscription should remain - await expect( - sql`SELECT * FROM notification_subscriptions`, - ).resolves.toStrictEqual([ - { - id: 2, - signer_address: signerAddress, - push_notification_device_id: 2, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - }); - - it('should not delete devices with other subscriptions', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - { - chainId: faker.string.numeric(), - address: getAddress(faker.finance.ethereumAddress()), - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), - }, - ]) - .build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - const safe = upsertSubscriptionsDto.safes[0]; - await target.deleteSubscription({ - deviceUuid: upsertSubscriptionsDto.deviceUuid!, - chainId: safe.chainId, - safeAddress: safe.address, - }); - - // Device should not have been deleted - await expect( - sql`SELECT * FROM push_notification_devices`, - ).resolves.toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - }); - }); - - describe('deleteDevice', () => { - it('should delete all subscriptions of a device', async () => { - const signerAddress = getAddress(faker.finance.ethereumAddress()); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await target.upsertSubscriptions({ - signerAddress, - upsertSubscriptionsDto, - }); - - await target.deleteDevice(upsertSubscriptionsDto.deviceUuid!); - - // All subscriptions of the device should be deleted - await expect( - sql`SELECT * FROM notification_subscriptions`, - ).resolves.toStrictEqual([]); - }); - }); -}); diff --git a/src/datasources/notifications/notifications.datasource.ts b/src/datasources/notifications/notifications.datasource.ts deleted file mode 100644 index dc959415a2..0000000000 --- a/src/datasources/notifications/notifications.datasource.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { NotificationType as DomainNotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; -import { UUID } from 'crypto'; -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; -import { asError } from '@/logging/utils'; -import { - Inject, - Injectable, - NotFoundException, - UnprocessableEntityException, -} from '@nestjs/common'; -import postgres from 'postgres'; -import { UpsertSubscriptionsDto } from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; - -@Injectable() -export class NotificationsDatasource implements INotificationsDatasource { - constructor( - @Inject('DB_INSTANCE') - private readonly sql: postgres.Sql, - @Inject(LoggingService) - private readonly loggingService: ILoggingService, - ) {} - - /** - * Upserts subscriptions for the given signer/device as per the list of Safes - * and notification types provided. - * - * @param args.signerAddress Signer address (optional) - * @param args.upsertSubscriptionsDto {@link UpsertSubscriptionsDto} DTO - * - * @returns Device UUID - */ - async upsertSubscriptions(args: { - signerAddress?: `0x${string}`; - upsertSubscriptionsDto: UpsertSubscriptionsDto; - }): Promise<{ deviceUuid: UUID }> { - const deviceUuid = - args.upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); - - await this.sql.begin(async (sql) => { - // Insert (or update the type/cloud messaging token of) a device - const [device] = await sql<[{ id: number }]>` - INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) - VALUES (${args.upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${args.upsertSubscriptionsDto.cloudMessagingToken}) - ON CONFLICT (device_uuid) - DO UPDATE SET - cloud_messaging_token = EXCLUDED.cloud_messaging_token, - device_type = EXCLUDED.device_type, - -- If updated_at is not set ON CONFLICT, an error is thrown meaning nothing is returned - updated_at = NOW() - RETURNING id - `.catch((e) => { - const error = 'Error getting device'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new UnprocessableEntityException(error); - }); - - // For each Safe, upsert the subscription and overwrite the subscribed-to notification types - await Promise.all( - args.upsertSubscriptionsDto.safes.map(async (safe) => { - try { - // 1. Delete previous subscriptions (and by cascade, notification types) - // to avoid duplicates, and clearing "unknown" subscriptions - await sql` - DELETE FROM notification_subscriptions - WHERE chain_id = ${safe.chainId} - AND safe_address = ${safe.address} - AND ( - signer_address = ${args.signerAddress ?? null} - OR ( - signer_address IS NULL - ) - ) - AND push_notification_device_id = ${device.id} - `; - - // 2. Upsert subscription - const [subscription] = await sql<[{ id: number }]>` - INSERT INTO notification_subscriptions (chain_id, safe_address, signer_address, push_notification_device_id) - VALUES (${safe.chainId}, ${safe.address}, ${args.signerAddress ?? null}, ${device.id}) - ON CONFLICT (chain_id, safe_address, signer_address, push_notification_device_id) - -- If no value is set ON CONFLICT, an error is thrown meaning nothing is returned - DO UPDATE SET updated_at = NOW() - RETURNING id - `; - - // 3. Insert subscribed-to notification types - await sql` - INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) - SELECT ${subscription.id}, id - FROM notification_types - WHERE name = ANY(${safe.notificationTypes}) - `; - } catch (e) { - const error = 'Error upserting subscription'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new NotFoundException(); - } - }), - ); - }); - - return { deviceUuid }; - } - - /** - * Gets notification preferences for given signer/device for the given Safe. - * - * @param args.deviceUuid Device UUID - * @param args.chainId Chain ID - * @param args.safeAddress Safe address - * @param args.signerAddress Signer address - * - * @returns List of {@link DomainNotificationType} notifications subscribed to - */ - async getSafeSubscription(args: { - deviceUuid: UUID; - chainId: string; - safeAddress: `0x${string}`; - signerAddress: `0x${string}`; - }): Promise> { - const notificationTypes = await this.sql< - Array<{ name: DomainNotificationType }> - >` - SELECT nt.name - FROM notification_subscriptions ns - JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id - JOIN notification_subscription_notification_types nsnt ON ns.id = nsnt.notification_subscription_id - JOIN notification_types nt ON nsnt.notification_type_id = nt.id - WHERE ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress} - AND ns.signer_address = ${args.signerAddress} - AND pnd.device_uuid = ${args.deviceUuid} - `.catch((e) => { - const error = 'Error getting subscription or notification types'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new NotFoundException(error); - }); - - return notificationTypes.map((notificationType) => notificationType.name); - } - - /** - * Gets subscribers and their device UUID/cloud messaging tokens for the given Safe. - * - * @param args.chainId Chain ID - * @param args.safeAddress Safe address - * - * @returns List of subscribers/tokens for given Safe - */ - async getSubscribersBySafe(args: { - chainId: string; - safeAddress: string; - }): Promise< - Array<{ - subscriber: `0x${string}` | null; - deviceUuid: UUID; - cloudMessagingToken: string; - }> - > { - const subscribers = await this.sql< - Array<{ - signer_address: `0x${string}` | null; - cloud_messaging_token: string; - device_uuid: UUID; - }> - >` - SELECT - pd.cloud_messaging_token, - ns.signer_address, - pd.device_uuid - FROM - push_notification_devices pd - JOIN - notification_subscriptions ns ON pd.id = ns.push_notification_device_id - WHERE - ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress}; - `.catch((e) => { - const error = 'Error getting subscribers with tokens'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new NotFoundException(error); - }); - - return subscribers.map((subscriber) => { - return { - subscriber: subscriber.signer_address, - deviceUuid: subscriber.device_uuid, - cloudMessagingToken: subscriber.cloud_messaging_token, - }; - }); - } - - /** - * Deletes the Safe subscription for the given signer/device. - * - * @param args.deviceUuid Device UUID - * @param args.chainId Chain ID - * @param args.safeAddress Safe address - * @param args.signerAddress Signer address - */ - async deleteSubscription(args: { - deviceUuid: UUID; - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - await this.sql.begin(async (sql) => { - try { - // 1. Delete the subscription and return device ID - const [deletedSubscription] = await sql< - [{ push_notification_device_id: number }] - >` - DELETE FROM notification_subscriptions ns - USING push_notification_devices pnd - WHERE ns.push_notification_device_id = pnd.id - AND pnd.device_uuid = ${args.deviceUuid} - AND ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress} - RETURNING ns.push_notification_device_id; - `; - - // 2. Check if there any remaining subscriptions for device - const remainingSubscriptions = await sql` - SELECT 1 - FROM notification_subscriptions - WHERE push_notification_device_id = ${deletedSubscription.push_notification_device_id} - `; - - // 3. If no subscriptions, delete orphaned device - if (remainingSubscriptions.length === 0) { - // Note: we can't use this.deleteDevice here as we are in a transaction - await sql` - DELETE FROM push_notification_devices - WHERE device_uuid = ${args.deviceUuid} - `; - } - } catch (e) { - const error = 'Error deleting subscription'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new NotFoundException(error); - } - }); - } - - /** - * Deletes subscriptions for the given device UUID. - * - * @param deviceUuid Device UUID - */ - async deleteDevice(deviceUuid: UUID): Promise { - await this.sql` - DELETE FROM push_notification_devices - WHERE device_uuid = ${deviceUuid} - `.catch((e) => { - const error = 'Error deleting device'; - this.loggingService.warn(`${error}: ${asError(e).message}`); - throw new UnprocessableEntityException(error); - }); - } -} diff --git a/src/domain/hooks/helpers/event-notifications.helper.ts b/src/domain/hooks/helpers/event-notifications.helper.ts index 2355d52676..f390138f56 100644 --- a/src/domain/hooks/helpers/event-notifications.helper.ts +++ b/src/domain/hooks/helpers/event-notifications.helper.ts @@ -13,10 +13,7 @@ import { } from '@/routes/hooks/entities/event-type.entity'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { Event } from '@/routes/hooks/entities/event.entity'; -import { - INotificationsRepositoryV2, - NotificationsRepositoryV2Module, -} from '@/domain/notifications/v2/notifications.repository.interface'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; @@ -37,6 +34,7 @@ import { IDelegatesV2Repository, } from '@/domain/delegate/v2/delegates.v2.repository.interface'; import { UUID } from 'crypto'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; type EventToNotify = | DeletedMultisigTransactionEvent diff --git a/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts b/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts new file mode 100644 index 0000000000..06bdb343fe --- /dev/null +++ b/src/domain/notifications/v2/entities/__tests__/notification.repository.mock.ts @@ -0,0 +1,11 @@ +import type { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; + +export const MockNotificationRepositoryV2: jest.MockedObjectDeep = + { + enqueueNotification: jest.fn(), + upsertSubscriptions: jest.fn(), + getSafeSubscription: jest.fn(), + getSubscribersBySafe: jest.fn(), + deleteSubscription: jest.fn(), + deleteDevice: jest.fn(), + }; diff --git a/src/domain/notifications/v2/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/domain/notifications/v2/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts index 4c34391083..9951f4fa01 100644 --- a/src/domain/notifications/v2/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/domain/notifications/v2/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -4,14 +4,14 @@ import { Builder } from '@/__tests__/builder'; import { getAddress } from 'viem'; import type { UpsertSubscriptionsDto } from '@/domain/notifications/v2/entities/upsert-subscriptions.dto.entity'; import { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; -import type { Uuid } from '@/domain/notifications/v2/entities/uuid.entity'; import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; +import type { UUID } from 'crypto'; export function upsertSubscriptionsDtoBuilder(): IBuilder { return new Builder() .with('cloudMessagingToken', faker.string.alphanumeric({ length: 10 })) .with('deviceType', faker.helpers.arrayElement(Object.values(DeviceType))) - .with('deviceUuid', faker.string.uuid() as Uuid) + .with('deviceUuid', faker.string.uuid() as UUID) .with( 'safes', Array.from( diff --git a/src/domain/notifications/v2/entities/device-type.entity.ts b/src/domain/notifications/v2/entities/device-type.entity.ts index 996df9dd7e..26ca0ad285 100644 --- a/src/domain/notifications/v2/entities/device-type.entity.ts +++ b/src/domain/notifications/v2/entities/device-type.entity.ts @@ -1,5 +1,5 @@ export enum DeviceType { - ANDROID = 'ANDROID', - IOS = 'IOS', - WEB = 'WEB', + Android = 'ANDROID', + Ios = 'IOS', + Web = 'WEB', } diff --git a/src/domain/notifications/v2/entities/upsert-subscriptions.dto.entity.ts b/src/domain/notifications/v2/entities/upsert-subscriptions.dto.entity.ts index 9a9971e926..723ac466d1 100644 --- a/src/domain/notifications/v2/entities/upsert-subscriptions.dto.entity.ts +++ b/src/domain/notifications/v2/entities/upsert-subscriptions.dto.entity.ts @@ -1,6 +1,6 @@ import type { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; import type { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; -import type { Uuid } from '@/domain/notifications/v2/entities/uuid.entity'; +import type { UUID } from 'crypto'; export type UpsertSubscriptionsDto = { cloudMessagingToken: string; @@ -10,5 +10,5 @@ export type UpsertSubscriptionsDto = { notificationTypes: Array; }>; deviceType: DeviceType; - deviceUuid?: Uuid; + deviceUuid?: UUID; }; diff --git a/src/domain/notifications/v2/entities/uuid.entity.ts b/src/domain/notifications/v2/entities/uuid.entity.ts deleted file mode 100644 index feddb9c4e2..0000000000 --- a/src/domain/notifications/v2/entities/uuid.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export type Uuid = `${string}-${string}-${string}-${string}-${string}`; 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..5561f8e112 --- /dev/null +++ b/src/domain/notifications/v2/notifications.repository.integration.spec.ts @@ -0,0 +1,633 @@ +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} Returns an instance of PostgresDatabaseService + */ + async function createDatabaseConnection(): Promise { + const databaseService = new PostgresDatabaseService( + mockLoggingService, + dataSource, + ); + await databaseService.initializeDatabaseConnection(); + + return databaseService; + } + + /** + * 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. + * + * @param {postgresDatabaseService} postgresDatabaseService The postgres database service instance + * + * @async + * @function migrateDatabase + * @returns {Promise} Resolves when migrations are complete. + */ + async function migrateDatabase( + postgresDatabaseService: PostgresDatabaseService, + ): 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(); + postgresDatabaseService = await createDatabaseConnection(); + await migrateDatabase(postgresDatabaseService); + + 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, 'transaction') + .mockImplementation( + ( + runInTransaction: (entityManager: EntityManager) => Promise, + ): Promise => { + return runInTransaction(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.interface.ts b/src/domain/notifications/v2/notifications.repository.interface.ts index b00481e59f..ed74a11644 100644 --- a/src/domain/notifications/v2/notifications.repository.interface.ts +++ b/src/domain/notifications/v2/notifications.repository.interface.ts @@ -1,12 +1,8 @@ -import { UpsertSubscriptionsDto } from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; -import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; -import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; -import { UUID } from 'crypto'; -import { NotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository'; -import { Module } from '@nestjs/common'; -import { NotificationsDatasourceModule } from '@/datasources/notifications/notifications.datasource.module'; -import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; -import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import type { UpsertSubscriptionsDto } from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; +import type { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; +import type { UUID } from 'crypto'; +import type { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import type { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); @@ -50,15 +46,3 @@ export interface INotificationsRepositoryV2 { deleteDevice(deviceUuid: UUID): Promise; } - -@Module({ - imports: [PushNotificationsApiModule, NotificationsDatasourceModule], - providers: [ - { - provide: INotificationsRepositoryV2, - useClass: NotificationsRepositoryV2, - }, - ], - exports: [INotificationsRepositoryV2], -}) -export class NotificationsRepositoryV2Module {} diff --git a/src/domain/notifications/v2/notifications.repository.module.ts b/src/domain/notifications/v2/notifications.repository.module.ts new file mode 100644 index 0000000000..f95deb69ae --- /dev/null +++ b/src/domain/notifications/v2/notifications.repository.module.ts @@ -0,0 +1,31 @@ +import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; +import { NotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository'; +import { Module } from '@nestjs/common'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; +import { PostgresDatabaseModuleV2 } from '@/datasources/db/v2/postgres-database.module'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; + +@Module({ + imports: [ + PostgresDatabaseModuleV2, + PushNotificationsApiModule, + TypeOrmModule.forFeature([ + NotificationType, + NotificationDevice, + NotificationSubscription, + NotificationSubscriptionNotificationType, + ]), + ], + providers: [ + { + provide: INotificationsRepositoryV2, + useClass: NotificationsRepositoryV2, + }, + ], + exports: [INotificationsRepositoryV2], +}) +export class NotificationsRepositoryV2Module {} diff --git a/src/domain/notifications/v2/notifications.repository.spec.ts b/src/domain/notifications/v2/notifications.repository.spec.ts new file mode 100644 index 0000000000..760848316b --- /dev/null +++ b/src/domain/notifications/v2/notifications.repository.spec.ts @@ -0,0 +1,432 @@ +import type { UUID } from 'crypto'; +import { faker } from '@faker-js/faker/.'; +import { UnauthorizedException } from '@nestjs/common'; +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 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 { 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; + const notificationTypeRepository = { ...mockRepository }; + const notificationDeviceRepository = { ...mockRepository }; + const notificationSubscriptionRepository = { ...mockRepository }; + const notificationSubscriptionsRepository = { ...mockRepository }; + const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + } as jest.MockedObjectDeep; + const mockPushNotificationsApi: IPushNotificationsApi = { + enqueueNotification: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + notificationsRepository = new NotificationsRepositoryV2( + mockPushNotificationsApi, + mockLoggingService, + 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(), + ); + const mockSubscriptions = Array.from({ length: 4 }, () => + notificationSubscriptionBuilder().build(), + ); + mockEntityManager.find.mockResolvedValue(mockNotificationTypes); + notificationSubscriptionRepository.find.mockResolvedValue( + mockSubscriptions, + ); + 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(), + ); + 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 as string, + safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + }; + + const result = await notificationsRepository.getSafeSubscription(args); + + expect(notificationTypeRepository.find).toHaveBeenCalledWith({ + select: { name: true }, + where: { + notification_subscription_notification_type: { + notification_subscription: { + push_notification_device: { device_uuid: args.deviceUuid }, + chain_id: args.chainId, + safe_address: args.safeAddress, + signer_address: args.authPayload.signer_address, + }, + }, + }, + }); + expect(result).toEqual(mockNotificationTypes); + }); + + it('Should throw UnauthorizedException if signer_address is not passed', async () => { + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', '' as `0x${string}`) + .build(); + const authPayload = new AuthPayload(authPayloadDto); + + const args = { + authPayload, + deviceUuid: faker.string.uuid() as UUID, + chainId: authPayload.chain_id as string, + safeAddress: faker.string.hexadecimal({ + length: 32, + }) as `0x${string}`, + }; + const result = notificationsRepository.getSafeSubscription(args); + + await expect(result).rejects.toThrow(UnauthorizedException); + }); + + it('Should return an empty array if there is no notification type for safe', async () => { + const mockNotificationTypes: Array = []; + 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 as string, + safeAddress: faker.string.hexadecimal({ + length: 32, + }) as `0x${string}`, + }; + const result = await notificationsRepository.getSafeSubscription(args); + + expect(notificationTypeRepository.find).toHaveBeenCalledWith({ + select: { name: true }, + where: { + notification_subscription_notification_type: { + notification_subscription: { + push_notification_device: { device_uuid: args.deviceUuid }, + chain_id: args.chainId, + safe_address: args.safeAddress, + signer_address: args.authPayload.signer_address, + }, + }, + }, + }); + expect(result).toEqual(mockNotificationTypes); + }); + }); + + describe('getSubscribersBySafe()', () => { + it('Should successfully return subscribers by safe', async () => { + const mockSubscribers = Array.from( + { length: 5 }, + (): NotificationSubscription => + notificationSubscriptionBuilder().build(), + ); + notificationSubscriptionsRepository.find.mockResolvedValue( + mockSubscribers, + ); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionsRepository, + ); + + const result = await notificationsRepository.getSubscribersBySafe({ + chainId: faker.number.int({ min: 1, max: 100 }).toString(), + safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + }); + + const output = mockSubscribers.map( + (subscription: NotificationSubscription) => { + return { + subscriber: subscription.signer_address, + deviceUuid: subscription.push_notification_device.device_uuid, + cloudMessagingToken: + subscription.push_notification_device.cloud_messaging_token, + }; + }, + ); + + expect(result).toEqual(output); + }); + + it('Should return empty when there is no subscribers for safe', async () => { + notificationSubscriptionsRepository.find.mockResolvedValue([]); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionsRepository, + ); + + const result = await notificationsRepository.getSubscribersBySafe({ + chainId: faker.number.int({ min: 1, max: 100 }).toString(), + safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + }); + + expect(result).toEqual([]); + }); + }); + + describe('deleteSubscription()', () => { + it('Should remove a subscription successfully', async () => { + const mockNotificationSubscription = + notificationSubscriptionBuilder().build(); + + notificationSubscriptionRepository.findOne.mockResolvedValue( + mockNotificationSubscription, + ); + + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionRepository, + ); + + const args = { + deviceUuid: faker.string.uuid() as UUID, + chainId: faker.number.int({ min: 0 }).toString(), + safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + }; + + await notificationsRepository.deleteSubscription(args); + + expect(notificationSubscriptionRepository.findOne).toHaveBeenCalledTimes( + 1, + ); + expect(notificationSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { + chain_id: args.chainId, + safe_address: args.safeAddress, + push_notification_device: { + device_uuid: args.deviceUuid, + }, + }, + }); + expect(notificationSubscriptionRepository.remove).toHaveBeenCalledTimes( + 1, + ); + expect(notificationSubscriptionRepository.remove).toHaveBeenCalledWith( + mockNotificationSubscription, + ); + }); + + it('Should not call remove if no subscription is found', async () => { + notificationSubscriptionRepository.findOne.mockResolvedValue(null); + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationSubscriptionRepository, + ); + + const args = { + deviceUuid: faker.string.uuid() as UUID, + chainId: faker.number.int({ min: 0 }).toString(), + safeAddress: faker.string.hexadecimal({ length: 32 }) as `0x${string}`, + }; + + await notificationsRepository.deleteSubscription(args); + + expect(notificationSubscriptionRepository.findOne).toHaveBeenCalledTimes( + 1, + ); + expect(notificationSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { + chain_id: args.chainId, + safe_address: args.safeAddress, + push_notification_device: { + device_uuid: args.deviceUuid, + }, + }, + }); + expect(notificationSubscriptionRepository.remove).not.toHaveBeenCalled(); + }); + }); + + describe('deleteDevice()', () => { + it('Should delete a device successfully', async () => { + mockPostgresDatabaseService.getRepository.mockResolvedValue( + notificationDeviceRepository, + ); + + const deviceUuid = faker.string.uuid() as UUID; + + await notificationsRepository.deleteDevice(deviceUuid); + + expect(notificationDeviceRepository.delete).toHaveBeenCalled(); + expect(notificationDeviceRepository.delete).toHaveBeenCalledWith({ + device_uuid: deviceUuid, + }); + }); + }); +}); diff --git a/src/domain/notifications/v2/notifications.repository.ts b/src/domain/notifications/v2/notifications.repository.ts index a22660e4a6..ed15b65fdf 100644 --- a/src/domain/notifications/v2/notifications.repository.ts +++ b/src/domain/notifications/v2/notifications.repository.ts @@ -1,9 +1,8 @@ import { UpsertSubscriptionsDto } from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { UUID } from 'crypto'; -import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; +import type { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; import { Inject, Injectable, @@ -11,9 +10,14 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; import { get } from 'lodash'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationSubscription } from '@/datasources/notifications/entities/notification-subscription.entity.db'; +import { NotificationDevice } from '@/datasources/notifications/entities/notification-devices.entity.db'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import { In, type EntityManager } from 'typeorm'; +import { PostgresDatabaseService } from '@/datasources/db/v2/postgres-database.service'; +import { NotificationSubscriptionNotificationType } from '@/datasources/notifications/entities/notification-subscription-notification-type.entity.db'; @Injectable() export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { @@ -47,13 +51,13 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { constructor( @Inject(IPushNotificationsApi) private readonly pushNotificationsApi: IPushNotificationsApi, - @Inject(INotificationsDatasource) - private readonly notificationsDatasource: INotificationsDatasource, @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(PostgresDatabaseService) + private readonly postgresDatabaseService: PostgresDatabaseService, ) {} - async enqueueNotification(args: { + public async enqueueNotification(args: { token: string; deviceUuid: UUID; notification: FirebaseNotification; @@ -68,8 +72,7 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { this.loggingService.info( `Deleting device due to stale token ${args.deviceUuid}: ${e}`, ); - await this.notificationsDatasource - .deleteDevice(args.deviceUuid) + await this.deleteDevice(args.deviceUuid) // No need to log as datasource does .catch(() => null); } else { @@ -88,19 +91,192 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { return isNotFound && isUnregistered; } - async upsertSubscriptions(args: { + public async upsertSubscriptions(args: { authPayload: AuthPayload; upsertSubscriptionsDto: UpsertSubscriptionsDto; }): Promise<{ deviceUuid: UUID; }> { - return this.notificationsDatasource.upsertSubscriptions({ - signerAddress: args.authPayload.signer_address, - upsertSubscriptionsDto: args.upsertSubscriptionsDto, + const deviceUuid = await this.postgresDatabaseService.transaction( + async (entityManager: EntityManager): Promise => { + const device = await this.upsertDevice(entityManager, args); + await this.deletePreviousSubscriptions(entityManager, { + deviceId: device.id, + signerAddress: args.authPayload.signer_address, + upsertSubscriptionsDto: args.upsertSubscriptionsDto, + }); + + const subscriptions = await this.upsertSubscription(entityManager, { + ...args, + deviceId: device.id, + }); + + await this.insertSubscriptionNotificationTypes(entityManager, { + subscriptions, + upsertSubscriptionsDto: args.upsertSubscriptionsDto, + }); + + return device.device_uuid; + }, + ); + + return { deviceUuid }; + } + + private async upsertDevice( + entityManager: EntityManager, + args: { + authPayload: AuthPayload; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }, + ): Promise> { + const deviceUuid = + args.upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); + + const queryResult = await entityManager.upsert( + NotificationDevice, + { + device_uuid: deviceUuid, + device_type: args.upsertSubscriptionsDto.deviceType, + cloud_messaging_token: args.upsertSubscriptionsDto.cloudMessagingToken, + }, + ['device_uuid'], + ); + + return { id: queryResult.identifiers[0].id, device_uuid: deviceUuid }; + } + + private async deletePreviousSubscriptions( + entityManager: EntityManager, + args: { + deviceId: number; + signerAddress?: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }, + ): Promise { + for (const safe of args.upsertSubscriptionsDto.safes) { + await entityManager + .createQueryBuilder() + .delete() + .from(NotificationSubscription) + .where( + `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: args.deviceId, + signerAddress: args.signerAddress ?? null, + }, + ) + .execute(); + } + } + + private async upsertSubscription( + entityManager: EntityManager, + args: { + authPayload: AuthPayload; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + deviceId: number; + }, + ): Promise> { + const subscriptionsToInsert: Partial[] = []; + for (const safe of args.upsertSubscriptionsDto.safes) { + const device = new NotificationDevice(); + device.id = args.deviceId; + subscriptionsToInsert.push({ + chain_id: safe.chainId, + safe_address: safe.address, + signer_address: args.authPayload.signer_address ?? null, + push_notification_device: device, + }); + } + + const insertResult = await entityManager.upsert( + NotificationSubscription, + subscriptionsToInsert, + [ + 'chain_id', + 'safe_address', + 'signer_address', + 'push_notification_device', + ], + ); + const subscriptionIds: Array = insertResult.identifiers.map( + (subscriptionIdentifier) => subscriptionIdentifier.id, + ); + + const subscriptions = await this.getSubscriptionsById(subscriptionIds); + + return subscriptions; + } + + public async getSubscriptionsById( + subscriptionIds: Array, + ): Promise> { + const notificationSubscriptionRepository = + await this.postgresDatabaseService.getRepository( + NotificationSubscription, + ); + + return await notificationSubscriptionRepository.find({ + where: { id: In(subscriptionIds) }, }); } - getSafeSubscription(args: { + private async insertSubscriptionNotificationTypes( + entityManager: EntityManager, + arg: { + upsertSubscriptionsDto: UpsertSubscriptionsDto; + subscriptions: Array; + }, + ): Promise { + const notificationTypesMap = new Map(); // A map of all the notification types in request along with their database entity + const notificationTypes = arg.upsertSubscriptionsDto.safes.flatMap( + (safe) => safe.notificationTypes, + ); + const uniqueNotificationTypes = new Set(notificationTypes); + + const notificationTypeObjects = await entityManager.find(NotificationType, { + where: { name: In([...uniqueNotificationTypes]) }, + }); + + for (const notificationTypeObject of notificationTypeObjects) { + notificationTypesMap.set( + notificationTypeObject.name, + notificationTypeObject, + ); + } + + const subscriptionNotificationTypes = []; + for (const safe of arg.upsertSubscriptionsDto.safes) { + const safeSubscription = arg.subscriptions.find( + (subscriptions) => + subscriptions.chain_id === safe.chainId && + subscriptions.safe_address === safe.address, + ); + + for (const notificationType of safe.notificationTypes) { + subscriptionNotificationTypes.push({ + notification_subscription: safeSubscription, + notification_type: notificationTypesMap.get(notificationType), + }); + } + } + + await entityManager.upsert( + NotificationSubscriptionNotificationType, + subscriptionNotificationTypes, + ['notification_subscription', 'notification_type'], + ); + } + + public async getSafeSubscription(args: { authPayload: AuthPayload; deviceUuid: UUID; chainId: string; @@ -110,15 +286,29 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { throw new UnauthorizedException(); } - return this.notificationsDatasource.getSafeSubscription({ - signerAddress: args.authPayload.signer_address, - deviceUuid: args.deviceUuid, - chainId: args.chainId, - safeAddress: args.safeAddress, + const notificationTypeRepository = + await this.postgresDatabaseService.getRepository(NotificationType); + + return await notificationTypeRepository.find({ + select: { + name: true, + }, + where: { + notification_subscription_notification_type: { + notification_subscription: { + push_notification_device: { + device_uuid: args.deviceUuid, + }, + chain_id: args.chainId, + safe_address: args.safeAddress, + signer_address: args.authPayload.signer_address, + }, + }, + }, }); } - getSubscribersBySafe(args: { + public async getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; }): Promise< @@ -128,21 +318,69 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { cloudMessagingToken: string; }> > { - return this.notificationsDatasource.getSubscribersBySafe({ - chainId: args.chainId, - safeAddress: args.safeAddress, + const notificationSubscriptionRepository = + await this.postgresDatabaseService.getRepository( + NotificationSubscription, + ); + + const subscriptions = await notificationSubscriptionRepository.find({ + where: { + chain_id: args.chainId, + safe_address: args.safeAddress, + }, + relations: ['push_notification_device'], }); + + const output: Array<{ + subscriber: `0x${string}` | null; + deviceUuid: UUID; + cloudMessagingToken: string; + }> = []; + + for (const subscription of subscriptions) { + output.push({ + subscriber: subscription.signer_address, + deviceUuid: subscription.push_notification_device.device_uuid, + cloudMessagingToken: + subscription.push_notification_device.cloud_messaging_token, + }); + } + + return output; } - deleteSubscription(args: { + public async deleteSubscription(args: { deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise { - return this.notificationsDatasource.deleteSubscription(args); + const notificationsSubscriptionsRepository = + await this.postgresDatabaseService.getRepository( + NotificationSubscription, + ); + const subscription = await notificationsSubscriptionsRepository.findOne({ + where: { + chain_id: args.chainId, + safe_address: args.safeAddress, + push_notification_device: { + device_uuid: args.deviceUuid, + }, + }, + }); + + if (subscription) { + await notificationsSubscriptionsRepository.remove(subscription); + } } - deleteDevice(deviceUuid: UUID): Promise { - return this.notificationsDatasource.deleteDevice(deviceUuid); + public async deleteDevice(deviceUuid: UUID): Promise { + const notificationsDeviceRepository = + await this.postgresDatabaseService.getRepository( + NotificationDevice, + ); + + await notificationsDeviceRepository.delete({ + device_uuid: deviceUuid, + }); } } diff --git a/src/domain/notifications/v2/test.notification.repository.module.ts b/src/domain/notifications/v2/test.notification.repository.module.ts new file mode 100644 index 0000000000..3003eb73e2 --- /dev/null +++ b/src/domain/notifications/v2/test.notification.repository.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; +import { MockNotificationRepositoryV2 } from '@/domain/notifications/v2/entities/__tests__/notification.repository.mock'; + +@Module({ + providers: [ + { + provide: INotificationsRepositoryV2, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(MockNotificationRepositoryV2); + }, + }, + ], + exports: [INotificationsRepositoryV2], +}) +export class TestNotificationsRepositoryV2Module {} diff --git a/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts index 640bf9668d..27c9baf862 100644 --- a/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -21,6 +21,8 @@ import { TestPostgresDatabaseModule } from '@/datasources/db/__tests__/test.post import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; import { TestTargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/__tests__/test.targeted-messaging.datasource.module'; import { TargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/targeted-messaging.datasource.module'; +import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; +import { TestPushNotificationsApiModule } from '@/datasources/push-notifications-api/__tests__/test.push-notifications-api.module'; describe('Events queue processing e2e tests', () => { let app: INestApplication; @@ -43,6 +45,8 @@ describe('Events queue processing e2e tests', () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) + .overrideModule(PushNotificationsApiModule) + .useModule(TestPushNotificationsApiModule) .overrideProvider(CacheKeyPrefix) .useValue(cacheKeyPrefix) .overrideModule(PostgresDatabaseModule) diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index dde001e079..a4eb63877b 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -28,12 +28,9 @@ import request from 'supertest'; import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; import { TestPushNotificationsApiModule } from '@/datasources/push-notifications-api/__tests__/test.push-notifications-api.module'; -import { NotificationsDatasourceModule } from '@/datasources/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/notifications/__tests__/test.notifications.datasource.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import type { INetworkService } from '@/datasources/network/network.service.interface'; import { NetworkService } from '@/datasources/network/network.service.interface'; -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; @@ -56,12 +53,15 @@ import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.mo import { TestPostgresDatabaseModule } from '@/datasources/db/__tests__/test.postgres-database.module'; import { TestTargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/__tests__/test.targeted-messaging.datasource.module'; import { TargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/targeted-messaging.datasource.module'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; +import { TestNotificationsRepositoryV2Module } from '@/domain/notifications/v2/test.notification.repository.module'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; // TODO: Migrate to E2E tests as TransactionEventType events are already being received via queue. describe.skip('Post Hook Events for Notifications (Unit)', () => { let app: INestApplication; let pushNotificationsApi: jest.MockedObjectDeep; - let notificationsDatasource: jest.MockedObjectDeep; + let notificationsRepository: jest.MockedObjectDeep; let networkService: jest.MockedObjectDeep; let configurationService: IConfigurationService; let authToken: string; @@ -98,18 +98,19 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .useModule(TestPostgresDatabaseModuleV2) .overrideModule(PushNotificationsApiModule) .useModule(TestPushNotificationsApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) + .overrideModule(NotificationsRepositoryV2Module) + .useModule(TestNotificationsRepositoryV2Module) .compile(); app = moduleFixture.createNestApplication(); networkService = moduleFixture.get(NetworkService); pushNotificationsApi = moduleFixture.get(IPushNotificationsApi); - notificationsDatasource = moduleFixture.get(INotificationsDatasource); configurationService = moduleFixture.get(IConfigurationService); authToken = configurationService.getOrThrow('auth.token'); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + notificationsRepository = moduleFixture.get(INotificationsRepositoryV2); + await app.init(); } @@ -160,7 +161,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { }, ); const chain = chainBuilder().build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + notificationsRepository.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { return Promise.resolve({ @@ -211,7 +212,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -291,7 +292,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -368,7 +369,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { ) .with('threshold', faker.number.int({ min: 2 })) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -440,7 +441,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { subscribers.map((subscriber) => subscriber.subscriber), ) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -492,7 +493,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { subscribers.map((subscriber) => subscriber.subscriber), ) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const multisigTransaction = multisigTransactionBuilder() @@ -561,7 +562,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const confirmations = faker.helpers @@ -654,7 +655,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { subscribers.map((subscriber) => subscriber.subscriber), ) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -726,7 +727,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { subscribers.map((subscriber) => subscriber.subscriber), ) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -778,7 +779,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { subscribers.map((subscriber) => subscriber.subscriber), ) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const message = messageBuilder() @@ -848,7 +849,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const confirmations = faker.helpers @@ -947,7 +948,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .with('safe', event.address) .build(); }); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -1026,7 +1027,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .with('safe', event.address) .build(); }); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -1079,7 +1080,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const delegates = subscribers.map((subscriber) => { @@ -1159,7 +1160,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const delegates = subscribers.map((subscriber) => { @@ -1265,7 +1266,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .with('safe', event.address) .build(); }); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -1338,7 +1339,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const delegates = subscribers.map((subscriber) => { @@ -1397,7 +1398,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const delegates = subscribers.map((subscriber) => { @@ -1478,7 +1479,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); const delegates = subscribers.map((subscriber) => { @@ -1583,7 +1584,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -1650,7 +1651,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: { min: 1, max: 5 }, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + notificationsRepository.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -1750,7 +1751,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .build(), ]) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue([ + notificationsRepository.getSubscribersBySafe.mockResolvedValue([ ...ownerSubscriptions, ...delegateSubscriptions, ...nonOwnerDelegateSubscriptions, @@ -1891,7 +1892,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .build(), ]) .build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue([ + notificationsRepository.getSubscribersBySafe.mockResolvedValue([ ...ownerSubscriptions, ...delegateSubscriptions, ...nonOwnerDelegateSubscriptions, @@ -1995,7 +1996,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { }, ); const chain = chainBuilder().build(); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + notificationsRepository.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { return Promise.resolve({ @@ -2024,8 +2025,8 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { .send(event) .expect(202); - expect(notificationsDatasource.deleteDevice).toHaveBeenCalledTimes(1); - expect(notificationsDatasource.deleteDevice).toHaveBeenNthCalledWith( + expect(notificationsRepository.deleteDevice).toHaveBeenCalledTimes(1); + expect(notificationsRepository.deleteDevice).toHaveBeenNthCalledWith( 1, subscribers[0].deviceUuid, ); @@ -2087,7 +2088,7 @@ describe.skip('Post Hook Events for Notifications (Unit)', () => { count: safe.owners.length, }, ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + notificationsRepository.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ diff --git a/src/routes/hooks/hooks.controller.spec.ts b/src/routes/hooks/hooks.controller.spec.ts index 74a734687f..e69d552d97 100644 --- a/src/routes/hooks/hooks.controller.spec.ts +++ b/src/routes/hooks/hooks.controller.spec.ts @@ -15,8 +15,6 @@ import { NetworkModule } from '@/datasources/network/network.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import type { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/notifications/__tests__/test.notifications.datasource.module'; import { TestPostgresDatabaseModule } from '@/datasources/db/__tests__/test.postgres-database.module'; import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; import { PostgresDatabaseModuleV2 } from '@/datasources/db/v2/postgres-database.module'; @@ -45,8 +43,6 @@ describe('Post Hook Events (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .overrideModule(PostgresDatabaseModuleV2) .useModule(TestPostgresDatabaseModuleV2) .compile(); diff --git a/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts b/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts new file mode 100644 index 0000000000..7386d4b88f --- /dev/null +++ b/src/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder.ts @@ -0,0 +1,66 @@ +import type { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; +import type { RegisterDeviceDto } from '@/routes/notifications/v1/entities/register-device.dto.entity'; +import type { UUID } from 'crypto'; +import { getAddress, recoverMessageAddress } from 'viem'; + +export const createV2RegisterDtoBuilder = async ( + args: RegisterDeviceDto, +): Promise< + Array<{ + upsertSubscriptionsDto: UpsertSubscriptionsDto; + authPayload: AuthPayload; + }> +> => { + const safeV2Array: Array<{ + authPayload: AuthPayload; + upsertSubscriptionsDto: UpsertSubscriptionsDto & { + signature: `0x${string}`; + }; + }> = []; + + const safesV1Registrations = args.safeRegistrations; + + for (const safeV1Registration of safesV1Registrations) { + if (safeV1Registration.safes.length) { + const safeV2: (typeof safeV2Array)[number] = { + upsertSubscriptionsDto: { + cloudMessagingToken: args.cloudMessagingToken, + deviceType: args.deviceType, + deviceUuid: (args.uuid as UUID | undefined) || undefined, + safes: [], + signature: safeV1Registration.signatures[0] as `0x${string}`, + }, + authPayload: new AuthPayload(), + }; + for (const safeAddresses of safeV1Registration.safes) { + safeV2.upsertSubscriptionsDto.safes.push({ + address: getAddress(safeAddresses), + chainId: safeV1Registration.chainId, + notificationTypes: Object.values(NotificationType), + }); + } + safeV2Array.push(safeV2); + } + } + + for (const [index, safeV2] of safeV2Array.entries()) { + const safeAddresses = safeV2.upsertSubscriptionsDto.safes.map( + (safeV2Safes) => safeV2Safes.address, + ); + + const recoveredAddress = await recoverMessageAddress({ + message: `gnosis-safe${args.timestamp}${args.uuid}${args.cloudMessagingToken}${safeAddresses.sort().join('')}`, + signature: safeV2.upsertSubscriptionsDto.signature, + }); + + safeV2.authPayload.chain_id = + safeV2.upsertSubscriptionsDto.safes[0].chainId; + safeV2.authPayload.signer_address = recoveredAddress; + + safeV2Array[index].authPayload = safeV2.authPayload; + } + + return safeV2Array; +}; diff --git a/src/routes/notifications/v1/entities/__tests__/create-signature.builder.ts b/src/routes/notifications/v1/entities/__tests__/create-signature.builder.ts new file mode 100644 index 0000000000..c010d58fe3 --- /dev/null +++ b/src/routes/notifications/v1/entities/__tests__/create-signature.builder.ts @@ -0,0 +1,16 @@ +import type { UUID } from 'crypto'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +export async function safeRegistrationSignatureBuilder(args: { + signaturePrefix: string; + uuid: UUID; + cloudMessagingToken: UUID; + timestamp: number; + safeAddresses: Array<`0x${string}`>; +}): Promise { + const privateKey = generatePrivateKey(); + const signer = privateKeyToAccount(privateKey); + return await signer.signMessage({ + message: `${args.signaturePrefix}-${args.timestamp}${args.uuid}${args.cloudMessagingToken}${args.safeAddresses.sort().join('')}`, + }); +} diff --git a/src/routes/notifications/v1/entities/__tests__/register-device.dto.builder.ts b/src/routes/notifications/v1/entities/__tests__/register-device.dto.builder.ts index fab2d24502..c5f2304d49 100644 --- a/src/routes/notifications/v1/entities/__tests__/register-device.dto.builder.ts +++ b/src/routes/notifications/v1/entities/__tests__/register-device.dto.builder.ts @@ -4,19 +4,38 @@ import type { IBuilder } from '@/__tests__/builder'; import { Builder } from '@/__tests__/builder'; import type { RegisterDeviceDto } from '@/routes/notifications/v1/entities/register-device.dto.entity'; import { safeRegistrationBuilder } from '@/routes/notifications/v1/entities/__tests__/safe-registration.builder'; +import type { UUID } from 'crypto'; + +export async function registerDeviceDtoBuilder(args?: { + uuid?: UUID; + cloudMessagingToken?: UUID; + timestamp?: number; +}): Promise> { + const uuid = args?.uuid ?? (faker.string.uuid() as UUID); + const cloudMessagingToken = + args?.cloudMessagingToken ?? (faker.string.uuid() as UUID); + const timestamp = new Date(args?.timestamp ?? faker.date.recent()); + timestamp.setMilliseconds(0); + const timestampWithoutMilliseconds = timestamp.getTime(); + const signaturePrefix = 'gnosis-safe'; + const safeRegistrations = await safeRegistrationBuilder({ + signaturePrefix, + uuid, + cloudMessagingToken, + timestamp: timestampWithoutMilliseconds, + }); -export function registerDeviceDtoBuilder(): IBuilder { return new Builder() - .with('uuid', faker.string.uuid()) - .with('cloudMessagingToken', faker.string.uuid()) + .with('uuid', uuid) + .with('cloudMessagingToken', cloudMessagingToken) .with('buildNumber', faker.string.numeric()) .with('bundle', faker.internet.domainName()) .with('deviceType', faker.helpers.objectValue(DeviceType)) .with('version', faker.system.semver()) - .with('timestamp', faker.date.recent().getTime().toString()) + .with('timestamp', timestamp.toString()) .with( 'safeRegistrations', - faker.helpers.multiple(() => safeRegistrationBuilder().build(), { + faker.helpers.multiple(() => safeRegistrations.build(), { count: { min: 0, max: 10 }, }), ); diff --git a/src/routes/notifications/v1/entities/__tests__/safe-registration.builder.ts b/src/routes/notifications/v1/entities/__tests__/safe-registration.builder.ts index 94b76556b6..13af587e48 100644 --- a/src/routes/notifications/v1/entities/__tests__/safe-registration.builder.ts +++ b/src/routes/notifications/v1/entities/__tests__/safe-registration.builder.ts @@ -2,20 +2,29 @@ import { faker } from '@faker-js/faker'; import type { IBuilder } from '@/__tests__/builder'; import { Builder } from '@/__tests__/builder'; import type { SafeRegistration } from '@/routes/notifications/v1/entities/safe-registration.entity'; +import type { UUID } from 'crypto'; +import { safeRegistrationSignatureBuilder } from '@/routes/notifications/v1/entities/__tests__/create-signature.builder'; +import { getAddress } from 'viem'; -export function safeRegistrationBuilder(): IBuilder { +export async function safeRegistrationBuilder(args: { + signaturePrefix: string; + uuid: UUID; + cloudMessagingToken: UUID; + timestamp: number; +}): Promise> { + const safeAddresses = faker.helpers.multiple( + () => getAddress(faker.finance.ethereumAddress()), + { + count: { min: 0, max: 5 }, + }, + ); return new Builder() .with('chainId', faker.string.numeric()) - .with( - 'safes', - faker.helpers.multiple(() => faker.finance.ethereumAddress(), { - count: { min: 0, max: 5 }, + .with('safes', safeAddresses) + .with('signatures', [ + await safeRegistrationSignatureBuilder({ + ...args, + safeAddresses, }), - ) - .with( - 'signatures', - faker.helpers.multiple(() => faker.string.hexadecimal({ length: 32 }), { - count: { min: 0, max: 5 }, - }), - ); + ]); } diff --git a/src/routes/notifications/v1/notifications.controller.spec.ts b/src/routes/notifications/v1/notifications.controller.spec.ts index bf623f3d13..05a0e45e28 100644 --- a/src/routes/notifications/v1/notifications.controller.spec.ts +++ b/src/routes/notifications/v1/notifications.controller.spec.ts @@ -30,11 +30,20 @@ import { PostgresDatabaseModuleV2 } from '@/datasources/db/v2/postgres-database. import { TestPostgresDatabaseModuleV2 } from '@/datasources/db/v2/test.postgres-database.module'; import { TestTargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/__tests__/test.targeted-messaging.datasource.module'; import { TargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/targeted-messaging.datasource.module'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; +import { TestNotificationsRepositoryV2Module } from '@/domain/notifications/v2/test.notification.repository.module'; +import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; +import { NotificationsModuleV2 } from '@/routes/notifications/v2/notifications.module'; +import { TestNotificationsModuleV2 } from '@/routes/notifications/v2/test.notifications.module'; +import type { UUID } from 'crypto'; +import { createV2RegisterDtoBuilder } from '@/routes/notifications/v1/entities/__tests__/create-registration-v2.dto.builder'; describe('Notifications Controller (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; + let notificationServiceV2: jest.MockedObjectDeep; + let isNotificationsV2Enabled: boolean; beforeEach(async () => { jest.resetAllMocks(); @@ -56,59 +65,118 @@ describe('Notifications Controller (Unit)', () => { .useModule(TestQueuesApiModule) .overrideModule(PostgresDatabaseModuleV2) .useModule(TestPostgresDatabaseModuleV2) + .overrideModule(NotificationsRepositoryV2Module) + .useModule(TestNotificationsRepositoryV2Module) + .overrideModule(NotificationsModuleV2) + .useModule(TestNotificationsModuleV2) .compile(); const configurationService = moduleFixture.get( IConfigurationService, ); + notificationServiceV2 = moduleFixture.get(NotificationsServiceV2); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + isNotificationsV2Enabled = configurationService.getOrThrow( + 'features.pushNotifications', + ); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); await app.init(); }); - const buildInputDto = (): RegisterDeviceDto => - registerDeviceDtoBuilder() - .with( - 'safeRegistrations', - faker.helpers.multiple( - (_, i) => { - return safeRegistrationBuilder() - .with('chainId', i.toString()) - .build(); - }, - { count: 4 }, - ), - ) + const buildInputDto = async ( + safeRegistrationsLength: number = 4, + ): Promise => { + const uuid = faker.string.uuid() as UUID; + const cloudMessagingToken = faker.string.uuid() as UUID; + const timestamp = faker.date.recent(); + timestamp.setMilliseconds(0); + const timestampWithoutMilliseconds = timestamp.getTime(); + + const safeRegistrations = await Promise.all( + faker.helpers.multiple( + async () => { + const safeRegistration = await safeRegistrationBuilder({ + signaturePrefix: 'gnosis-safe', + uuid, + cloudMessagingToken, + timestamp: timestampWithoutMilliseconds, + }); + return safeRegistration + .with('chainId', faker.number.int({ min: 1, max: 100 }).toString()) + .build(); + }, + { count: safeRegistrationsLength }, + ), + ); + + return ( + await registerDeviceDtoBuilder({ + uuid, + cloudMessagingToken, + timestamp: timestampWithoutMilliseconds, + }) + ) + .with('safeRegistrations', safeRegistrations) .build(); + }; const rejectForUrl = (url: string): Promise => Promise.reject(`No matching rule for url: ${url}`); describe('POST /register/notifications', () => { - it('Success', async () => { - const registerDeviceDto = buildInputDto(); - networkService.get.mockImplementation(({ url }) => - url.includes(`${safeConfigUrl}/api/v1/chains/`) - ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) - : rejectForUrl(url), - ); - networkService.post.mockImplementation(({ url }) => - url.includes('/api/v1/notifications/devices/') - ? Promise.resolve({ data: {}, status: 200 }) - : rejectForUrl(url), - ); + it.each([5, 20])( + 'Success for a subscription with %i safe registrations', + async (safeRegistrationLength: number) => { + const registerDeviceDto = await buildInputDto(safeRegistrationLength); + const upsertSubscriptionsV2Dto = + await createV2RegisterDtoBuilder(registerDeviceDto); - await request(app.getHttpServer()) - .post('/v1/register/notifications') - .send(registerDeviceDto) - .expect(200) - .expect({}); - }); + networkService.get.mockImplementation(({ url }) => + url.includes(`${safeConfigUrl}/api/v1/chains/`) + ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) + : rejectForUrl(url), + ); + networkService.post.mockImplementation(({ url }) => + url.includes('/api/v1/notifications/devices/') + ? Promise.resolve({ data: {}, status: 200 }) + : rejectForUrl(url), + ); + + await request(app.getHttpServer()) + .post('/v1/register/notifications') + .send(registerDeviceDto) + .expect(200) + .expect({}); + + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + // We call V2 as many times as we have a registration with at least one safe + const safeRegistrationsWithSafe = + registerDeviceDto.safeRegistrations.filter( + (safeRegistration) => safeRegistration.safes.length > 0, + ); + + if (isNotificationsV2Enabled) { + expect( + notificationServiceV2.upsertSubscriptions, + ).toHaveBeenCalledTimes(safeRegistrationsWithSafe.length); + + for (const [ + index, + upsertSubscriptionsV2, + ] of upsertSubscriptionsV2Dto.entries()) { + const nthCall = index + 1; // Convert zero-based index to a one-based call number + expect( + notificationServiceV2.upsertSubscriptions, + ).toHaveBeenNthCalledWith(nthCall, upsertSubscriptionsV2); + } + } + }, + ); it('Client errors returned from provider', async () => { - const registerDeviceDto = buildInputDto(); + const registerDeviceDto = await buildInputDto(); networkService.get.mockImplementation(({ url }) => { return url.includes(`${safeConfigUrl}/api/v1/chains/`) ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) @@ -143,10 +211,16 @@ describe('Notifications Controller (Unit)', () => { error: 'Bad Request', }), ); + + if (isNotificationsV2Enabled) { + expect( + notificationServiceV2.upsertSubscriptions, + ).not.toHaveBeenCalled(); + } }); it('Server errors returned from provider', async () => { - const registerDeviceDto = buildInputDto(); + const registerDeviceDto = await buildInputDto(); networkService.get.mockImplementation(({ url }) => url.includes(`${safeConfigUrl}/api/v1/chains/`) ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) @@ -179,10 +253,16 @@ describe('Notifications Controller (Unit)', () => { message: `Push notification registration failed for chain IDs: ${registerDeviceDto.safeRegistrations[0].chainId}`, error: 'Internal Server Error', }); + + if (isNotificationsV2Enabled) { + expect( + notificationServiceV2.upsertSubscriptions, + ).not.toHaveBeenCalled(); + } }); it('Both client and server errors returned from provider', async () => { - const registerDeviceDto = buildInputDto(); + const registerDeviceDto = await buildInputDto(); networkService.get.mockImplementation(({ url }) => { return url.includes(`${safeConfigUrl}/api/v1/chains/`) ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) @@ -230,10 +310,16 @@ describe('Notifications Controller (Unit)', () => { ]}`, error: 'Internal Server Error', }); + + if (isNotificationsV2Enabled) { + expect( + notificationServiceV2.upsertSubscriptions, + ).not.toHaveBeenCalled(); + } }); it('No status code errors returned from provider', async () => { - const registerDeviceDto = buildInputDto(); + const registerDeviceDto = await buildInputDto(); networkService.get.mockImplementation(({ url }) => url.includes(`${safeConfigUrl}/api/v1/chains/`) ? Promise.resolve({ data: chainBuilder().build(), status: 200 }) @@ -264,6 +350,12 @@ describe('Notifications Controller (Unit)', () => { message: `Push notification registration failed for chain IDs: ${registerDeviceDto.safeRegistrations[1].chainId}`, error: 'Internal Server Error', }); + + if (isNotificationsV2Enabled) { + expect( + notificationServiceV2.upsertSubscriptions, + ).not.toHaveBeenCalled(); + } }); }); @@ -291,6 +383,11 @@ describe('Notifications Controller (Unit)', () => { expect(networkService.delete).toHaveBeenCalledWith({ url: expectedProviderURL, }); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteDevice).toHaveBeenCalledTimes(1); + expect(notificationServiceV2.deleteDevice).toHaveBeenCalledWith(uuid); + } }); it('Failure: Config API fails', async () => { @@ -306,6 +403,10 @@ describe('Notifications Controller (Unit)', () => { .delete(`/v1/chains/${chainId}/notifications/devices/${uuid}`) .expect(503); expect(networkService.delete).toHaveBeenCalledTimes(0); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteDevice).not.toHaveBeenCalled(); + } }); it('Failure: Transaction API fails', async () => { @@ -327,6 +428,10 @@ describe('Notifications Controller (Unit)', () => { .delete(`/v1/chains/${chain.chainId}/notifications/devices/${uuid}`) .expect(503); expect(networkService.delete).toHaveBeenCalledTimes(1); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteDevice).not.toHaveBeenCalled(); + } }); }); @@ -358,6 +463,17 @@ describe('Notifications Controller (Unit)', () => { expect(networkService.delete).toHaveBeenCalledWith({ url: expectedProviderURL, }); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteSubscription).toHaveBeenCalledTimes( + 1, + ); + expect(notificationServiceV2.deleteSubscription).toHaveBeenCalledWith({ + deviceUuid: uuid, + chainId: chain.chainId, + safeAddress: getAddress(safeAddress), + }); + } }); it('Failure: Config API fails', async () => { @@ -376,6 +492,10 @@ describe('Notifications Controller (Unit)', () => { ) .expect(503); expect(networkService.delete).toHaveBeenCalledTimes(0); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteSubscription).not.toHaveBeenCalled(); + } }); it('Failure: Transaction API fails', async () => { @@ -400,6 +520,10 @@ describe('Notifications Controller (Unit)', () => { ) .expect(503); expect(networkService.delete).toHaveBeenCalledTimes(1); + + if (isNotificationsV2Enabled) { + expect(notificationServiceV2.deleteSubscription).not.toHaveBeenCalled(); + } }); }); }); diff --git a/src/routes/notifications/v1/notifications.controller.ts b/src/routes/notifications/v1/notifications.controller.ts index 3381924a72..55be550c32 100644 --- a/src/routes/notifications/v1/notifications.controller.ts +++ b/src/routes/notifications/v1/notifications.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, HttpCode, + Inject, Param, Post, } from '@nestjs/common'; @@ -11,11 +12,34 @@ import { RegisterDeviceDto } from '@/routes/notifications/v1/entities/register-d import { NotificationsService } from '@/routes/notifications/v1/notifications.service'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import type { UpsertSubscriptionsSafesDto } from '@/routes/notifications/v2/entities/upsert-subscriptions.dto.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; +import type { UUID } from 'crypto'; +import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; +import { recoverMessageAddress } from 'viem'; +import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; +import { IConfigurationService } from '@/config/configuration.service.interface'; @ApiTags('notifications') @Controller({ path: '', version: '1' }) export class NotificationsController { - constructor(private readonly notificationsService: NotificationsService) {} + private isPushNotificationV2Enabled = false; + constructor( + // Adding NotificationServiceV2 to ensure compatibility with V1. + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + @Inject(NotificationsServiceV2) + private readonly notificationServiceV2: NotificationsServiceV2, + private readonly notificationsService: NotificationsService, + + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.isPushNotificationV2Enabled = + this.configurationService.getOrThrow( + 'features.pushNotifications', + ); + } @ApiOkResponse() @Post('register/notifications') @@ -23,28 +47,129 @@ export class NotificationsController { async registerDevice( @Body() registerDeviceDto: RegisterDeviceDto, ): Promise { - return this.notificationsService.registerDevice(registerDeviceDto); + await this.notificationsService.registerDevice(registerDeviceDto); + + if (this.isPushNotificationV2Enabled) { + // Compatibility with V2 + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + const compatibleV2Requests = + await this.createV2RegisterDto(registerDeviceDto); + + const v2Requests = []; + for (const compatibleV2Request of compatibleV2Requests) { + v2Requests.push( + await this.notificationServiceV2.upsertSubscriptions( + compatibleV2Request, + ), + ); + } + + await Promise.all(v2Requests); + } + } + + private async createV2RegisterDto( + args: RegisterDeviceDto, + ): Promise< + Array[0]> + > { + const safeV2Array: Array< + Parameters[0] & { + upsertSubscriptionsDto: { + safes: Array; + signature: `0x${string}`; + }; + } + > = []; + + const safesV1Registrations = args.safeRegistrations; + + for (const safeV1Registration of safesV1Registrations) { + if (safeV1Registration.safes.length) { + const safeV2: Parameters< + NotificationsServiceV2['upsertSubscriptions'] + >[0] & { + upsertSubscriptionsDto: { + safes: Array; + signature: `0x${string}`; + }; + } = { + upsertSubscriptionsDto: { + cloudMessagingToken: args.cloudMessagingToken, + deviceType: args.deviceType, + deviceUuid: (args.uuid as UUID) || undefined, + safes: [], + signature: safeV1Registration.signatures[0] as `0x${string}`, + }, + authPayload: new AuthPayload(), + }; + const uniqueSafeAddresses = new Set(safeV1Registration.safes); + for (const safeAddresses of uniqueSafeAddresses) { + safeV2.upsertSubscriptionsDto.safes.push({ + address: safeAddresses as `0x${string}`, + chainId: safeV1Registration.chainId, + notificationTypes: Object.values(NotificationType), + }); + } + safeV2Array.push(safeV2); + } + } + + for (const [index, safeV2] of safeV2Array.entries()) { + const safeAddresses = safeV2.upsertSubscriptionsDto.safes.map( + (safeV2Safes) => safeV2Safes.address, + ); + + const recoveredAddress = await recoverMessageAddress({ + message: `gnosis-safe${args.timestamp}${args.uuid}${args.cloudMessagingToken}${safeAddresses.sort().join('')}`, + signature: safeV2.upsertSubscriptionsDto.signature, + }); + + safeV2.authPayload.chain_id = + safeV2.upsertSubscriptionsDto.safes[0].chainId; + safeV2.authPayload.signer_address = recoveredAddress; + + safeV2Array[index].authPayload = safeV2.authPayload; + } + + return safeV2Array; } @Delete('chains/:chainId/notifications/devices/:uuid') async unregisterDevice( @Param('chainId') chainId: string, - @Param('uuid') uuid: string, + @Param('uuid', new ValidationPipe(UuidSchema)) uuid: UUID, ): Promise { - return this.notificationsService.unregisterDevice({ chainId, uuid }); + await this.notificationsService.unregisterDevice({ chainId, uuid }); + + if (this.isPushNotificationV2Enabled) { + // Compatibility with V2 + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + await this.notificationServiceV2.deleteDevice(uuid); + } } @Delete('chains/:chainId/notifications/devices/:uuid/safes/:safeAddress') async unregisterSafe( @Param('chainId') chainId: string, - @Param('uuid') uuid: string, + @Param('uuid', new ValidationPipe(UuidSchema)) uuid: UUID, @Param('safeAddress', new ValidationPipe(AddressSchema)) safeAddress: `0x${string}`, ): Promise { - return this.notificationsService.unregisterSafe({ + await this.notificationsService.unregisterSafe({ chainId, uuid, safeAddress, }); + + if (this.isPushNotificationV2Enabled) { + // Compatibility with V2 + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + await this.notificationServiceV2.deleteSubscription({ + deviceUuid: uuid, + chainId: chainId, + safeAddress: safeAddress, + }); + } } } diff --git a/src/routes/notifications/v1/notifications.module.ts b/src/routes/notifications/v1/notifications.module.ts index 3ab8304018..66886da27c 100644 --- a/src/routes/notifications/v1/notifications.module.ts +++ b/src/routes/notifications/v1/notifications.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { NotificationsController } from '@/routes/notifications/v1/notifications.controller'; import { NotificationsService } from '@/routes/notifications/v1/notifications.service'; import { NotificationsRepositoryModule } from '@/domain/notifications/v1/notifications.repository.interface'; +import { NotificationsModuleV2 } from '@/routes/notifications/v2/notifications.module'; @Module({ - imports: [NotificationsRepositoryModule], + // Adding NotificationModuleV2 to ensure compatibility with V1. + // @TODO Remove NotificationModuleV2 after all clients have migrated and compatibility is no longer needed. + imports: [NotificationsRepositoryModule, NotificationsModuleV2], controllers: [NotificationsController], providers: [NotificationsService], }) diff --git a/src/routes/notifications/v2/entities/upsert-subscriptions.dto.entity.ts b/src/routes/notifications/v2/entities/upsert-subscriptions.dto.entity.ts new file mode 100644 index 0000000000..04971847d8 --- /dev/null +++ b/src/routes/notifications/v2/entities/upsert-subscriptions.dto.entity.ts @@ -0,0 +1,54 @@ +import { DeviceType } from '@/domain/notifications/v2/entities/device-type.entity'; +import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { UUID } from 'crypto'; +import { z } from 'zod'; + +export const UpsertSubscriptionsDtoSafeSchema = z.object({ + chainId: NumericStringSchema, + address: AddressSchema, + notificationTypes: z.array(z.nativeEnum(NotificationType)), +}); + +export const UpsertSubscriptionsDtoSchema = z.object({ + cloudMessagingToken: z.string(), + safes: z.array(UpsertSubscriptionsDtoSafeSchema), + deviceType: z.nativeEnum(DeviceType), + deviceUuid: UuidSchema.nullish().default(null), +}); + +export class UpsertSubscriptionsSafesDto + implements z.infer +{ + @ApiProperty() + chainId!: string; + + @ApiProperty() + address!: `0x${string}`; + + @ApiProperty({ + isArray: true, + enum: NotificationType, + enumName: 'NotificationType', + }) + notificationTypes!: Array; +} + +export class UpsertSubscriptionsDto + implements z.infer +{ + @ApiProperty() + cloudMessagingToken!: string; + + @ApiProperty({ isArray: true, type: UpsertSubscriptionsSafesDto }) + safes!: Array; + + @ApiProperty({ enum: DeviceType, enumName: 'DeviceType' }) + deviceType!: DeviceType; + + @ApiPropertyOptional({ nullable: true, type: String }) + deviceUuid!: UUID; +} diff --git a/src/routes/notifications/v2/notifications.controller.spec.ts b/src/routes/notifications/v2/notifications.controller.spec.ts index 74743bc831..a8d095a0e4 100644 --- a/src/routes/notifications/v2/notifications.controller.spec.ts +++ b/src/routes/notifications/v2/notifications.controller.spec.ts @@ -4,8 +4,6 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import configuration from '@/config/entities/__tests__/configuration'; import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; @@ -19,8 +17,6 @@ import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-pay import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { delegateBuilder } from '@/domain/delegate/entities/__tests__/delegate.builder'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; -import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; -import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; @@ -42,18 +38,22 @@ import { TestCounterfactualSafesDataSourceModule } from '@/datasources/accounts/ import { TestPostgresDatabaseModule } from '@/datasources/db/__tests__/test.postgres-database.module'; import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; import { TestPostgresDatabaseModuleV2 } from '@/datasources/db/v2/test.postgres-database.module'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; import { PostgresDatabaseModuleV2 } from '@/datasources/db/v2/postgres-database.module'; import { TestTargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/__tests__/test.targeted-messaging.datasource.module'; import { TargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/targeted-messaging.datasource.module'; import { TestAddressBooksDataSourceModule } from '@/datasources/accounts/address-books/__tests__/test.address-books.datasource.module'; import { AddressBooksDatasourceModule } from '@/datasources/accounts/address-books/address-books.datasource.module'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; +import { TestNotificationsRepositoryV2Module } from '@/domain/notifications/v2/test.notification.repository.module'; describe('Notifications Controller V2 (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let jwtService: IJwtService; let networkService: jest.MockedObjectDeep; - let notificationsDatasource: jest.MockedObjectDeep; + let notificationsRepository: jest.MockedObjectDeep; beforeEach(async () => { jest.resetAllMocks(); @@ -82,8 +82,6 @@ describe('Notifications Controller V2 (Unit)', () => { .useModule(TestCounterfactualSafesDataSourceModule) .overrideModule(TargetedMessagingDatasourceModule) .useModule(TestTargetedMessagingDatasourceModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) @@ -94,6 +92,8 @@ describe('Notifications Controller V2 (Unit)', () => { .useModule(TestQueuesApiModule) .overrideModule(PostgresDatabaseModuleV2) .useModule(TestPostgresDatabaseModuleV2) + .overrideModule(NotificationsRepositoryV2Module) + .useModule(TestNotificationsRepositoryV2Module) .compile(); const configurationService = moduleFixture.get( @@ -102,7 +102,7 @@ describe('Notifications Controller V2 (Unit)', () => { safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); jwtService = moduleFixture.get(IJwtService); networkService = moduleFixture.get(NetworkService); - notificationsDatasource = moduleFixture.get(INotificationsDatasource); + notificationsRepository = moduleFixture.get(INotificationsRepositoryV2); app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -168,13 +168,16 @@ describe('Notifications Controller V2 (Unit)', () => { .send(upsertSubscriptionsDto) .expect(201); - expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + expect(notificationsRepository.upsertSubscriptions).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.upsertSubscriptions, + notificationsRepository.upsertSubscriptions, ).toHaveBeenNthCalledWith(1, { - signerAddress, + authPayload: { + signer_address: signerAddress, + chain_id: authPayloadDto.chain_id, + }, upsertSubscriptionsDto, }); }); @@ -238,13 +241,16 @@ describe('Notifications Controller V2 (Unit)', () => { .send(upsertSubscriptionsDto) .expect(201); - expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + expect(notificationsRepository.upsertSubscriptions).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.upsertSubscriptions, + notificationsRepository.upsertSubscriptions, ).toHaveBeenNthCalledWith(1, { - signerAddress, + authPayload: { + signer_address: signerAddress, + chain_id: authPayloadDto.chain_id, + }, upsertSubscriptionsDto, }); }); @@ -306,13 +312,16 @@ describe('Notifications Controller V2 (Unit)', () => { .send(upsertSubscriptionsDto) .expect(201); - expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + expect(notificationsRepository.upsertSubscriptions).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.upsertSubscriptions, + notificationsRepository.upsertSubscriptions, ).toHaveBeenNthCalledWith(1, { - signerAddress, + authPayload: { + signer_address: signerAddress, + chain_id: authPayloadDto.chain_id, + }, upsertSubscriptionsDto, }); }); @@ -458,7 +467,7 @@ describe('Notifications Controller V2 (Unit)', () => { new UnprocessableEntityException(), new NotFoundException(), ]); - notificationsDatasource.upsertSubscriptions.mockRejectedValue(error); + notificationsRepository.upsertSubscriptions.mockRejectedValue(error); await request(app.getHttpServer()) .post(`/v2/register/notifications`) @@ -637,7 +646,7 @@ describe('Notifications Controller V2 (Unit)', () => { .with('chain_id', chainId) .build(); const accessToken = jwtService.sign(authPayloadDto); - notificationsDatasource.getSafeSubscription.mockResolvedValue( + notificationsRepository.getSafeSubscription.mockResolvedValue( notificationTypes, ); @@ -649,13 +658,13 @@ describe('Notifications Controller V2 (Unit)', () => { .expect(200) .expect(notificationTypes); - expect(notificationsDatasource.getSafeSubscription).toHaveBeenCalledTimes( + expect(notificationsRepository.getSafeSubscription).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.getSafeSubscription, + notificationsRepository.getSafeSubscription, ).toHaveBeenNthCalledWith(1, { - signerAddress, + authPayload: authPayloadDto, deviceUuid, chainId, safeAddress, @@ -675,7 +684,7 @@ describe('Notifications Controller V2 (Unit)', () => { .with('chain_id', faker.string.numeric({ exclude: chainId })) .build(); const accessToken = jwtService.sign(authPayloadDto); - notificationsDatasource.getSafeSubscription.mockResolvedValue( + notificationsRepository.getSafeSubscription.mockResolvedValue( notificationTypes, ); @@ -687,13 +696,16 @@ describe('Notifications Controller V2 (Unit)', () => { .expect(200) .expect(notificationTypes); - expect(notificationsDatasource.getSafeSubscription).toHaveBeenCalledTimes( + expect(notificationsRepository.getSafeSubscription).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.getSafeSubscription, + notificationsRepository.getSafeSubscription, ).toHaveBeenNthCalledWith(1, { - signerAddress, + authPayload: { + chain_id: authPayloadDto.chain_id, + signer_address: authPayloadDto.signer_address, + }, deviceUuid, chainId, safeAddress, @@ -761,7 +773,7 @@ describe('Notifications Controller V2 (Unit)', () => { .build(); const accessToken = jwtService.sign(authPayloadDto); const error = new NotFoundException(); - notificationsDatasource.getSafeSubscription.mockRejectedValue(error); + notificationsRepository.getSafeSubscription.mockRejectedValue(error); await request(app.getHttpServer()) .get( @@ -902,11 +914,11 @@ describe('Notifications Controller V2 (Unit)', () => { ) .expect(200); - expect(notificationsDatasource.deleteSubscription).toHaveBeenCalledTimes( + expect(notificationsRepository.deleteSubscription).toHaveBeenCalledTimes( 1, ); expect( - notificationsDatasource.deleteSubscription, + notificationsRepository.deleteSubscription, ).toHaveBeenNthCalledWith(1, { deviceUuid, chainId, @@ -966,7 +978,7 @@ describe('Notifications Controller V2 (Unit)', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const deviceUuid = faker.string.uuid(); const error = new NotFoundException(); - notificationsDatasource.deleteSubscription.mockRejectedValue(error); + notificationsRepository.deleteSubscription.mockRejectedValue(error); await request(app.getHttpServer()) .delete( @@ -988,8 +1000,8 @@ describe('Notifications Controller V2 (Unit)', () => { .delete(`/v2/chains/${chainId}/notifications/devices/${deviceUuid}`) .expect(200); - expect(notificationsDatasource.deleteDevice).toHaveBeenCalledTimes(1); - expect(notificationsDatasource.deleteDevice).toHaveBeenNthCalledWith( + expect(notificationsRepository.deleteDevice).toHaveBeenCalledTimes(1); + expect(notificationsRepository.deleteDevice).toHaveBeenNthCalledWith( 1, deviceUuid, ); @@ -1003,8 +1015,8 @@ describe('Notifications Controller V2 (Unit)', () => { .delete(`/v2/chains/${chainId}/notifications/devices/${deviceUuid}`) .expect(200); - expect(notificationsDatasource.deleteDevice).toHaveBeenCalledTimes(1); - expect(notificationsDatasource.deleteDevice).toHaveBeenNthCalledWith( + expect(notificationsRepository.deleteDevice).toHaveBeenCalledTimes(1); + expect(notificationsRepository.deleteDevice).toHaveBeenNthCalledWith( 1, deviceUuid, ); @@ -1047,7 +1059,7 @@ describe('Notifications Controller V2 (Unit)', () => { const chainId = faker.string.numeric(); const deviceUuid = faker.string.uuid(); const error = new NotFoundException(); - notificationsDatasource.deleteDevice.mockRejectedValue(error); + notificationsRepository.deleteDevice.mockRejectedValue(error); await request(app.getHttpServer()) .delete(`/v2/chains/${chainId}/notifications/devices/${deviceUuid}`) diff --git a/src/routes/notifications/v2/notifications.controller.ts b/src/routes/notifications/v2/notifications.controller.ts index 15f43e7ca0..fd90fa0590 100644 --- a/src/routes/notifications/v2/notifications.controller.ts +++ b/src/routes/notifications/v2/notifications.controller.ts @@ -2,7 +2,7 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { UpsertSubscriptionsDto, UpsertSubscriptionsDtoSchema, -} from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; +} from '@/routes/notifications/v2/entities/upsert-subscriptions.dto.entity'; import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; import { Auth } from '@/routes/auth/decorators/auth.decorator'; import { AuthGuard } from '@/routes/auth/guards/auth.guard'; @@ -22,7 +22,7 @@ import { import { ApiTags } from '@nestjs/swagger'; import { UUID } from 'crypto'; import { OptionalAuthGuard } from '@/routes/auth/guards/optional-auth.guard'; -import type { NotificationType } from '@/domain/notifications/v2/entities/notification.entity'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; @ApiTags('notifications') @Controller({ path: '', version: '2' }) diff --git a/src/routes/notifications/v2/notifications.module.ts b/src/routes/notifications/v2/notifications.module.ts index eafc3f7cfd..37311da200 100644 --- a/src/routes/notifications/v2/notifications.module.ts +++ b/src/routes/notifications/v2/notifications.module.ts @@ -3,11 +3,13 @@ import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; import { NotificationsControllerV2 } from '@/routes/notifications/v2/notifications.controller'; -import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.interface'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/v2/notifications.repository.module'; @Module({ imports: [NotificationsRepositoryV2Module, AuthRepositoryModule], controllers: [NotificationsControllerV2], providers: [NotificationsServiceV2, AuthGuard], + // Export the controller to enable compatibility with V1. + exports: [NotificationsServiceV2], }) export class NotificationsModuleV2 {} diff --git a/src/routes/notifications/v2/notifications.service.ts b/src/routes/notifications/v2/notifications.service.ts index b35359950d..82fe8a77d3 100644 --- a/src/routes/notifications/v2/notifications.service.ts +++ b/src/routes/notifications/v2/notifications.service.ts @@ -1,9 +1,9 @@ import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; -import { NotificationType } from '@/domain/notifications/v2/entities/notification-type.entity'; import { UpsertSubscriptionsDto } from '@/routes/notifications/v1/entities/upsert-subscriptions.dto.entity'; -import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; import { UUID } from 'crypto'; +import { NotificationType } from '@/datasources/notifications/entities/notification-type.entity.db'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/v2/notifications.repository.interface'; @Injectable() export class NotificationsServiceV2 { @@ -12,7 +12,7 @@ export class NotificationsServiceV2 { private readonly notificationsRepository: INotificationsRepositoryV2, ) {} - upsertSubscriptions(args: { + async upsertSubscriptions(args: { authPayload: AuthPayload; upsertSubscriptionsDto: UpsertSubscriptionsDto; }): Promise<{ @@ -20,24 +20,25 @@ export class NotificationsServiceV2 { }> { return this.notificationsRepository.upsertSubscriptions(args); } - getSafeSubscription(args: { + + async getSafeSubscription(args: { authPayload: AuthPayload; deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise> { - return this.notificationsRepository.getSafeSubscription(args); + return await this.notificationsRepository.getSafeSubscription(args); } - deleteSubscription(args: { + async deleteSubscription(args: { deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise { - return this.notificationsRepository.deleteSubscription(args); + await this.notificationsRepository.deleteSubscription(args); } - deleteDevice(deviceUuid: UUID): Promise { - return this.notificationsRepository.deleteDevice(deviceUuid); + async deleteDevice(deviceUuid: UUID): Promise { + await this.notificationsRepository.deleteDevice(deviceUuid); } } diff --git a/src/routes/notifications/v2/test.notifications.module.ts b/src/routes/notifications/v2/test.notifications.module.ts new file mode 100644 index 0000000000..c91592278b --- /dev/null +++ b/src/routes/notifications/v2/test.notifications.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { NotificationsServiceV2 } from '@/routes/notifications/v2/notifications.service'; + +const MockedNotificationsServiceV2 = { + upsertSubscriptions: jest.fn(), + getSafeSubscription: jest.fn(), + deleteSubscription: jest.fn(), + deleteDevice: jest.fn(), +} as jest.MockedObjectDeep; + +@Module({ + providers: [ + { + provide: NotificationsServiceV2, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(MockedNotificationsServiceV2); + }, + }, + ], + exports: [NotificationsServiceV2], +}) +export class TestNotificationsModuleV2 {}