feat(комментарии): письмо ученику об ответе под уроком
Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел ответ, только если сам возвращался в урок. У тредов вопросов письмо есть, у комментариев не было — из-за этого ответ под уроком 6.3 «Холст» висел непрочитанным, пока его не продублировали руками. - sendCommentReplyEmail: тот же шаблон ДС-2, что у ответов в тредах, с текстом ответа внутри письма и кнопкой на урок. Свой лимит превью 4000 символов: ввод разрешает 10 000, а порог вопросов (2000) резал бы развёрнутые разборы. - commentReplyRecipient: правила «шлём / не шлём» вынесены чистой функцией и покрыты тестами — не шлём при выключенной настройке, не-staff, удалённом родителе, ответе самому себе и пустой почте. - notifyStudentOnCommentReply: тумблер в админке рядом с уведомлением о фидбеке, по умолчанию включён. - Удалён неподключённый дубль comment-actions.ts: компонент комментариев ходит в lib/actions/student-actions.ts, а в дубле жил свой лимит 2000 — правка в нём не давала эффекта. Проверено: type-check и lint чистые (3 ошибки линтера — существующий долг в quick-enroll-modal и kinescope-player), vitest 13/13, письмо отправлено боем и доставлено (Resend, last_event=delivered). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const sent: Array<Record<string, string>> = [];
|
||||
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class {
|
||||
emails = {
|
||||
send: async (args: Record<string, string>) => {
|
||||
sent.push(args);
|
||||
return { data: { id: "test" }, error: null };
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../settings", () => ({
|
||||
getSetting: async () => "Second Brain",
|
||||
}));
|
||||
|
||||
const { sendCommentReplyEmail } = await import("../email");
|
||||
|
||||
const LESSON_URL = "https://school.second-brain.ru/courses/obsidian-full/lessons/lesson-1";
|
||||
|
||||
describe("sendCommentReplyEmail", () => {
|
||||
beforeEach(() => {
|
||||
sent.length = 0;
|
||||
});
|
||||
|
||||
it("кладёт текст ответа в письмо и ведёт кнопкой на урок", async () => {
|
||||
await sendCommentReplyEmail(
|
||||
"student@example.com",
|
||||
"Владимир",
|
||||
"6.3. Холст",
|
||||
"Прямого импорта нет, но есть два пути.",
|
||||
LESSON_URL,
|
||||
"Дмитрий"
|
||||
);
|
||||
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0].to).toBe("student@example.com");
|
||||
expect(sent[0].subject).toBe("Ответ на ваш комментарий к уроку «6.3. Холст»");
|
||||
expect(sent[0].html).toContain("Прямого импорта нет, но есть два пути.");
|
||||
expect(sent[0].html).toContain("Привет, Владимир!");
|
||||
expect(sent[0].html).toContain("Отвечает <strong>Дмитрий</strong>:");
|
||||
expect(sent[0].html).toContain(`href="${LESSON_URL}"`);
|
||||
});
|
||||
|
||||
it("экранирует разметку в тексте ответа и в названии урока", async () => {
|
||||
await sendCommentReplyEmail(
|
||||
"student@example.com",
|
||||
"Владимир",
|
||||
"Урок <b>про холст</b>",
|
||||
'Смотрите тег <script>alert("x")</script>',
|
||||
LESSON_URL
|
||||
);
|
||||
|
||||
const html = sent[0].html;
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).toContain("Урок <b>про холст</b>");
|
||||
});
|
||||
|
||||
it("обрезает длинный ответ и говорит, что целиком он под уроком", async () => {
|
||||
await sendCommentReplyEmail(
|
||||
"student@example.com",
|
||||
"Владимир",
|
||||
"6.3. Холст",
|
||||
"я".repeat(4500),
|
||||
LESSON_URL
|
||||
);
|
||||
|
||||
const html = sent[0].html;
|
||||
expect(html).toContain("Ответ длинный — целиком он под уроком.");
|
||||
expect(html).toContain("…");
|
||||
expect(html).not.toContain("я".repeat(4100));
|
||||
});
|
||||
|
||||
it("без имени отвечающего пишет просто «Школа ответила»", async () => {
|
||||
await sendCommentReplyEmail("student@example.com", "", "6.3. Холст", "Коротко", LESSON_URL);
|
||||
|
||||
const html = sent[0].html;
|
||||
expect(html).toContain("Школа ответила на ваш комментарий");
|
||||
expect(html).not.toContain("Отвечает <strong>");
|
||||
expect(html).toContain("Привет!");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user