mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const tx = {
|
|
customerPass: { updateMany: vi.fn() },
|
|
passAdjustment: { create: vi.fn(), findFirst: vi.fn(), count: vi.fn() },
|
|
};
|
|
|
|
vi.mock("@/src/lib/prisma", () => ({
|
|
prisma: {
|
|
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
|
|
product: { findFirst: vi.fn() },
|
|
user: { findFirst: vi.fn() },
|
|
customerPass: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
|
courseBooking: { count: vi.fn() },
|
|
passAdjustment: { create: vi.fn() },
|
|
},
|
|
}));
|
|
vi.mock("@/src/lib/auth/getUser", () => ({ requireManager: vi.fn() }));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), updateTag: vi.fn() }));
|
|
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
|
|
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
|
|
|
|
import {
|
|
adjustPassCredits,
|
|
endPass,
|
|
issuePass,
|
|
refundPass,
|
|
} from "@/src/modules/products/actions/pass-admin-actions";
|
|
import { prisma } from "@/src/lib/prisma";
|
|
import { requireManager } from "@/src/lib/auth/getUser";
|
|
import { sendMail } from "@/src/lib/mail/resend";
|
|
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
|
|
|
const purchasedPass = {
|
|
id: "pass-1",
|
|
userId: "user-1",
|
|
status: "active",
|
|
source: "purchase",
|
|
creditsTotal: 10,
|
|
creditsRemaining: 10,
|
|
validUntil: null,
|
|
stripePaymentIntentId: "pi_1",
|
|
stripeAccountId: "acct_studio",
|
|
amountPaid: 12500,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(requireManager).mockResolvedValue({
|
|
id: "mgr-1",
|
|
businesses: [{ id: "biz-1" }],
|
|
} as never);
|
|
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue(purchasedPass as never);
|
|
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
|
|
vi.mocked(sendMail).mockResolvedValue(undefined as never);
|
|
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
|
|
tx.passAdjustment.create.mockResolvedValue({});
|
|
tx.passAdjustment.findFirst.mockResolvedValue({ creditsDelta: -10 });
|
|
tx.passAdjustment.count.mockResolvedValue(0);
|
|
});
|
|
|
|
describe("issuePass", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(prisma.product.findFirst).mockResolvedValue({
|
|
id: "prod-1",
|
|
name: "10er-Karte",
|
|
sports: ["pilates"],
|
|
businessId: "biz-1",
|
|
business: { name: "Studio", branding: null },
|
|
} as never);
|
|
vi.mocked(prisma.customerPass.create).mockResolvedValue({} as never);
|
|
});
|
|
|
|
it("vergibt eine Karte an ein bestehendes Konto, mit eigenem Guthaben und Protokoll", async () => {
|
|
vi.mocked(prisma.user.findFirst).mockResolvedValue({ id: "user-1" } as never);
|
|
|
|
const result = await issuePass({
|
|
productId: "prod-1",
|
|
email: "Kunde@Example.com",
|
|
credits: 6,
|
|
validUntil: null,
|
|
reason: "Restguthaben vom alten Anbieter",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
|
|
expect(data).toMatchObject({
|
|
userId: "user-1",
|
|
email: "kunde@example.com",
|
|
creditsTotal: 6,
|
|
creditsRemaining: 6,
|
|
source: "manual",
|
|
sports: ["pilates"],
|
|
});
|
|
expect(data.adjustments).toEqual({
|
|
create: expect.objectContaining({ type: "issue", creditsDelta: 6, managerId: "mgr-1" }),
|
|
});
|
|
});
|
|
|
|
it("hält die Karte für eine Adresse ohne Konto bereit", async () => {
|
|
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
|
|
|
await issuePass({
|
|
productId: "prod-1",
|
|
email: "neu@example.com",
|
|
credits: 10,
|
|
validUntil: "2099-03-14",
|
|
reason: "",
|
|
});
|
|
|
|
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
|
|
expect(data.userId).toBeNull();
|
|
expect(data.validUntil).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("lehnt ein Produkt eines fremden Studios ab", async () => {
|
|
vi.mocked(prisma.product.findFirst).mockResolvedValue(null);
|
|
const result = await issuePass({
|
|
productId: "prod-x",
|
|
email: "a@b.de",
|
|
credits: 1,
|
|
validUntil: null,
|
|
reason: "",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(prisma.customerPass.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("adjustPassCredits", () => {
|
|
it("verlangt einen Grund", async () => {
|
|
const result = await adjustPassCredits({ passId: "pass-1", delta: 1, reason: " " });
|
|
expect(result).toEqual({ success: false, error: "Bitte gib einen Grund an." });
|
|
});
|
|
|
|
it("erhöht bei einer Gutschrift auch die Gesamtzahl der Karte", async () => {
|
|
await adjustPassCredits({ passId: "pass-1", delta: 2, reason: "Kulanz" });
|
|
expect(tx.customerPass.updateMany.mock.calls[0][0].data).toEqual({
|
|
creditsRemaining: { increment: 2 },
|
|
creditsTotal: { increment: 2 },
|
|
});
|
|
});
|
|
|
|
it("zieht nie unter null ab", async () => {
|
|
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
|
|
|
const result = await adjustPassCredits({ passId: "pass-1", delta: -20, reason: "Korrektur" });
|
|
|
|
expect(tx.customerPass.updateMany.mock.calls[0][0].where).toMatchObject({
|
|
creditsRemaining: { gte: 20 },
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("endPass", () => {
|
|
it("beendet und protokolliert in einer Transaktion", async () => {
|
|
const result = await endPass({ passId: "pass-1", reason: "Kunde zieht weg" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "pass-1", status: "active" },
|
|
data: { status: "ended" },
|
|
});
|
|
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({ type: "end" });
|
|
expect(prisma.passAdjustment.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("protokolliert nichts, wenn die Karte schon nicht mehr aktiv war", async () => {
|
|
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
|
expect((await endPass({ passId: "pass-1", reason: "" })).success).toBe(false);
|
|
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("refundPass", () => {
|
|
const refunded = {
|
|
success: true as const,
|
|
stripeRefundId: "re_1",
|
|
amountRefundedCents: 12500,
|
|
};
|
|
|
|
it("sperrt und protokolliert zuerst, dann erstattet Stripe komplett - die Gebühr trägt das Studio", async () => {
|
|
vi.mocked(refundStripePayment).mockResolvedValue(refunded);
|
|
|
|
const result = await refundPass({ passId: "pass-1" });
|
|
|
|
expect(result.success).toBe(true);
|
|
// Sperre: nicht mehr einlösbar, an den gelesenen Stand gebunden.
|
|
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "pass-1", status: { in: ["active", "ended"] }, creditsRemaining: 10 },
|
|
data: { status: "refunding", creditsRemaining: 0 },
|
|
});
|
|
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({
|
|
type: "refund",
|
|
creditsDelta: -10,
|
|
});
|
|
expect(refundStripePayment).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
paymentIntentId: "pi_1",
|
|
refundApplicationFee: false,
|
|
idempotencyKey: "refund-pass-pass-1",
|
|
})
|
|
);
|
|
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBeUndefined();
|
|
// Abschluss erst nach Stripes Bestätigung.
|
|
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "pass-1", status: "refunding" },
|
|
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
|
});
|
|
});
|
|
|
|
it("erstattet keine Karte, mit der schon gebucht wurde", async () => {
|
|
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
|
|
|
|
const result = await refundPass({ passId: "pass-1" });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
|
expect(refundStripePayment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("erstattet keine vergebene Karte - da floss kein Geld", async () => {
|
|
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
|
...purchasedPass,
|
|
source: "manual",
|
|
stripePaymentIntentId: null,
|
|
amountPaid: null,
|
|
} as never);
|
|
|
|
expect((await refundPass({ passId: "pass-1" })).success).toBe(false);
|
|
expect(refundStripePayment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("setzt die Karte nur zurück, wenn Stripe sicher abgelehnt hat", async () => {
|
|
vi.mocked(refundStripePayment).mockResolvedValue({
|
|
success: false,
|
|
error: "Insufficient funds",
|
|
definitive: true,
|
|
});
|
|
|
|
const result = await refundPass({ passId: "pass-1" });
|
|
|
|
expect(result.success).toBe(false);
|
|
// Status und Einheiten von vor der Sperre, aus dem Protokoll.
|
|
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
|
where: { id: "pass-1", status: "refunding" },
|
|
data: { status: "active", creditsRemaining: 10 },
|
|
});
|
|
expect(tx.passAdjustment.create).toHaveBeenLastCalledWith({
|
|
data: expect.objectContaining({ type: "refund_failed", creditsDelta: 10 }),
|
|
});
|
|
});
|
|
|
|
it("stellt eine vorher beendete Karte als beendet wieder her", async () => {
|
|
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
|
...purchasedPass,
|
|
status: "ended",
|
|
} as never);
|
|
tx.passAdjustment.count.mockResolvedValue(1);
|
|
vi.mocked(refundStripePayment).mockResolvedValue({
|
|
success: false,
|
|
error: "nein",
|
|
definitive: true,
|
|
});
|
|
|
|
await refundPass({ passId: "pass-1" });
|
|
|
|
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
|
where: { id: "pass-1", status: "refunding" },
|
|
data: { status: "ended", creditsRemaining: 10 },
|
|
});
|
|
});
|
|
|
|
it("lässt die Karte gesperrt, wenn der Ausgang offen ist", async () => {
|
|
vi.mocked(refundStripePayment).mockResolvedValue({
|
|
success: false,
|
|
error: "timeout",
|
|
definitive: false,
|
|
});
|
|
|
|
const result = await refundPass({ passId: "pass-1" });
|
|
|
|
expect(result.success).toBe(false);
|
|
// Nur die Sperre - kein Zurücksetzen, kein Abschluss.
|
|
expect(tx.customerPass.updateMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fragt bei einer gesperrten Karte nur erneut bei Stripe nach", async () => {
|
|
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
|
...purchasedPass,
|
|
status: "refunding",
|
|
creditsRemaining: 0,
|
|
} as never);
|
|
vi.mocked(refundStripePayment).mockResolvedValue({
|
|
success: false,
|
|
error: "Charge already refunded",
|
|
definitive: true,
|
|
code: "charge_already_refunded",
|
|
});
|
|
|
|
const result = await refundPass({ passId: "pass-1" });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "pass-1", status: "refunding" },
|
|
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
|
});
|
|
});
|
|
});
|