import { describe, it, expect } from "vitest"; import { SlidingWindowLimiter, almatyDate, botReason, emailQuery, isPlausibleEmail, mergeAttribs, parseQuizLead, planExisting, shouldSendResult, type ListmonkSubscriber, } from "../quiz-lead"; const NOW = new Date("2026-11-20T10:00:00Z"); const QUIZ_LIST = 48; const v2 = (extra: Record = {}) => parseQuizLead({ email: " Reader@Example.com ", quiz_type: "t3-ready", utm_source: "youtube", ...extra })!; const v1 = () => parseQuizLead({ email: "a@example.com", archetype: "gardener", score: 70, utm_source: "telegram" })!; /** Подписчик Press и покупатель — тот, кого прежний код и выбрасывал из Press. */ const pressBuyer = (extra: Partial = {}): ListmonkSubscriber => ({ id: 7, email: "reader@example.com", name: "Читатель", status: "enabled", lists: [ { id: 3, subscription_status: "confirmed" }, { id: 58, subscription_status: "unconfirmed" }, { id: 13, subscription_status: "unsubscribed" }, ], attribs: { course: "obsidian", updates_until: "20270101", utm_source: "first-touch" }, ...extra, }); describe("quiz-lead: разбор входа", () => { it("v2: тип по проблеме, почта нормализуется", () => { const l = v2({ quiz_answers: "1a 2b", utm_content: "{{utm_content}}", started_at: 1000, send_result: true }); expect(l.email).toBe("reader@example.com"); expect(l.quizType).toBe("t3-ready"); expect(l.archetype).toBeNull(); expect(l.answers).toBe("1a 2b"); expect(l.utm).toEqual({ source: "youtube", medium: null, campaign: null, content: null }); expect(l.startedAt).toBe(1000); expect(l.sendResult).toBe(true); }); it("v1 (опубликованный бот) проходит как раньше", () => { const l = v1(); expect(l.archetype).toBe("gardener"); expect(l.score).toBe(70); expect(l.quizType).toBeNull(); expect(l.sendResult).toBe(false); }); it("отказ: чужой тип, неполный v1, мусор", () => { expect(parseQuizLead({ email: "a@example.com", quiz_type: "t9" })).toBeNull(); expect(parseQuizLead({ email: "a@example.com", archetype: "gardener" })).toBeNull(); expect(parseQuizLead({ email: "a@example.com" })).toBeNull(); expect(parseQuizLead(null)).toBeNull(); expect(parseQuizLead("x")).toBeNull(); }); it("почта с кавычкой или пробелом не принимается — она уходит в SQL-фильтр Listmonk", () => { expect(isPlausibleEmail("a@example.com")).toBe(true); expect(isPlausibleEmail("laukhin+test@dementiy.com")).toBe(true); expect(isPlausibleEmail("x'or'1'='1@example.com")).toBe(false); expect(isPlausibleEmail("a b@example.com")).toBe(false); expect(isPlausibleEmail("a@localhost")).toBe(false); expect(isPlausibleEmail("a".repeat(65) + "@example.com")).toBe(false); expect(parseQuizLead({ email: "x'@example.com", quiz_type: "t1-start" })).toBeNull(); }); it("фильтр экранирует кавычку на всякий случай", () => { expect(emailQuery("o'neil@example.com")).toBe("subscribers.email='o''neil@example.com'"); }); }); describe("quiz-lead: отсев ботов", () => { it("быстрее 20 секунд — бот, медленнее — человек, без времени (v1) — не судим", () => { const t = NOW.getTime(); expect(botReason(v2({ started_at: t - 5_000 }), t)).toMatch(/^too-fast/); expect(botReason(v2({ started_at: t - 90_000 }), t)).toBeNull(); expect(botReason(v1(), t)).toBeNull(); }); it("лимит в окне: сверх лимита — отказ, после окна — снова можно", () => { const lim = new SlidingWindowLimiter(2, 1000); expect(lim.allow("a", 0)).toBe(true); expect(lim.allow("a", 10)).toBe(true); expect(lim.allow("a", 20)).toBe(false); expect(lim.allow("b", 20)).toBe(true); expect(lim.allow("a", 1011)).toBe(true); }); it("письмо-результат — не чаще раза в сутки и только по просьбе бота", () => { const l = v2({ send_result: true }); expect(shouldSendResult(l, {}, NOW)).toBe(true); expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-20T01:00:00Z" }, NOW)).toBe(false); expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-19T09:00:00Z" }, NOW)).toBe(true); expect(shouldSendResult(v2(), {}, NOW)).toBe(false); }); }); describe("quiz-lead: существующий подписчик — ничего чужого не трогаем", () => { it("списки и их статусы остаются, квиз добавляется отдельным вызовом, preconfirm выключен", () => { const plan = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW); expect(plan.kind).toBe("update"); if (plan.kind !== "update") return; expect(plan.addToList).toBe(true); expect(plan.put.lists).toEqual([3, 58, 13, QUIZ_LIST]); expect(plan.put.preconfirm_subscriptions).toBe(false); expect(plan.put.status).toBe("enabled"); expect(plan.put.name).toBe("Читатель"); }); it("атрибуты дописываются, первая метка прихода не перетирается", () => { const plan = planExisting(pressBuyer(), v2({ quiz_answers: "ответы" }), QUIZ_LIST, NOW); if (plan.kind !== "update") throw new Error("ожидали update"); expect(plan.put.attribs).toMatchObject({ course: "obsidian", updates_until: "20270101", utm_source: "first-touch", quiz_type: "t3-ready", quiz_date: "20261120", quiz_answers: "ответы", quiz_utm: { source: "youtube", medium: null, campaign: null, content: null }, }); }); it("уже в списке квиза (даже отписан) — заново не добавляем", () => { const sub = pressBuyer({ lists: [{ id: 3 }, { id: QUIZ_LIST, subscription_status: "unsubscribed" }] }); const plan = planExisting(sub, v2(), QUIZ_LIST, NOW); if (plan.kind !== "update") throw new Error("ожидали update"); expect(plan.addToList).toBe(false); expect(plan.put.lists).toEqual([3, QUIZ_LIST]); }); it("заблокированного не трогаем и не разблокируем", () => { expect(planExisting(pressBuyer({ status: "blocklisted" }), v2(), QUIZ_LIST, NOW)).toEqual({ kind: "skip", reason: "blocklisted", }); }); it("v1 пишет archetype и score, общий utm — только если его не было", () => { const fresh = mergeAttribs({}, v1(), NOW, false); expect(fresh).toEqual({ archetype: "gardener", score: 70, utm_source: "telegram" }); const kept = mergeAttribs({ utm_source: "press" }, v1(), NOW, false); expect(kept.utm_source).toBe("press"); }); it("отметка об отправке письма ставится только когда письмо уходит", () => { const plan = planExisting(pressBuyer(), v2({ send_result: true }), QUIZ_LIST, NOW); if (plan.kind !== "update") throw new Error("ожидали update"); expect(plan.sendResult).toBe(true); expect(plan.put.attribs.quiz_result_sent_at).toBe(NOW.toISOString()); const quiet = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW); if (quiet.kind !== "update") throw new Error("ожидали update"); expect(quiet.put.attribs.quiz_result_sent_at).toBeUndefined(); }); it("дата квиза — по Алматы", () => { expect(almatyDate(new Date("2026-11-19T20:30:00Z"))).toBe("20261120"); }); });