diff --git a/src/app/api/stripe/checkout/pass/route.ts b/src/app/api/stripe/checkout/pass/route.ts index 5d1c2b5..7b39997 100644 --- a/src/app/api/stripe/checkout/pass/route.ts +++ b/src/app/api/stripe/checkout/pass/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { prisma } from "@/src/lib/prisma"; import { getPlatformFeeCents } from "@/src/lib/stripe"; import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout"; @@ -30,6 +31,12 @@ import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent"; * * Body: { productId: string; discountCode?: string; marketingConsent?: boolean } */ +const bodySchema = z.object({ + productId: z.string().min(1), + discountCode: z.string().optional(), + marketingConsent: z.boolean().optional(), +}); + export async function POST(request: NextRequest) { try { const userData = await getUserData(); @@ -48,14 +55,11 @@ export async function POST(request: NextRequest) { ); } - const { productId, discountCode, marketingConsent } = (await request.json()) as { - productId?: string; - discountCode?: string; - marketingConsent?: boolean; - }; - if (!productId) { - return NextResponse.json({ error: "productId fehlt" }, { status: 400 }); + const body = bodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } + const { productId, discountCode, marketingConsent } = body.data; const product = await prisma.product.findUnique({ where: { id: productId }, diff --git a/src/modules/marketing/lib/consent.ts b/src/modules/marketing/lib/consent.ts index 2336880..6f522cf 100644 --- a/src/modules/marketing/lib/consent.ts +++ b/src/modules/marketing/lib/consent.ts @@ -1,4 +1,5 @@ import { revalidateTag } from "next/cache"; +import { Prisma } from "@/src/generated/prisma/client"; import { prisma } from "@/src/lib/prisma"; import { writeAuditLog } from "./audit-log"; import { CONSENT_SOURCE_LABELS, CONSENT_TEXT_VERSION, type ConsentSource } from "./consent-text"; @@ -18,10 +19,43 @@ export async function setMarketingConsent(params: { businessId: string; granted: boolean; source: ConsentSource; +}): Promise { + const { userId } = params; + + // Zwei gleichzeitige Anfragen für ein neues Kunde-Studio-Paar (Doppelklick, + // Buchung und Einstellungen zugleich) lesen beide "keine Zeile" und wollen + // beide anlegen - eine scheitert am Unique-Key. Ein zweiter Durchlauf sieht + // dann die Zeile der anderen und gleicht nur noch ab. + let changed: boolean; + try { + changed = await applyConsent(params); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) { + throw error; + } + changed = await applyConsent(params); + } + + // Das Kunden-Detail des Managers zeigt den Stand der Einwilligung. + if (changed) { + try { + revalidateTag(`kunde-detail-${userId}`, { expire: 0 }); + } catch { + // Außerhalb eines Requests (Skripte, Tests) gibt es keinen Cache. + } + } + return changed; +} + +async function applyConsent(params: { + userId: string; + businessId: string; + granted: boolean; + source: ConsentSource; }): Promise { const { userId, businessId, granted, source } = params; - const changed = await prisma.$transaction(async (tx) => { + return prisma.$transaction(async (tx) => { const current = await tx.marketingConsent.findUnique({ where: { userId_businessId: { userId, businessId } }, select: { granted: true }, @@ -59,16 +93,6 @@ export async function setMarketingConsent(params: { return true; }); - - // Das Kunden-Detail des Managers zeigt den Stand der Einwilligung. - if (changed) { - try { - revalidateTag(`kunde-detail-${userId}`, { expire: 0 }); - } catch { - // Außerhalb eines Requests (Skripte, Tests) gibt es keinen Cache. - } - } - return changed; } /** diff --git a/src/modules/marketing/ui/views/marketing-view.tsx b/src/modules/marketing/ui/views/marketing-view.tsx index 7ea6a6b..9678270 100644 --- a/src/modules/marketing/ui/views/marketing-view.tsx +++ b/src/modules/marketing/ui/views/marketing-view.tsx @@ -29,18 +29,19 @@ const MarketingView = ({ businesses, initialData }: MarketingViewProps) => { const isMobile = useIsMobile(); const requestSeq = useRef(0); + // Studio und Daten wechseln zusammen, erst wenn das neue Studio geladen ist. + // Sonst würde der Editor (key={businessId}) mit den Bausteinen des alten + // Studios neu starten und sie beim Speichern dem neuen zuordnen. const changeBusiness = (next: string) => { const seq = ++requestSeq.current; - const previous = businessId; - setBusinessId(next); startLoading(async () => { const result = await getMarketingEditor(next); if (seq !== requestSeq.current) return; if (!result.success) { - setBusinessId(previous); toast.error(result.error); return; } + setBusinessId(next); setData(result.data); }); }; diff --git a/src/modules/products/actions/pass-purchase-actions.ts b/src/modules/products/actions/pass-purchase-actions.ts index a6a1b81..2726b88 100644 --- a/src/modules/products/actions/pass-purchase-actions.ts +++ b/src/modules/products/actions/pass-purchase-actions.ts @@ -60,9 +60,17 @@ export async function previewPassDiscountCode( const product = await prisma.product.findUnique({ where: { id: productId }, - select: { id: true, businessId: true, priceCents: true, isActive: true }, + select: { + id: true, + businessId: true, + priceCents: true, + isActive: true, + business: { select: { isPublic: true } }, + }, }); - if (!product || !product.isActive) { + // Dieselben Bedingungen wie im Checkout - sonst zeigt die Vorschau einen + // Preis, den der Checkout dann ablehnt. + if (!product || !product.isActive || !product.business.isPublic) { return { valid: false, error: "Diese Karte ist nicht verfügbar." }; } diff --git a/tests/api/stripe/checkout-pass.test.ts b/tests/api/stripe/checkout-pass.test.ts index 5894caf..d71cd64 100644 --- a/tests/api/stripe/checkout-pass.test.ts +++ b/tests/api/stripe/checkout-pass.test.ts @@ -158,6 +158,23 @@ describe("POST /api/stripe/checkout/pass", () => { }); }); + it("antwortet bei falschen Feldtypen mit 400 statt 500", async () => { + const res = await POST(request({ productId: "prod-1", discountCode: 5 })); + expect(res.status).toBe(400); + expect(reserveDiscountCode).not.toHaveBeenCalled(); + expect((await POST(request({ productId: "" }))).status).toBe(400); + }); + + it("antwortet bei kaputtem JSON mit 400", async () => { + const res = await POST( + new Request("http://localhost/api/stripe/checkout/pass", { + method: "POST", + body: "{kein json", + }) as never + ); + expect(res.status).toBe(400); + }); + it("speichert das Häkchen für Werbe-Mails", async () => { await POST(request({ productId: "prod-1", marketingConsent: true })); expect(grantConsentQuietly).toHaveBeenCalledWith({ diff --git a/tests/marketing/consent.test.ts b/tests/marketing/consent.test.ts index ba041fc..38711b2 100644 --- a/tests/marketing/consent.test.ts +++ b/tests/marketing/consent.test.ts @@ -11,6 +11,7 @@ vi.mock("@/src/lib/prisma", () => { return { prisma }; }); +import { Prisma } from "@/src/generated/prisma/client"; import { prisma } from "@/src/lib/prisma"; import { setMarketingConsent } from "@/src/modules/marketing/lib/consent"; import { CONSENT_TEXT_VERSION } from "@/src/modules/marketing/lib/consent-text"; @@ -83,4 +84,35 @@ describe("setMarketingConsent", () => { expect(prisma.marketingConsent.upsert).not.toHaveBeenCalled(); expect(prisma.marketingAuditLog.createMany).not.toHaveBeenCalled(); }); + + it("versucht es einmal neu, wenn eine parallele Anfrage die Zeile zuerst anlegt", async () => { + // Beide Anfragen lesen "keine Zeile"; unsere scheitert beim Anlegen am Unique-Key. + // Im zweiten Durchlauf ist die Zeile der anderen Anfrage da. + vi.mocked(prisma.marketingConsent.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ granted: true } as never); + vi.mocked(prisma.marketingConsent.upsert).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }) + ); + + const changed = await setMarketingConsent({ + userId: "user-1", + businessId: "biz-1", + granted: true, + source: "profile", + }); + + expect(changed).toBe(false); + expect(prisma.marketingConsent.findUnique).toHaveBeenCalledTimes(2); + }); + + it("gibt andere Fehler weiter", async () => { + vi.mocked(prisma.marketingConsent.findUnique).mockRejectedValue(new Error("DB weg")); + await expect( + setMarketingConsent({ userId: "u", businessId: "b", granted: true, source: "profile" }) + ).rejects.toThrow("DB weg"); + }); }); diff --git a/tests/marketing/marketing-view.test.tsx b/tests/marketing/marketing-view.test.tsx new file mode 100644 index 0000000..e472043 --- /dev/null +++ b/tests/marketing/marketing-view.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +vi.mock("@/src/hooks/use-mobile", () => ({ useIsMobile: () => true })); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); +vi.mock("@/src/modules/marketing/actions/editor-actions", () => ({ + getMarketingEditor: vi.fn(), + setTriggerActive: vi.fn(), + getSendHistory: vi.fn(), + getChangeHistory: vi.fn(), +})); +// Radix-Select lässt sich in jsdom kaum bedienen - ein natives onValueChange(e.target.value)}> + {children} + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( + + ), +})); + +import { toast } from "sonner"; +import MarketingView from "@/src/modules/marketing/ui/views/marketing-view"; +import { + getMarketingEditor, + setTriggerActive, + type EditorData, +} from "@/src/modules/marketing/actions/editor-actions"; +import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config"; + +function editorData(prefix: string): EditorData { + return { + consentingCustomers: 3, + nodes: [ + { + id: `${prefix}-trigger`, + kind: "birthday", + config: {}, + position: { x: 0, y: 0 }, + emailNodeId: `${prefix}-mail`, + isActive: false, + sent: 0, + redeemed: 0, + activatedAt: null, + }, + { + id: `${prefix}-mail`, + kind: "email", + config: { ...EMAIL_TEMPLATES.birthday, name: `Mail ${prefix}` }, + position: { x: 300, y: 0 }, + emailNodeId: null, + isActive: false, + sent: 0, + redeemed: 0, + activatedAt: null, + }, + ], + }; +} + +const businesses = [ + { id: "biz-a", name: "Studio A" }, + { id: "biz-b", name: "Studio B" }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("MarketingView - Studio wechseln", () => { + it("wechselt Studio und Daten erst zusammen, wenn das neue Studio geladen ist", async () => { + let resolve!: (value: Awaited>) => void; + vi.mocked(getMarketingEditor).mockReturnValue(new Promise((r) => (resolve = r))); + vi.mocked(setTriggerActive).mockResolvedValue({ success: true, data: editorData("b") }); + + render(); + expect(screen.getByText(/Mail a/)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Business"), { target: { value: "biz-b" } }); + + // Solange B lädt, bleibt alles bei A - auch die Auswahl. + expect(screen.getByText(/Mail a/)).toBeInTheDocument(); + expect(screen.getByLabelText("Business")).toHaveValue("biz-a"); + + await act(async () => resolve({ success: true, data: editorData("b") })); + + expect(screen.getByText(/Mail b/)).toBeInTheDocument(); + expect(screen.getByLabelText("Business")).toHaveValue("biz-b"); + + await act(async () => { + fireEvent.click(screen.getByRole("switch")); + }); + expect(setTriggerActive).toHaveBeenCalledWith("biz-b", "b-trigger", true); + }); + + it("bleibt beim alten Studio, wenn das neue nicht lädt", async () => { + vi.mocked(getMarketingEditor).mockResolvedValue({ success: false, error: "Kaputt" }); + + render(); + await act(async () => { + fireEvent.change(screen.getByLabelText("Business"), { target: { value: "biz-b" } }); + }); + + expect(toast.error).toHaveBeenCalledWith("Kaputt"); + expect(screen.getByLabelText("Business")).toHaveValue("biz-a"); + expect(screen.getByText(/Mail a/)).toBeInTheDocument(); + }); +}); diff --git a/tests/products/pass-purchase-actions.test.ts b/tests/products/pass-purchase-actions.test.ts new file mode 100644 index 0000000..66dd70c --- /dev/null +++ b/tests/products/pass-purchase-actions.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/src/lib/prisma", () => ({ prisma: { product: { findUnique: vi.fn() } } })); +vi.mock("@/src/lib/auth/getUser", () => ({ + getUserData: vi.fn(), + isUser: (user: { role: string }) => user.role === "user", +})); +vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, validateDiscountCode: vi.fn() }; +}); +vi.mock("@/src/modules/marketing/lib/consent", () => ({ hasMarketingConsent: vi.fn() })); + +import { prisma } from "@/src/lib/prisma"; +import { getUserData } from "@/src/lib/auth/getUser"; +import { validateDiscountCode } from "@/src/modules/settings/lib/discount-validation"; +import { previewPassDiscountCode } from "@/src/modules/products/actions/pass-purchase-actions"; + +const product = { + id: "prod-1", + businessId: "biz-1", + priceCents: 12000, + isActive: true, + business: { isPublic: true }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getUserData).mockResolvedValue({ id: "user-1", role: "user" } as never); + vi.mocked(prisma.product.findUnique).mockResolvedValue(product as never); + vi.mocked(validateDiscountCode).mockResolvedValue({ + valid: true, + discountCode: { id: "code-1", type: "percent", value: 25 }, + }); +}); + +describe("previewPassDiscountCode", () => { + it("rechnet den Kartenpreis mit Rabatt", async () => { + expect(await previewPassDiscountCode("prod-1", "KARTE25")).toEqual({ + valid: true, + discountedPriceCents: 9000, + discountAmountCents: 3000, + }); + }); + + it("lehnt Karten nicht öffentlicher Studios ab - wie der Checkout", async () => { + vi.mocked(prisma.product.findUnique).mockResolvedValue({ + ...product, + business: { isPublic: false }, + } as never); + expect(await previewPassDiscountCode("prod-1", "KARTE25")).toEqual({ + valid: false, + error: "Diese Karte ist nicht verfügbar.", + }); + expect(validateDiscountCode).not.toHaveBeenCalled(); + }); +});