mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:01:16 +00:00
see comments
This commit is contained in:
parent
dc4f08ab0c
commit
d36bf73138
8 changed files with 289 additions and 23 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
122
tests/marketing/marketing-view.test.tsx
Normal file
122
tests/marketing/marketing-view.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
58
tests/products/pass-purchase-actions.test.ts
Normal file
58
tests/products/pass-purchase-actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue