Kuma/tests/products/pass-admin-actions.test.ts
2026-10-02 15:36:12 +02:00

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