Kuma/tests/marketing/triggers.test.ts
2026-10-02 17:31:08 +02:00

187 lines
6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
birthdayInYear,
findDueEvents,
type ActiveTrigger,
type CustomerFacts,
} from "@/src/modules/marketing/lib/triggers";
// activatedAt als Berliner Mittag, damit der Tag eindeutig ist.
const activatedOn = (day: string) => new Date(`${day}T10:00:00Z`);
function trigger(overrides: Partial<ActiveTrigger> & Pick<ActiveTrigger, "kind">): ActiveTrigger {
return {
id: `t-${overrides.kind}`,
config: {},
activatedAt: activatedOn("2026-01-01"),
emailNodeId: "mail-1",
...overrides,
};
}
function customer(overrides: Partial<CustomerFacts> = {}): CustomerFacts {
return { userId: "u1", birthday: null, bookingDays: [], ...overrides };
}
describe("birthdayInYear", () => {
it("nimmt Monat und Tag aus UTC", () => {
expect(birthdayInYear(new Date("1990-05-12"), 2026)).toBe("2026-05-12");
});
it("feiert den 29.2. in anderen Jahren am 28.2.", () => {
expect(birthdayInYear(new Date("1996-02-29"), 2027)).toBe("2027-02-28");
expect(birthdayInYear(new Date("1996-02-29"), 2028)).toBe("2028-02-29");
});
});
describe("Geburtstag", () => {
const birthday = trigger({ kind: "birthday" });
it("ist am Geburtstag fällig, Zeitraum ist das Jahr", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toEqual([
expect.objectContaining({ triggerKind: "birthday", userId: "u1", periodKey: "2026" }),
]);
});
it("holt bis zu zwei Tage nach, danach nicht mehr", () => {
const facts = [customer({ birthday: new Date("1990-05-12") })];
expect(
findDueEvents({ today: "2026-05-14", triggers: [birthday], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-05-15", triggers: [birthday], customers: facts })
).toHaveLength(0);
});
it("holt am 1.1. den 31.12. des Vorjahres nach", () => {
const events = findDueEvents({
today: "2027-01-01",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-12-31") })],
});
expect(events[0]?.periodKey).toBe("2026");
});
it("zählt keinen Geburtstag vor dem Einschalten", () => {
const events = findDueEvents({
today: "2026-05-13",
triggers: [trigger({ kind: "birthday", activatedAt: activatedOn("2026-05-13") })],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toHaveLength(0);
});
it("überspringt Kunden ohne Geburtsdatum", () => {
expect(
findDueEvents({ today: "2026-05-12", triggers: [birthday], customers: [customer()] })
).toHaveLength(0);
});
});
describe("Meilenstein", () => {
const milestone = trigger({ kind: "milestone", config: { bookings: 3 } });
it("ist fällig, sobald der 3. Kurs vorbei ist", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-10"] })],
});
expect(events).toEqual([
expect.objectContaining({ periodKey: "3", milestoneCount: 3, triggerKind: "milestone" }),
]);
});
it("zählt kommende Kurse nicht mit", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-12"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der die Schwelle vor dem Einschalten erreicht hat", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [
trigger({
kind: "milestone",
config: { bookings: 3 },
activatedAt: activatedOn("2026-03-08"),
}),
],
customers: [
customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-05", "2026-03-10"] }),
],
});
expect(events).toHaveLength(0);
});
it("holt höchstens sieben Tage nach", () => {
const facts = [customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-03"] })];
expect(
findDueEvents({ today: "2026-03-10", triggers: [milestone], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-03-11", triggers: [milestone], customers: facts })
).toHaveLength(0);
});
});
describe("Reaktivierung", () => {
const winback = trigger({ kind: "winback", config: { days: 30 } });
it("ist fällig, wenn der letzte Kurs 30 Tage her ist", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-31"] })],
});
expect(events).toEqual([expect.objectContaining({ periodKey: "2026-03-31:30" })]);
});
it("schreibt niemanden an, der schon wieder gebucht hat", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-31", "2026-05-02"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der schon vor dem Einschalten so lange weg war", () => {
const events = findDueEvents({
today: "2026-06-01",
triggers: [
trigger({ kind: "winback", config: { days: 30 }, activatedAt: activatedOn("2026-05-15") }),
],
customers: [customer({ bookingDays: ["2026-03-31"] })],
});
expect(events).toHaveLength(0);
});
it("braucht mindestens einen besuchten Kurs", () => {
expect(
findDueEvents({ today: "2026-04-30", triggers: [winback], customers: [customer()] })
).toHaveLength(0);
});
});
it("sortiert Geburtstag vor Meilenstein vor Reaktivierung", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [
trigger({ kind: "winback", config: { days: 30 } }),
trigger({ kind: "milestone", config: { bookings: 1 } }),
trigger({ kind: "birthday" }),
],
customers: [customer({ birthday: new Date("1990-05-12"), bookingDays: ["2026-04-11"] })],
});
expect(events.map((e) => e.triggerKind)).toEqual(["birthday", "winback"]);
});