This commit is contained in:
2026-05-19 12:48:41 +07:00
parent 208a91b971
commit 55a0133ed4
23 changed files with 3781 additions and 22 deletions
@@ -0,0 +1,173 @@
/**
* 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<string, string> = {}): NextRequest {
const defaultHeaders: Record<string, string> = {
"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);
}
);
});