mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +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 { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { getPlatformFeeCents } from "@/src/lib/stripe";
|
import { getPlatformFeeCents } from "@/src/lib/stripe";
|
||||||
import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout";
|
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 }
|
* 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userData = await getUserData();
|
const userData = await getUserData();
|
||||||
|
|
@ -48,14 +55,11 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { productId, discountCode, marketingConsent } = (await request.json()) as {
|
const body = bodySchema.safeParse(await request.json().catch(() => null));
|
||||||
productId?: string;
|
if (!body.success) {
|
||||||
discountCode?: string;
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
marketingConsent?: boolean;
|
|
||||||
};
|
|
||||||
if (!productId) {
|
|
||||||
return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
const { productId, discountCode, marketingConsent } = body.data;
|
||||||
|
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
|
import { Prisma } from "@/src/generated/prisma/client";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { writeAuditLog } from "./audit-log";
|
import { writeAuditLog } from "./audit-log";
|
||||||
import { CONSENT_SOURCE_LABELS, CONSENT_TEXT_VERSION, type ConsentSource } from "./consent-text";
|
import { CONSENT_SOURCE_LABELS, CONSENT_TEXT_VERSION, type ConsentSource } from "./consent-text";
|
||||||
|
|
@ -18,10 +19,43 @@ export async function setMarketingConsent(params: {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
granted: boolean;
|
granted: boolean;
|
||||||
source: ConsentSource;
|
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> {
|
}): Promise<boolean> {
|
||||||
const { userId, businessId, granted, source } = params;
|
const { userId, businessId, granted, source } = params;
|
||||||
|
|
||||||
const changed = await prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.marketingConsent.findUnique({
|
const current = await tx.marketingConsent.findUnique({
|
||||||
where: { userId_businessId: { userId, businessId } },
|
where: { userId_businessId: { userId, businessId } },
|
||||||
select: { granted: true },
|
select: { granted: true },
|
||||||
|
|
@ -59,16 +93,6 @@ export async function setMarketingConsent(params: {
|
||||||
|
|
||||||
return true;
|
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 isMobile = useIsMobile();
|
||||||
const requestSeq = useRef(0);
|
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 changeBusiness = (next: string) => {
|
||||||
const seq = ++requestSeq.current;
|
const seq = ++requestSeq.current;
|
||||||
const previous = businessId;
|
|
||||||
setBusinessId(next);
|
|
||||||
startLoading(async () => {
|
startLoading(async () => {
|
||||||
const result = await getMarketingEditor(next);
|
const result = await getMarketingEditor(next);
|
||||||
if (seq !== requestSeq.current) return;
|
if (seq !== requestSeq.current) return;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setBusinessId(previous);
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setBusinessId(next);
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,17 @@ export async function previewPassDiscountCode(
|
||||||
|
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: productId },
|
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." };
|
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 () => {
|
it("speichert das Häkchen für Werbe-Mails", async () => {
|
||||||
await POST(request({ productId: "prod-1", marketingConsent: true }));
|
await POST(request({ productId: "prod-1", marketingConsent: true }));
|
||||||
expect(grantConsentQuietly).toHaveBeenCalledWith({
|
expect(grantConsentQuietly).toHaveBeenCalledWith({
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ vi.mock("@/src/lib/prisma", () => {
|
||||||
return { prisma };
|
return { prisma };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { Prisma } from "@/src/generated/prisma/client";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { setMarketingConsent } from "@/src/modules/marketing/lib/consent";
|
import { setMarketingConsent } from "@/src/modules/marketing/lib/consent";
|
||||||
import { CONSENT_TEXT_VERSION } from "@/src/modules/marketing/lib/consent-text";
|
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.marketingConsent.upsert).not.toHaveBeenCalled();
|
||||||
expect(prisma.marketingAuditLog.createMany).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