Fix quiz-lead wiping lists and attributes of existing subscribers
PUT /api/subscribers/{id} with lists=[quiz] replaced all lists and attribs:
a Press reader taking the quiz dropped out of Press, buyers lost course and
updates_until, status "enabled" lifted blocklisting. Verified on Listmonk 6.2.
Now: add to the quiz list via PUT /api/subscribers/lists (action add), write
attribs with current lists and status and preconfirm_subscriptions=false,
leave blocklisted subscribers untouched, keep first-touch utm_*.
Bot protection: constant-time secret check, quizzes faster than 20 s are
dropped silently, global hourly cap and per-email cap, strict email check
(the address goes into a Listmonk SQL filter), result email at most once a
day per address. Quiz v2 fields: quiz_type, quiz_date, quiz_answers,
quiz_utm, optional result email through LMS (send_result). v1 bot payload
keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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<string, unknown> = {}) =>
|
||||
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> = {}): 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user