/** * Property-based tests for the webhook handler * Feature: paypal-payment-integration */ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as fc from "fast-check"; import { NextRequest } from "next/server"; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- const mockVerifyWebhookSignature = vi.fn(); const mockMarkPaymentPaid = vi.fn(); vi.mock("@/lib/paypal", () => ({ verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args), })); vi.mock("@/lib/summit-requests", () => ({ markPaymentPaid: (...args: unknown[]) => mockMarkPaymentPaid(...args), })); function makeRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "paypal-transmission-id": "tx-123", "paypal-transmission-time": "2026-01-01T00:00:00Z", "paypal-cert-url": "https://api.paypal.com/cert", "paypal-auth-algo": "SHA256withRSA", "paypal-transmission-sig": "sig-abc", "content-type": "application/json", ...headers, }; return new NextRequest("http://localhost/api/payments/webhook", { method: "POST", headers: defaultHeaders, body: JSON.stringify(body), }); } // --------------------------------------------------------------------------- // Property 8: Webhook với signature không hợp lệ → HTTP 401 // --------------------------------------------------------------------------- describe("webhook handler — signature validation", () => { beforeEach(() => { mockVerifyWebhookSignature.mockReset(); mockMarkPaymentPaid.mockReset(); process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id"; }); it( "Feature: paypal-payment-integration, Property 8: Webhook signature không hợp lệ → 401", async () => { await fc.assert( fc.asyncProperty( fc.record({ event_type: fc.string(), resource: fc.record({ id: fc.string() }), }), async (payload) => { mockVerifyWebhookSignature.mockResolvedValue(false); mockMarkPaymentPaid.mockResolvedValue(undefined); const { POST } = await import("../route"); const req = makeRequest(payload); const res = await POST(req); expect(res.status).toBe(401); expect(mockMarkPaymentPaid).not.toHaveBeenCalled(); } ), { numRuns: 30 } ); } ); it( "Feature: paypal-payment-integration, Property 8b: Webhook thiếu headers → 401", async () => { await fc.assert( fc.asyncProperty( fc.record({ event_type: fc.string(), resource: fc.record({ id: fc.string() }), }), async (payload) => { // Missing all PayPal signature headers const { POST } = await import("../route"); const req = makeRequest(payload, { "paypal-transmission-id": "", "paypal-transmission-time": "", "paypal-cert-url": "", "paypal-auth-algo": "", "paypal-transmission-sig": "", }); const res = await POST(req); expect(res.status).toBe(401); } ), { numRuns: 20 } ); } ); }); // --------------------------------------------------------------------------- // Property 9: Webhook idempotent — valid signature, already paid → 200 // Property 10: Webhook hợp lệ cập nhật trạng thái sang paid // --------------------------------------------------------------------------- describe("webhook handler — PAYMENT.CAPTURE.COMPLETED", () => { beforeEach(() => { mockVerifyWebhookSignature.mockReset(); mockMarkPaymentPaid.mockReset(); process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id"; }); it( "Feature: paypal-payment-integration, Property 10: Webhook hợp lệ cập nhật trạng thái sang paid", async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 30 }), fc.string({ minLength: 1, maxLength: 30 }), async (orderId, captureId) => { mockVerifyWebhookSignature.mockResolvedValue(true); mockMarkPaymentPaid.mockResolvedValue(undefined); const payload = { event_type: "PAYMENT.CAPTURE.COMPLETED", resource: { id: captureId, supplementary_data: { related_ids: { order_id: orderId }, }, }, }; const { POST } = await import("../route"); const req = makeRequest(payload); const res = await POST(req); expect(res.status).toBe(200); expect(mockMarkPaymentPaid).toHaveBeenCalledWith(orderId, captureId); } ), { numRuns: 30 } ); } ); it( "Feature: paypal-payment-integration, Property 9: Webhook idempotent — trả về 200 kể cả khi đã paid", async () => { // markPaymentPaid is idempotent (no-op if already paid) — webhook should still return 200 mockVerifyWebhookSignature.mockResolvedValue(true); mockMarkPaymentPaid.mockResolvedValue(undefined); // no-op const payload = { event_type: "PAYMENT.CAPTURE.COMPLETED", resource: { id: "capture-already-done", supplementary_data: { related_ids: { order_id: "order-already-paid" } }, }, }; const { POST } = await import("../route"); const req = makeRequest(payload); const res = await POST(req); expect(res.status).toBe(200); } ); });