see comments

This commit is contained in:
Rajpreet0 2026-10-02 17:44:59 +02:00
parent dc4f08ab0c
commit d36bf73138
8 changed files with 289 additions and 23 deletions

View file

@ -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 },

View file

@ -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<boolean> {
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<boolean> {
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;
}
/**

View file

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

View file

@ -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." };
}

View file

@ -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({

View file

@ -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");
});
});

View file

@ -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 <select> reicht hier.
vi.mock("@/src/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (v: string) => void;
children: ReactNode;
}) => (
<select aria-label="Business" value={value} onChange={(e) => onValueChange(e.target.value)}>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
<option value={value}>{children}</option>
),
}));
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<ReturnType<typeof getMarketingEditor>>) => void;
vi.mocked(getMarketingEditor).mockReturnValue(new Promise((r) => (resolve = r)));
vi.mocked(setTriggerActive).mockResolvedValue({ success: true, data: editorData("b") });
render(<MarketingView businesses={businesses} initialData={editorData("a")} />);
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(<MarketingView businesses={businesses} initialData={editorData("a")} />);
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();
});
});

View file

@ -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<typeof import("@/src/modules/settings/lib/discount-validation")>();
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();
});
});