From 1f35abe72a68f03d42394e752149d568ff72f449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= <jozsef.kozma+akash@gmail.com> Date: Wed, 29 Jan 2025 13:19:09 +0100 Subject: [PATCH] test(billing): functional tests for Stripe webhook refs #719 --- .../stripe-webhook/stripe-webhook.service.ts | 4 +- .../test/functional/stripe-webhook.spec.ts | 132 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/functional/stripe-webhook.spec.ts diff --git a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts index a338d374a..654664922 100644 --- a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts +++ b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts @@ -36,13 +36,13 @@ export class StripeWebhookService { return; } - const checkoutSession = await this.stripe.checkout.sessions.retrieve(event.data.object.id, { + const checkoutSession = await this.stripe.checkout.sessions.retrieve(sessionId, { expand: ["line_items"] }); if (checkoutSession.payment_status !== "unpaid") { await this.refillService.topUpWallet(checkoutSession.amount_subtotal, checkoutSessionCache.userId); - await this.checkoutSessionRepository.deleteBy({ sessionId: event.data.object.id }); + await this.checkoutSessionRepository.deleteBy({ sessionId }); } else { this.logger.error({ event: "PAYMENT_NOT_COMPLETED", sessionId }); } diff --git a/apps/api/test/functional/stripe-webhook.spec.ts b/apps/api/test/functional/stripe-webhook.spec.ts new file mode 100644 index 000000000..66df0255d --- /dev/null +++ b/apps/api/test/functional/stripe-webhook.spec.ts @@ -0,0 +1,132 @@ +import { UserSetting } from "@akashnetwork/database/dbSchemas/user"; +import { faker } from "@faker-js/faker"; +import { eq } from "drizzle-orm"; +import nock from "nock"; +import stripe from "stripe"; +import { container } from "tsyringe"; + +import { app } from "@src/app"; +import { CheckoutSessionRepository } from "@src/billing/repositories"; +import { ApiPgDatabase, POSTGRES_DB, resolveTable } from "@src/core"; + +import { DbTestingService } from "@test/services/db-testing.service"; + +jest.setTimeout(20000); + +describe("Stripe webhook", () => { + const userWalletsTable = resolveTable("UserWallets"); + const db = container.resolve<ApiPgDatabase>(POSTGRES_DB); + const userWalletsQuery = db.query.UserWallets; + const checkoutSessionRepository = container.resolve(CheckoutSessionRepository); + const dbService = container.resolve(DbTestingService); + + afterEach(async () => { + await dbService.cleanAll(); + }); + + const generatePayload = (sessionId: string, eventType: string) => + JSON.stringify({ + data: { + object: { + id: sessionId + } + }, + type: eventType + }); + + const getWebhookResponse = async (sessionId: string, eventType: string) => { + const payload = generatePayload(sessionId, eventType); + + return await app.request("/v1/stripe-webhook", { + method: "POST", + body: payload, + headers: new Headers({ + "Content-Type": "text/plain", + "Stripe-Signature": stripe.webhooks.generateTestHeaderString({ + payload, + secret: process.env.STRIPE_WEBHOOK_SECRET + }) + }) + }); + }; + + describe("POST /v1/stripe-webhook", () => { + ["checkout.session.completed", "checkout.session.async_payment_succeeded"].forEach(eventType => { + it(`tops up wallet and drops session from cache for event ${eventType}`, async () => { + const sessionId = faker.string.uuid(); + const userId = faker.string.uuid(); + await UserSetting.create({ id: userId }); + await checkoutSessionRepository.create({ + sessionId, + userId + }); + nock("https://api.stripe.com").get(`/v1/checkout/sessions/${sessionId}?expand[0]=line_items`).reply(200, { + payment_status: "paid", + amount_subtotal: 100 + }); + + const webhookResponse = await getWebhookResponse(sessionId, eventType); + + const userWallet = await userWalletsQuery.findFirst({ where: eq(userWalletsTable.userId, userId) }); + const checkoutSession = await checkoutSessionRepository.findOneBy({ + sessionId + }); + expect(webhookResponse.status).toBe(200); + expect(userWallet).toMatchObject({ + userId, + deploymentAllowance: `1000000.00`, + isTrialing: false + }); + expect(checkoutSession).toBeUndefined(); + }); + }); + + it("does not top up wallet and keeps cache if the payment is not done", async () => { + const sessionId = faker.string.uuid(); + const userId = faker.string.uuid(); + await UserSetting.create({ id: userId }); + await checkoutSessionRepository.create({ + sessionId, + userId + }); + nock("https://api.stripe.com").get(`/v1/checkout/sessions/${sessionId}?expand[0]=line_items`).reply(200, { + payment_status: "unpaid", + amount_subtotal: 100 + }); + + const webhookResponse = await getWebhookResponse(sessionId, "checkout.session.completed"); + + const userWallet = await userWalletsQuery.findFirst({ where: eq(userWalletsTable.userId, userId) }); + const checkoutSession = await checkoutSessionRepository.findOneBy({ + sessionId + }); + expect(webhookResponse.status).toBe(200); + expect(userWallet).toBeUndefined(); + expect(checkoutSession).toMatchObject({ + sessionId + }); + }); + + it("does not top up wallet and keeps cache if the event is different", async () => { + const sessionId = faker.string.uuid(); + const userId = faker.string.uuid(); + await UserSetting.create({ id: userId }); + await checkoutSessionRepository.create({ + sessionId, + userId + }); + + const webhookResponse = await getWebhookResponse(sessionId, "checkout.session.not-found"); + + const userWallet = await userWalletsQuery.findFirst({ where: eq(userWalletsTable.userId, userId) }); + const checkoutSession = await checkoutSessionRepository.findOneBy({ + sessionId + }); + expect(webhookResponse.status).toBe(200); + expect(userWallet).toBeUndefined(); + expect(checkoutSession).toMatchObject({ + sessionId + }); + }); + }); +});