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
+197
View File
@@ -0,0 +1,197 @@
/**
* Property-based tests for lib/paypal.ts (PaymentService)
* Feature: paypal-payment-integration
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fc from "fast-check";
// ---------------------------------------------------------------------------
// Property 11: Base URL đúng theo PAYPAL_MODE
// ---------------------------------------------------------------------------
describe("getPayPalBaseUrl", () => {
it(
"Feature: paypal-payment-integration, Property 11: Base URL đúng theo PAYPAL_MODE",
() => {
fc.assert(
fc.property(fc.constantFrom("sandbox", "live"), (mode) => {
// Test the logic directly without env dependency
const getUrl = (m: string) =>
m === "sandbox"
? "https://api-m.sandbox.paypal.com"
: "https://api-m.paypal.com";
const url = getUrl(mode);
if (mode === "sandbox") {
expect(url).toBe("https://api-m.sandbox.paypal.com");
} else {
expect(url).toBe("https://api-m.paypal.com");
}
}),
{ numRuns: 100 }
);
}
);
it("returns sandbox URL when PAYPAL_MODE=sandbox", async () => {
const originalMode = process.env.PAYPAL_MODE;
process.env.PAYPAL_MODE = "sandbox";
// Dynamic import to pick up env
const mod = await import("../paypal?sandbox=" + Date.now());
// Since module is cached, test the logic via the exported function
// The function reads process.env at call time
process.env.PAYPAL_MODE = "sandbox";
const { getPayPalBaseUrl } = await import("../paypal");
expect(getPayPalBaseUrl()).toBe("https://api-m.sandbox.paypal.com");
process.env.PAYPAL_MODE = originalMode;
});
it("returns live URL when PAYPAL_MODE=live", async () => {
const originalMode = process.env.PAYPAL_MODE;
process.env.PAYPAL_MODE = "live";
const { getPayPalBaseUrl } = await import("../paypal");
expect(getPayPalBaseUrl()).toBe("https://api-m.paypal.com");
process.env.PAYPAL_MODE = originalMode;
});
it("returns live URL when PAYPAL_MODE is not set", async () => {
const originalMode = process.env.PAYPAL_MODE;
delete process.env.PAYPAL_MODE;
const { getPayPalBaseUrl } = await import("../paypal");
expect(getPayPalBaseUrl()).toBe("https://api-m.paypal.com");
process.env.PAYPAL_MODE = originalMode;
});
});
// ---------------------------------------------------------------------------
// Property 1: Số tiền order luôn là $120.00 USD
// Property 2: Approval URL luôn được trả về
// Property 3: Idempotency Key luôn duy nhất
// ---------------------------------------------------------------------------
describe("createPayPalOrder", () => {
beforeEach(() => {
process.env.PAYPAL_MODE = "sandbox";
process.env.PAYPAL_CLIENT_ID = "test-client-id";
process.env.PAYPAL_CLIENT_SECRET = "test-client-secret";
process.env.NEXT_PUBLIC_BASE_PATH = "/ipv6";
});
afterEach(() => {
vi.restoreAllMocks();
});
it(
"Feature: paypal-payment-integration, Property 1: Số tiền order luôn là $120.00 USD",
async () => {
const capturedBodies: unknown[] = [];
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string, opts: RequestInit) => {
if (String(url).includes("/v1/oauth2/token")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
});
}
if (String(url).includes("/v2/checkout/orders")) {
capturedBodies.push(JSON.parse(opts.body as string));
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
id: "ORDER-" + Math.random(),
links: [{ rel: "approve", href: "https://paypal.com/approve" }],
}),
});
}
return Promise.resolve({ ok: false, text: () => Promise.resolve("unexpected") });
}));
const { createPayPalOrder } = await import("../paypal");
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (registrationId) => {
capturedBodies.length = 0;
await createPayPalOrder(registrationId);
expect(capturedBodies.length).toBeGreaterThan(0);
const body = capturedBodies[capturedBodies.length - 1] as any;
expect(body.purchase_units[0].amount.value).toBe("120.00");
expect(body.purchase_units[0].amount.currency_code).toBe("USD");
}
),
{ numRuns: 20 }
);
}
);
it(
"Feature: paypal-payment-integration, Property 2: Approval URL luôn được trả về khi tạo order thành công",
async () => {
const expectedUrl = "https://paypal.com/approve?token=TEST";
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
if (String(url).includes("/v1/oauth2/token")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
});
}
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
id: "ORDER-123",
links: [
{ rel: "self", href: "https://paypal.com/self" },
{ rel: "approve", href: expectedUrl },
],
}),
});
}));
const { createPayPalOrder } = await import("../paypal");
const result = await createPayPalOrder("reg-test");
expect(result.approvalUrl).toBe(expectedUrl);
expect(result.orderId).toBe("ORDER-123");
}
);
it(
"Feature: paypal-payment-integration, Property 3: Idempotency Key luôn duy nhất",
async () => {
const capturedKeys: string[] = [];
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string, opts: RequestInit) => {
if (String(url).includes("/v1/oauth2/token")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
});
}
if (String(url).includes("/v2/checkout/orders")) {
const key = (opts.headers as Record<string, string>)["PayPal-Request-Id"];
capturedKeys.push(key);
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
id: "ORDER-" + Math.random(),
links: [{ rel: "approve", href: "https://paypal.com/approve" }],
}),
});
}
return Promise.resolve({ ok: false, text: () => Promise.resolve("unexpected") });
}));
const { createPayPalOrder } = await import("../paypal");
for (let i = 0; i < 50; i++) {
await createPayPalOrder(`reg-${i}`);
}
const uniqueKeys = new Set(capturedKeys);
expect(uniqueKeys.size).toBe(capturedKeys.length);
expect(capturedKeys.length).toBe(50);
}
);
});
@@ -0,0 +1,167 @@
/**
* Property-based tests for payment functions in lib/summit-requests.ts
* Feature: paypal-payment-integration
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as fc from "fast-check";
import type { AdminRequestRow } from "@/types/admin-submission";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockCreate = vi.fn();
const mockFindOneAndUpdate = vi.fn();
const mockFindOne = vi.fn();
vi.mock("@/lib/mongodb", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/models/SummitRequest", () => ({
default: {
create: (...args: unknown[]) => mockCreate(...args),
findOneAndUpdate: (...args: unknown[]) => mockFindOneAndUpdate(...args),
findOne: (...args: unknown[]) => mockFindOne(...args),
},
}));
const mockRow: AdminRequestRow = {
id: "V6-TEST",
submittedAt: "2026-01-01",
displayDate: "01 JAN 2026",
fullName: "Test User",
jobTitle: "Engineer",
company: "Test Co",
segment: "enterprise",
email: "test@example.com",
phone: "+84 123456789",
status: "pending",
notes: "",
source: "registration",
};
// ---------------------------------------------------------------------------
// Property 4: PayPal Order ID được lưu với trạng thái payment_pending
// ---------------------------------------------------------------------------
describe("createRegistrationWithPayment", () => {
beforeEach(() => {
mockCreate.mockReset();
mockCreate.mockResolvedValue({});
});
it(
"Feature: paypal-payment-integration, Property 4: Order ID lưu với payment_pending",
async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
async (orderId) => {
const { createRegistrationWithPayment } = await import("../summit-requests");
await createRegistrationWithPayment(mockRow, orderId);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
paymentStatus: "payment_pending",
paypalOrderId: orderId,
paymentAmount: 120,
paymentCurrency: "USD",
})
);
}
),
{ numRuns: 50 }
);
}
);
});
// ---------------------------------------------------------------------------
// Property 5: Capture thành công chuyển trạng thái sang paid
// ---------------------------------------------------------------------------
describe("markPaymentPaid", () => {
beforeEach(() => {
mockFindOneAndUpdate.mockReset();
mockFindOneAndUpdate.mockResolvedValue({ paymentStatus: "paid" });
});
it(
"Feature: paypal-payment-integration, Property 5: Capture thành công chuyển sang paid",
async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
fc.string({ minLength: 1, maxLength: 50 }),
async (orderId, captureId) => {
const { markPaymentPaid } = await import("../summit-requests");
await markPaymentPaid(orderId, captureId);
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ paypalOrderId: orderId, paymentStatus: { $ne: "paid" } },
expect.objectContaining({
$set: expect.objectContaining({
paymentStatus: "paid",
paypalCaptureId: captureId,
}),
}),
{ new: true }
);
}
),
{ numRuns: 50 }
);
}
);
});
// ---------------------------------------------------------------------------
// Property 7: Cancel chuyển trạng thái sang payment_cancelled
// ---------------------------------------------------------------------------
describe("markPaymentCancelled", () => {
beforeEach(() => {
mockFindOneAndUpdate.mockReset();
mockFindOneAndUpdate.mockResolvedValue({ paymentStatus: "payment_cancelled" });
});
it(
"Feature: paypal-payment-integration, Property 7: Cancel chuyển trạng thái sang payment_cancelled",
async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
async (orderId) => {
const { markPaymentCancelled } = await import("../summit-requests");
await markPaymentCancelled(orderId);
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ paypalOrderId: orderId },
{ $set: { paymentStatus: "payment_cancelled" } },
{ new: true }
);
}
),
{ numRuns: 50 }
);
}
);
});
// ---------------------------------------------------------------------------
// Property 14: paymentStatus mặc định là not_required
// ---------------------------------------------------------------------------
describe("createSummitRequest (default paymentStatus)", () => {
beforeEach(() => {
mockCreate.mockReset();
mockCreate.mockResolvedValue({});
});
it(
"Feature: paypal-payment-integration, Property 14: paymentStatus mặc định là not_required",
async () => {
// The Mongoose schema sets default: "not_required" — verify the schema field is defined
// We test that createSummitRequest does NOT pass paymentStatus (so schema default applies)
const { createSummitRequest } = await import("../summit-requests");
await createSummitRequest(mockRow);
const callArg = mockCreate.mock.calls[0][0];
// createSummitRequest should not override paymentStatus — schema default handles it
expect(callArg).not.toHaveProperty("paymentStatus");
}
);
});