From 549d2bf68af8ab98a4894241d46919f3df55959d Mon Sep 17 00:00:00 2001 From: Lars Waage <46653859+larwaa@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:23:28 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(products):=20add=20order=20rec?= =?UTF-8?q?eipt=20emails=20(#569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add order receipt handler and tests * chore: update tests * chore: fix flaky test * chore: use tz in test to avoid flakiness * chore: fix flaky tests * chore: add more test cases --- src/__tests__/dependencies-factory.ts | 1 + src/lib/bullmq/worker.ts | 2 + src/lib/server.ts | 1 + .../__tests__/integration/merchant.test.ts | 4 +- .../integration/dependencies-factory.ts | 1 + .../sign-off-waitlist-promotion.test.ts | 5 +- .../events/__tests__/unit/events.test.ts | 4 +- .../mail/__tests__/integration/worker.test.ts | 329 ++++++++++++++++++ src/services/mail/index.ts | 16 +- src/services/mail/worker.ts | 109 +++++- .../products/__tests__/integration/deps.ts | 82 ++++- .../initiate-payment-attempt.test.ts | 27 +- .../products/__tests__/unit/capture.test.ts | 73 ++++ .../products/__tests__/unit/dependencies.ts | 9 +- src/services/products/payments.ts | 9 + src/services/products/service.ts | 6 + 16 files changed, 655 insertions(+), 23 deletions(-) diff --git a/src/__tests__/dependencies-factory.ts b/src/__tests__/dependencies-factory.ts index e9edfe55..a743db57 100644 --- a/src/__tests__/dependencies-factory.ts +++ b/src/__tests__/dependencies-factory.ts @@ -80,6 +80,7 @@ export function makeTestServices(overrides?: Partial): Services & { vippsFactory: mockDeep(), paymentProcessingQueue: mockDeep(), productRepository, + mailService, config: { useTestMode: true, returnUrl: env.SERVER_URL, diff --git a/src/lib/bullmq/worker.ts b/src/lib/bullmq/worker.ts index 6c361f53..328c9c51 100644 --- a/src/lib/bullmq/worker.ts +++ b/src/lib/bullmq/worker.ts @@ -261,6 +261,7 @@ export async function initWorkers(): Promise<{ vippsFactory: Client, paymentProcessingQueue: instance.queues?.[PaymentProcessingQueueName], productRepository, + mailService, config: { useTestMode: env.VIPPS_TEST_MODE, returnUrl: env.SERVER_URL }, }); @@ -304,6 +305,7 @@ export async function initWorkers(): Promise<{ eventService, cabinService, fileService, + productService, logger: instance.log, }), ); diff --git a/src/lib/server.ts b/src/lib/server.ts index f17fa22a..3c6edd2e 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -819,6 +819,7 @@ async function registerServices( vippsFactory: Client, paymentProcessingQueue: serverInstance.queues[PaymentProcessingQueueName], productRepository, + mailService, config: { useTestMode: configuration?.VIPPS_TEST_MODE, returnUrl: env.SERVER_URL, diff --git a/src/repositories/products/__tests__/integration/merchant.test.ts b/src/repositories/products/__tests__/integration/merchant.test.ts index 920f1302..e9346c1b 100644 --- a/src/repositories/products/__tests__/integration/merchant.test.ts +++ b/src/repositories/products/__tests__/integration/merchant.test.ts @@ -67,7 +67,7 @@ describe("productRepository", () => { const toUpdate = await productRepository.createMerchant({ clientId: faker.string.uuid(), clientSecret: faker.string.uuid(), - name: faker.company.name(), + name: faker.string.uuid(), serialNumber: faker.string.uuid(), subscriptionKey: faker.string.uuid(), }); @@ -76,7 +76,7 @@ describe("productRepository", () => { id: toUpdate.merchant.id, clientId: existing.clientId, clientSecret: faker.string.uuid(), - name: faker.company.name(), + name: faker.string.uuid(), serialNumber: faker.string.uuid(), subscriptionKey: faker.string.uuid(), }); diff --git a/src/services/events/__tests__/integration/dependencies-factory.ts b/src/services/events/__tests__/integration/dependencies-factory.ts index 42ab78cd..3154d6f4 100644 --- a/src/services/events/__tests__/integration/dependencies-factory.ts +++ b/src/services/events/__tests__/integration/dependencies-factory.ts @@ -56,6 +56,7 @@ export function makeServices() { const vipps = MockVippsClientFactory(); const productService = ProductService({ productRepository, + mailService, paymentProcessingQueue: mockDeep(), vippsFactory: vipps.factory, config: { diff --git a/src/services/events/__tests__/integration/sign-off-waitlist-promotion.test.ts b/src/services/events/__tests__/integration/sign-off-waitlist-promotion.test.ts index c4297a9b..ddc665e1 100644 --- a/src/services/events/__tests__/integration/sign-off-waitlist-promotion.test.ts +++ b/src/services/events/__tests__/integration/sign-off-waitlist-promotion.test.ts @@ -101,6 +101,7 @@ describe("EventService", () => { userService, mailService, cabinService: mockDeep(), + productService: mock(), fileService: mock(), logger: mock(), }); @@ -277,8 +278,8 @@ describe("EventService", () => { event: expect.objectContaining({ name: event.name, startAt: expect.any(String), - url: `${env.CLIENT_URL}/events/${event.id}`, }), + actionUrl: `${env.CLIENT_URL}/events/${event.id}`, }), }), ); @@ -468,8 +469,8 @@ describe("EventService", () => { event: expect.objectContaining({ name: event.name, startAt: expect.any(String), - url: `${env.CLIENT_URL}/events/${event.id}`, }), + actionUrl: `${env.CLIENT_URL}/events/${event.id}`, }), }), ); diff --git a/src/services/events/__tests__/unit/events.test.ts b/src/services/events/__tests__/unit/events.test.ts index 693bac58..fd29b33b 100644 --- a/src/services/events/__tests__/unit/events.test.ts +++ b/src/services/events/__tests__/unit/events.test.ts @@ -458,8 +458,8 @@ describe("EventsService", () => { updateEventParams: { event: { id: faker.string.uuid(), - endAt: faker.date.between({ from: startAt, to: endAt }), - startAt: faker.date.soon({ refDate: endAt, days: 2 }), + endAt: DateTime.now().plus({ days: 1 }).toJSDate(), + startAt: DateTime.now().plus({ days: 2 }).toJSDate(), }, }, }, diff --git a/src/services/mail/__tests__/integration/worker.test.ts b/src/services/mail/__tests__/integration/worker.test.ts index 73a5e4da..9fef0bfc 100644 --- a/src/services/mail/__tests__/integration/worker.test.ts +++ b/src/services/mail/__tests__/integration/worker.test.ts @@ -4,13 +4,17 @@ import { jest } from "@jest/globals"; import { QueueEvents, UnrecoverableError } from "bullmq"; import { Redis } from "ioredis"; import { type DeepMockProxy, mock, mockDeep } from "jest-mock-extended"; +import { DateTime } from "luxon"; import { env } from "~/config.js"; import { type Booking, BookingTerms } from "~/domain/cabins.js"; import { DownstreamServiceError, InternalServerError, NotFoundError, + PermissionDeniedError, } from "~/domain/errors.js"; +import type { EventType } from "~/domain/events/event.js"; +import type { OrderType, ProductType } from "~/domain/products.js"; import type { User } from "~/domain/users.js"; import { Queue } from "~/lib/bullmq/queue.js"; import { Worker } from "~/lib/bullmq/worker.js"; @@ -22,6 +26,7 @@ import { type EmailWorkerType, type EventService, type FileService, + type ProductService, type UserService, getEmailHandler, } from "../../worker.js"; @@ -34,6 +39,7 @@ describe("MailService", () => { let mockMailService: DeepMockProxy; let mockEventService: DeepMockProxy; let mockCabinService: DeepMockProxy; + let mockProductService: DeepMockProxy; let mockFileService: DeepMockProxy; let eventsRedis: Redis; let queueEvents: QueueEvents; @@ -44,6 +50,7 @@ describe("MailService", () => { mockMailService = mockDeep(); mockEventService = mockDeep(); mockCabinService = mockDeep(); + mockProductService = mockDeep(); mockFileService = mockDeep(); redis = new Redis(env.REDIS_CONNECTION_STRING, { keepAlive: 1_000 * 60 * 3, // 3 minutes @@ -60,6 +67,7 @@ describe("MailService", () => { userService: mockUserService, eventService: mockEventService, cabinService: mockCabinService, + productService: mockProductService, fileService: mockFileService, logger: mock(), }); @@ -114,6 +122,327 @@ describe("MailService", () => { }, 10_000); }); + describe("type: event-wait-list-confrimation", () => { + it("should send a notification to the user", async () => { + const user = { + ...mock(), + id: faker.string.uuid(), + email: faker.internet.email(), + }; + const event = { + ...mock(), + id: faker.string.uuid(), + name: faker.lorem.words(3), + startAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + location: faker.location.streetAddress(), + }; + mockUserService.get.mockResolvedValue(user); + mockMailService.send.mockResolvedValue(); + mockEventService.get.mockResolvedValue(event); + + const job = await mailQueue.add("send-email", { + type: "event-wait-list-confirmation", + eventId: faker.string.uuid(), + recipientId: faker.string.uuid(), + }); + + await job.waitUntilFinished(queueEvents, 10_0000); + + expect(mockMailService.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: user.email, + templateAlias: "event-wait-list", + content: expect.objectContaining({ + event: { + name: event.name, + location: event.location, + startAt: "fredag 1. januar 2077 kl. 12:00", + }, + actionUrl: `${env.CLIENT_URL}/events/${event.id}`, + }), + }), + ); + }, 10_000); + }); + + describe("type: order-receipt", () => { + it("should send a receipt to the user", async () => { + const order: OrderType = { + ...mock(), + id: faker.string.uuid(), + capturedPaymentAttemptReference: faker.string.uuid(), + purchasedAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + productId: faker.string.uuid(), + totalPrice: faker.number.int(), + }; + const product: ProductType = { + ...mock(), + id: faker.string.uuid(), + name: faker.lorem.words(3), + }; + const user: User = { + ...mock(), + id: faker.string.uuid(), + firstName: faker.person.firstName(), + email: faker.internet.exampleEmail(), + }; + mockProductService.orders.get.mockResolvedValue({ + ok: true, + data: { order }, + }); + mockProductService.products.get.mockResolvedValue({ + ok: true, + data: { product }, + }); + mockUserService.get.mockResolvedValue(user); + mockMailService.send.mockResolvedValue(); + + const job = await mailQueue.add("send-email", { + type: "order-receipt", + userId: user.id, + orderId: order.id, + }); + + await job.waitUntilFinished(queueEvents, 10_0000); + + expect(mockMailService.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: user.email, + templateAlias: "order-receipt", + content: expect.objectContaining({ + order: expect.objectContaining({ + id: order.id, + totalPrice: order.totalPrice, + purchasedAt: "fredag 1. januar 2077 kl. 12:00", + }), + product: expect.objectContaining({ + name: product.name, + }), + user: expect.objectContaining({ + firstName: user.firstName, + }), + actionUrl: `${env.CLIENT_URL}/profile/orders/${order.id}?reference=${order.capturedPaymentAttemptReference}`, + }), + }), + ); + }, 10_000); + + it("throws unrecoverable error on permission denied", async () => { + const order: OrderType = { + ...mock(), + id: faker.string.uuid(), + capturedPaymentAttemptReference: faker.string.uuid(), + purchasedAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + productId: faker.string.uuid(), + totalPrice: faker.number.int(), + }; + const user: User = { + ...mock(), + id: faker.string.uuid(), + firstName: faker.person.firstName(), + email: faker.internet.exampleEmail(), + }; + mockProductService.orders.get.mockResolvedValue( + Result.error(new PermissionDeniedError("")), + ); + mockUserService.get.mockResolvedValue(user); + mailWorker.on("failed", (_job, error) => { + expect(error).toBeInstanceOf(UnrecoverableError); + }); + + const job = await mailQueue.add("send-email", { + type: "order-receipt", + userId: user.id, + orderId: order.id, + }); + + try { + await job.waitUntilFinished(queueEvents, 7_500); + fail("Expected job to fail"); + } catch { + const state = await job.getState(); + expect(state).toBe("failed"); + } + }, 10_000); + + it("throws unrecoverable error on order not found", async () => { + const order: OrderType = { + ...mock(), + id: faker.string.uuid(), + capturedPaymentAttemptReference: faker.string.uuid(), + purchasedAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + productId: faker.string.uuid(), + totalPrice: faker.number.int(), + }; + const user: User = { + ...mock(), + id: faker.string.uuid(), + firstName: faker.person.firstName(), + email: faker.internet.exampleEmail(), + }; + mockProductService.orders.get.mockResolvedValue( + Result.error(new NotFoundError("")), + ); + mockUserService.get.mockResolvedValue(user); + mailWorker.on("failed", (_job, error) => { + expect(error).toBeInstanceOf(UnrecoverableError); + }); + + const job = await mailQueue.add("send-email", { + type: "order-receipt", + userId: user.id, + orderId: order.id, + }); + + try { + await job.waitUntilFinished(queueEvents, 7_500); + fail("Expected job to fail"); + } catch { + const state = await job.getState(); + expect(state).toBe("failed"); + } + }, 10_000); + + it("throws error on internal server error from getOrder", async () => { + const order: OrderType = { + ...mock(), + id: faker.string.uuid(), + capturedPaymentAttemptReference: faker.string.uuid(), + purchasedAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + productId: faker.string.uuid(), + totalPrice: faker.number.int(), + }; + const user: User = { + ...mock(), + id: faker.string.uuid(), + firstName: faker.person.firstName(), + email: faker.internet.exampleEmail(), + }; + mockProductService.orders.get.mockResolvedValue( + Result.error(new InternalServerError("")), + ); + mockUserService.get.mockResolvedValue(user); + mailWorker.on("failed", (_job, error) => { + expect(error).toBeInstanceOf(InternalServerError); + }); + + const job = await mailQueue.add("send-email", { + type: "order-receipt", + userId: user.id, + orderId: order.id, + }); + + try { + await job.waitUntilFinished(queueEvents, 7_500); + fail("Expected job to fail"); + } catch { + const state = await job.getState(); + expect(state).toBe("failed"); + } + }, 10_000); + + it("throws unrecoverable error on product not found", async () => { + const order: OrderType = { + ...mock(), + id: faker.string.uuid(), + capturedPaymentAttemptReference: faker.string.uuid(), + purchasedAt: DateTime.fromObject( + { + year: 2077, + day: 1, + month: 1, + hour: 12, + minute: 0, + second: 0, + }, + { zone: "Europe/Oslo" }, + ).toJSDate(), + productId: faker.string.uuid(), + totalPrice: faker.number.int(), + }; + const user: User = { + ...mock(), + id: faker.string.uuid(), + firstName: faker.person.firstName(), + email: faker.internet.exampleEmail(), + }; + mockProductService.orders.get.mockResolvedValue( + Result.success({ order }), + ); + mockProductService.products.get.mockResolvedValue( + Result.error(new NotFoundError("")), + ); + mockUserService.get.mockResolvedValue(user); + mailWorker.on("failed", (_job, error) => { + expect(error).toBeInstanceOf(UnrecoverableError); + }); + + const job = await mailQueue.add("send-email", { + type: "order-receipt", + userId: user.id, + orderId: order.id, + }); + + try { + await job.waitUntilFinished(queueEvents, 7_500); + fail("Expected job to fail"); + } catch { + const state = await job.getState(); + expect(state).toBe("failed"); + } + }, 10_000); + }); + describe("type: cabin-booking-receipt", () => { it("should throw UnrecoverableError if the booking does not exist", async () => { mockCabinService.getBooking.mockResolvedValue({ diff --git a/src/services/mail/index.ts b/src/services/mail/index.ts index 51a5e70c..8a9a7aac 100644 --- a/src/services/mail/index.ts +++ b/src/services/mail/index.ts @@ -1,4 +1,6 @@ import { merge } from "lodash-es"; +import type { OrderType, ProductType } from "~/domain/products.js"; +import type { User } from "~/domain/users.js"; import type { EmailClient } from "~/lib/postmark.js"; import type { EmailQueueDataType, EmailQueueType } from "./worker.js"; @@ -8,6 +10,7 @@ type LayoutContent = { parentCompany: string; companyName: string; contactMail: string; + actionUrl?: string; }; type BaseMailContent< @@ -28,7 +31,6 @@ type EventWaitlistNotification = BaseMailContent< startAt: string; location?: string; price?: string; - url: string; }; } >; @@ -53,10 +55,20 @@ type UserRegistration = BaseMailContent< } >; +type OrderReceipt = BaseMailContent< + "order-receipt", + { + order: Pick & { purchasedAt: string }; + product: Pick; + user: Pick; + } +>; + export type MailContent = | EventWaitlistNotification | CabinBookingReceipt - | UserRegistration; + | UserRegistration + | OrderReceipt; type MailServiceDependencies = { emailQueue: EmailQueueType; diff --git a/src/services/mail/worker.ts b/src/services/mail/worker.ts index 4a539cb9..7a7aebe3 100644 --- a/src/services/mail/worker.ts +++ b/src/services/mail/worker.ts @@ -7,8 +7,11 @@ import type { DownstreamServiceError, InternalServerError, NotFoundError, + PermissionDeniedError, + UnauthorizedError, } from "~/domain/errors.js"; import type { EventType } from "~/domain/events/index.js"; +import type { OrderType, ProductType } from "~/domain/products.js"; import type { StudyProgram, User } from "~/domain/users.js"; import type { Queue } from "~/lib/bullmq/queue.js"; import type { Worker } from "~/lib/bullmq/worker.js"; @@ -16,6 +19,27 @@ import type { Context } from "~/lib/context.js"; import type { ResultAsync } from "~/lib/result.js"; import type { Attachment, MailContent } from "./index.js"; +export type ProductService = { + orders: { + get( + ctx: Context, + params: { id: string }, + ): ResultAsync< + { order: OrderType }, + | NotFoundError + | UnauthorizedError + | PermissionDeniedError + | InternalServerError + >; + }; + products: { + get( + _ctx: Context, + params: { id: string }, + ): ResultAsync<{ product: ProductType }, NotFoundError>; + }; +}; + export type UserService = { get(id: string): Promise; getStudyProgram(by: { id: string }): Promise; @@ -68,7 +92,8 @@ type EmailQueueDataType = recipientId: string; } | { type: "user-registration"; recipientId: string } - | { type: "cabin-booking-receipt"; bookingId: string }; + | { type: "cabin-booking-receipt"; bookingId: string } + | { type: "order-receipt"; orderId: string; userId: string }; type EmailWorker = EmailWorkerHelperType< EmailQueueDataType, @@ -89,6 +114,7 @@ type Dependencies = { eventService: EventService; cabinService: CabinService; fileService: FileService; + productService: ProductService; logger: Logger; }; @@ -98,6 +124,7 @@ const EmailHandler = ({ eventService, cabinService, fileService, + productService, logger, }: Dependencies): EmailProcessorType => { return async (job: EmailJobType) => { @@ -154,8 +181,11 @@ const EmailHandler = ({ { locale: "nb" }, ), location: event.location, - url: new URL(`/events/${event.id}`, env.CLIENT_URL).toString(), }, + actionUrl: new URL( + `/events/${event.id}`, + env.CLIENT_URL, + ).toString(), }, }); return { @@ -230,11 +260,84 @@ const EmailHandler = ({ ok: true, }; } + case "order-receipt": { + const { orderId, userId } = job.data; + const user = await userService.get(userId); + + const ctx: Context = { + user, + log: logger, + }; + + const getOrderResult = await productService.orders.get(ctx, { + id: orderId, + }); + if (!getOrderResult.ok) { + switch (getOrderResult.error.name) { + case "NotFoundError": + throw new UnrecoverableError("Order does not exist"); + case "InternalServerError": + throw getOrderResult.error; + case "PermissionDeniedError": + case "UnauthorizedError": + throw new UnrecoverableError("Permission denied"); + } + } + const { order } = getOrderResult.data; + const getProductResult = await productService.products.get(ctx, { + id: order.productId, + }); + if (!getProductResult.ok) { + switch (getProductResult.error.name) { + case "NotFoundError": + throw new UnrecoverableError("Product does not exist"); + } + } + + await mailService.send({ + to: user.email, + templateAlias: "order-receipt", + content: { + user, + order: { + id: order.id, + purchasedAt: order.purchasedAt + ? DateTime.fromJSDate(order.purchasedAt).toLocaleString( + { + ...DateTime.DATETIME_HUGE, + timeZoneName: undefined, + timeZone: "Europe/Oslo", + }, + { locale: "nb" }, + ) + : "", + totalPrice: order.totalPrice, + }, + product: getProductResult.data.product, + actionUrl: new URL( + `/profile/orders/${order.id}?reference=${order.capturedPaymentAttemptReference}`, + env.CLIENT_URL, + ).toString(), + }, + }); + return { + ok: true, + data: {}, + }; + } } }; }; -function getEmailHandler(dependencies: Dependencies): { +function getEmailHandler(dependencies: { + mailService: MailService; + userService: UserService; + eventService: EventService; + cabinService: CabinService; + fileService: FileService; + productService: ProductService; + logger: Logger; +}): { handler: EmailProcessorType; name: typeof EmailQueueName; } { diff --git a/src/services/products/__tests__/integration/deps.ts b/src/services/products/__tests__/integration/deps.ts index e7bc149e..b8a796dd 100644 --- a/src/services/products/__tests__/integration/deps.ts +++ b/src/services/products/__tests__/integration/deps.ts @@ -1,12 +1,21 @@ import { faker } from "@faker-js/faker"; import { QueueEvents } from "bullmq"; import { Redis } from "ioredis"; -import { mockDeep } from "jest-mock-extended"; +import { mock, mockDeep } from "jest-mock-extended"; import { env } from "~/config.js"; import { Queue } from "~/lib/bullmq/queue.js"; import { Worker } from "~/lib/bullmq/worker.js"; +import type { EmailClient } from "~/lib/postmark.js"; import prisma from "~/lib/prisma.js"; import { ProductRepository } from "~/repositories/products/repository.js"; +import { UserRepository } from "~/repositories/users/index.js"; +import { buildMailService } from "~/services/mail/index.js"; +import { + type EmailQueueType, + type EmailWorkerType, + getEmailHandler, +} from "~/services/mail/worker.js"; +import { UserService } from "~/services/users/service.js"; import { ProductService, type ProductServiceType } from "../../service.js"; import { type PaymentProcessingQueueType, @@ -18,22 +27,43 @@ import { MockVippsClientFactory } from "../mock-vipps-client.js"; export async function makeDependencies(overrides?: { productService?: ProductServiceType; }) { - const queueName = faker.string.uuid(); + const paymentQueueName = faker.string.uuid(); const productRepository = new ProductRepository(prisma); const { client, factory } = MockVippsClientFactory(); const redis = new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null, }); const paymentProcessingQueue: PaymentProcessingQueueType = new Queue( - queueName, + paymentQueueName, { connection: redis, }, ); + const emailQueueName = faker.string.uuid(); + const emailQueue: EmailQueueType = new Queue(emailQueueName, { + connection: redis, + }); + const emailClient = mockDeep(); + + const mailService = buildMailService( + { + emailQueue, + emailClient, + }, + { + companyName: "Test Company", + contactMail: faker.internet.exampleEmail(), + noReplyEmail: faker.internet.exampleEmail(), + parentCompany: "Test Parent Company", + productName: "Test Product", + websiteUrl: "https://example.com", + }, + ); const productService = overrides?.productService ?? ProductService({ + mailService, vippsFactory: factory, paymentProcessingQueue, productRepository, @@ -42,32 +72,66 @@ export async function makeDependencies(overrides?: { returnUrl: env.SERVER_URL, }, }); + const userRepository = new UserRepository(prisma); + const userService = new UserService(userRepository, mailService); + const { handler } = getPaymentProcessingHandler({ productService, log: mockDeep(), }); - const worker: PaymentProcessingWorkerType = new Worker(queueName, handler, { - connection: redis, + const worker: PaymentProcessingWorkerType = new Worker( + paymentQueueName, + handler, + { + connection: redis, + }, + ); + const emailHandler = getEmailHandler({ + mailService, + cabinService: mockDeep(), + eventService: mockDeep(), + productService, + userService, + logger: mock(), + fileService: mock(), + }); + const emailWorker: EmailWorkerType = new Worker( + emailQueueName, + emailHandler.handler, + { + connection: redis, + }, + ); + const emailQueueEventsRedis = new Redis(env.REDIS_CONNECTION_STRING, { + maxRetriesPerRequest: null, + }); + const emailQueueEvents = new QueueEvents(emailQueueName, { + connection: emailQueueEventsRedis, }); const queueEventsRedis = new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null, }); - const queueEvents = new QueueEvents(queueName, { + const queueEvents = new QueueEvents(paymentQueueName, { connection: queueEventsRedis, }); const close = async () => { + await emailQueueEvents.close(); await paymentProcessingQueue.close(); + await emailQueue.close(); + await emailWorker.close(true); await worker.close(true); await queueEvents.close(); queueEventsRedis.disconnect(); + emailQueueEventsRedis.disconnect(); redis.disconnect(); }; await worker.waitUntilReady(); + await emailWorker.waitUntilReady(); await queueEvents.waitUntilReady(); await paymentProcessingQueue.waitUntilReady(); @@ -78,6 +142,10 @@ export async function makeDependencies(overrides?: { queueEvents, paymentProcessingQueue, worker, - queueName, + paymentQueueName, + emailClient, + emailQueue, + emailQueueName, + emailQueueEvents, }; } diff --git a/src/services/products/__tests__/integration/initiate-payment-attempt.test.ts b/src/services/products/__tests__/integration/initiate-payment-attempt.test.ts index a2f08cd5..1e61a90c 100644 --- a/src/services/products/__tests__/integration/initiate-payment-attempt.test.ts +++ b/src/services/products/__tests__/integration/initiate-payment-attempt.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert"; import { faker } from "@faker-js/faker"; import type { EPaymentGetPaymentOKResponse } from "@vippsmobilepay/sdk"; import type { QueueEvents } from "bullmq"; -import { mock, mockDeep } from "jest-mock-extended"; +import { type DeepMockProxy, mock, mockDeep } from "jest-mock-extended"; import { User } from "~/domain/users.js"; import { makeMockContext } from "~/lib/context.js"; +import type { EmailClient } from "~/lib/postmark.js"; import prisma from "~/lib/prisma.js"; +import type { EmailQueueType } from "~/services/mail/worker.js"; import type { ProductServiceType } from "../../service.js"; import type { PaymentProcessingQueueType } from "../../worker.js"; import type { MockVippsClientFactory } from "../mock-vipps-client.js"; @@ -17,10 +19,21 @@ describe("ProductService", () => { let vippsMock: ReturnType["client"]; let queueEvents: QueueEvents; let paymentProcessingQueue: PaymentProcessingQueueType; + let emailClient: DeepMockProxy; + let emailQueue: EmailQueueType; + let emailQueueEvents: QueueEvents; beforeAll(async () => { - ({ productService, close, vippsMock, queueEvents, paymentProcessingQueue } = - await makeDependencies()); + ({ + productService, + close, + vippsMock, + queueEvents, + paymentProcessingQueue, + emailClient, + emailQueue, + emailQueueEvents, + } = await makeDependencies()); }); afterAll(async () => { @@ -51,7 +64,7 @@ describe("ProductService", () => { }); const ctx = makeMockContext(new User(user)); const merchantResult = await productService.merchants.create(ctx, { - name: faker.company.name(), + name: faker.string.uuid(), serialNumber: faker.string.sample(6), subscriptionKey: faker.string.uuid(), clientId: faker.string.uuid(), @@ -196,6 +209,12 @@ describe("ProductService", () => { expect(updatedOrder.paymentStatus).toEqual("CAPTURED"); expect(updatedOrder.isFinalState()).toBe(true); expect(updatedOrder.capturedPaymentAttemptReference).toEqual(reference); + + const pendingEmailJobs = await emailQueue.getJobs(); + await Promise.all( + pendingEmailJobs.map((job) => job.waitUntilFinished(emailQueueEvents)), + ); + expect(emailClient.sendEmailWithTemplate).toHaveBeenCalled(); }); }); }); diff --git a/src/services/products/__tests__/unit/capture.test.ts b/src/services/products/__tests__/unit/capture.test.ts index 0032efe1..a3f703bb 100644 --- a/src/services/products/__tests__/unit/capture.test.ts +++ b/src/services/products/__tests__/unit/capture.test.ts @@ -135,5 +135,78 @@ describe("ProductService", () => { expect(result).toEqual(expected); }, ); + + it("should send a receipt on capture", async () => { + /** + * Arrange + */ + const { + productService, + productRepository, + mockVippsClient, + mockMailService, + } = makeDependencies(); + const order = mock({ + paymentStatus: "CREATED", + userId: faker.string.uuid(), + }); + const paymentAttempt = mock({ + state: "AUTHORIZED", + reference: faker.string.uuid(), + }); + productRepository.getOrder.mockResolvedValue({ + ok: true, + data: { + order, + }, + }); + productRepository.getPaymentAttempt.mockResolvedValue({ + ok: true, + data: { + paymentAttempt, + }, + }); + productRepository.getMerchant.mockResolvedValue({ + ok: true, + data: { + merchant: mock(), + }, + }); + mockVippsClient.payment.capture.mockResolvedValue({ + ok: true, + data: { + amount: { + value: 100 * 100, + currency: "NOK", + }, + reference: paymentAttempt?.reference ?? "", + state: "AUTHORIZED", + aggregate: mock(), + pspReference: mock(), + }, + }); + productRepository.updateOrder.mockImplementation((_params, fn) => { + const updated = fn(order); + if (!updated.ok) throw updated.error; + + return Promise.resolve({ + ok: true, + data: { + order: updated.data.order, + }, + }); + }); + + const result = await productService.payments.capture(makeMockContext(), { + reference: faker.string.uuid(), + }); + if (!result.ok) throw result.error; + + expect(mockMailService.sendAsync).toHaveBeenCalledWith({ + type: "order-receipt", + orderId: order.id, + userId: order.userId, + }); + }); }); }); diff --git a/src/services/products/__tests__/unit/dependencies.ts b/src/services/products/__tests__/unit/dependencies.ts index 4abf8202..59638bcf 100644 --- a/src/services/products/__tests__/unit/dependencies.ts +++ b/src/services/products/__tests__/unit/dependencies.ts @@ -1,5 +1,9 @@ import { mockDeep } from "jest-mock-extended"; -import { type ProductRepository, ProductService } from "../../service.js"; +import { + type MailService, + type ProductRepository, + ProductService, +} from "../../service.js"; import type { PaymentProcessingQueueType } from "../../worker.js"; import { MockVippsClientFactory } from "../mock-vipps-client.js"; @@ -8,10 +12,12 @@ function makeDependencies() { const mockVippsClient = client; const productRepository = mockDeep(); const mockPaymentProcessingQueue = mockDeep(); + const mockMailService = mockDeep(); const productService = ProductService({ vippsFactory: factory, paymentProcessingQueue: mockPaymentProcessingQueue, productRepository, + mailService: mockMailService, config: { useTestMode: true, returnUrl: "http://localhost:3000", @@ -22,6 +28,7 @@ function makeDependencies() { productRepository, productService, mockPaymentProcessingQueue, + mockMailService, }; } diff --git a/src/services/products/payments.ts b/src/services/products/payments.ts index f30886d4..0bad761d 100644 --- a/src/services/products/payments.ts +++ b/src/services/products/payments.ts @@ -30,6 +30,7 @@ function buildPayments({ vippsFactory, config, paymentProcessingQueue, + mailService, }: BuildProductsDependencies) { async function newClient( by: { merchantId: string } | { orderId: string } | { productId: string }, @@ -673,6 +674,14 @@ function buildPayments({ return updateOrder; } + if (order.userId !== null) { + await mailService.sendAsync({ + type: "order-receipt", + orderId: order.id, + userId: order.userId, + }); + } + return { ok: true, data: { diff --git a/src/services/products/service.ts b/src/services/products/service.ts index 04306489..12ec9a69 100644 --- a/src/services/products/service.ts +++ b/src/services/products/service.ts @@ -8,6 +8,7 @@ import type { } from "~/domain/products.js"; import type { Context } from "~/lib/context.js"; import type { ResultAsync, TResult } from "~/lib/result.js"; +import type { EmailQueueDataType } from "../mail/worker.js"; import { buildMerchants } from "./merchants.js"; import { buildOrders } from "./orders.js"; import { buildPayments } from "./payments.js"; @@ -92,10 +93,15 @@ interface ProductRepository { >; } +export interface MailService { + sendAsync(jobData: EmailQueueDataType): Promise; +} + type BuildProductsDependencies = { vippsFactory: typeof Client; paymentProcessingQueue: PaymentProcessingQueueType; productRepository: ProductRepository; + mailService: MailService; config: { returnUrl: string; useTestMode?: boolean;