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
+      });
+    });
+  });
+});