Files
portal/tools/night/mailer.test.mjs
T

135 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sendSummary, mailFailedSince, recordOwnerAck, OWNER_ACTION_KINDS } from './mailer.mjs';
import { readLines, readJson } from './state.mjs';
let dir, failPath, sentPath, repliesPath;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'night-mail-'));
failPath = join(dir, 'mail-failures.jsonl');
sentPath = join(dir, 'last-summary.json');
repliesPath = join(dir, 'owner-replies.jsonl');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
const OK_TRANSPORT = { sendMail: async () => ({ messageId: '1' }) };
const DEAD_TRANSPORT = { sendMail: async () => { throw new Error('SMTP лёг'); } };
describe('sendSummary', () => {
it('письмо ушло — неудача не записана', async () => {
const r = await sendSummary({ subject: 'с', text: 'т', failPath, transport: OK_TRANSPORT });
expect(r.ok).toBe(true);
expect(readLines(failPath)).toEqual([]);
});
it('письмо уходит ровно с тем текстом и заголовком, которые дала сводка', async () => {
const seen = [];
await sendSummary({
subject: 'Лидерра · сводка за 30.07.2026, 08:00',
text: 'Прогон r-1.',
failPath,
from: 'robot@liderra.ru',
to: 'owner@example.com',
transport: { sendMail: async m => { seen.push(m); return { messageId: '1' }; } },
});
expect(seen.length).toBe(1);
expect(seen[0].subject).toBe('Лидерра · сводка за 30.07.2026, 08:00');
expect(seen[0].text).toBe('Прогон r-1.');
expect(seen[0].from).toBe('robot@liderra.ru');
expect(seen[0].to).toBe('owner@example.com');
});
it('письмо ушло — записано ВРЕМЯ успешной отправки', async () => {
await sendSummary({ subject: 'с', text: 'т', failPath, sentPath, at: 777, transport: OK_TRANSPORT });
expect(readJson(sentPath).last_summary_at).toBe(777);
});
it('письмо НЕ ушло — время успешной отправки не трогается, иначе неудача сошла бы за успех', async () => {
await sendSummary({ subject: 'с', text: 'т', failPath, sentPath, at: 777, transport: OK_TRANSPORT });
await sendSummary({ subject: 'с', text: 'т', failPath, sentPath, at: 999, transport: DEAD_TRANSPORT });
expect(readJson(sentPath).last_summary_at).toBe(777);
});
it('письмо не ушло — записана СВОЯ неудача, и она видна снаружи', async () => {
const r = await sendSummary({ subject: 'с', text: 'т', failPath, transport: DEAD_TRANSPORT });
expect(r.ok).toBe(false);
const rows = readLines(failPath);
expect(rows.length).toBe(1);
expect(rows[0].error).toContain('SMTP лёг');
});
it('отправлялка не падает вместе с почтой — возвращает отказ, а не бросает', async () => {
await expect(sendSummary({ subject: 'с', text: 'т', failPath, transport: DEAD_TRANSPORT })).resolves.toBeTruthy();
});
it('записать свою неудачу НЕ вышло — отправлялка всё равно не бросает и говорит об этом', async () => {
// 🔴 Запись собственной неудачи — тоже запись на диск. Кончилось место или нет прав —
// и она падает; это ровно тот случай, ради которого затея и нужна: «кончилось бы место —
// никто не разбудил бы владельца». Подсовываем заведомо непишущийся путь: в него нельзя
// писать, потому что на его месте уже лежит КАТАЛОГ.
const badPath = join(dir, 'занято-каталогом');
mkdirSync(badPath, { recursive: true });
const r = await sendSummary({ subject: 'с', text: 'т', failPath: badPath, transport: DEAD_TRANSPORT });
expect(r.ok).toBe(false);
expect(r.error).toContain('SMTP лёг');
expect(r.unrecorded).toBeTruthy(); // сказано вслух, что неудачу записать не удалось
});
it('две неудачи подряд — две строки, а не одна затёртая', async () => {
await sendSummary({ subject: 'с', text: 'т', failPath, at: 100, transport: DEAD_TRANSPORT });
await sendSummary({ subject: 'с', text: 'т', failPath, at: 200, transport: DEAD_TRANSPORT });
expect(readLines(failPath).length).toBe(2);
});
});
describe('mailFailedSince', () => {
it('неудач не было — false', () => {
expect(mailFailedSince(failPath, 0)).toBe(false);
});
it('неудача после последней сводки — true', async () => {
await sendSummary({ subject: 'с', text: 'т', failPath, at: 100, transport: DEAD_TRANSPORT });
expect(mailFailedSince(failPath, 50)).toBe(true);
});
it('старая неудача до сводки не считается — она уже пережита', async () => {
await sendSummary({ subject: 'с', text: 'т', failPath, at: 10, transport: DEAD_TRANSPORT });
expect(mailFailedSince(failPath, 50)).toBe(false);
});
});
describe('recordOwnerAck — след действия владельца', () => {
it('список видов действий владельца ЗАКРЫТ и назван дословно', () => {
expect(OWNER_ACTION_KINDS).toEqual(['answer', 'explicit_ack', 'talked_to_assistant']);
});
it('явное слово владельца записывается отдельно от простого признака жизни', () => {
recordOwnerAck(repliesPath, { kind: 'explicit_ack', at: 5 });
expect(readLines(repliesPath)[0].kind).toBe('explicit_ack');
});
it('текст владельца сохраняется как есть', () => {
recordOwnerAck(repliesPath, { kind: 'talked_to_assistant', at: 6, text: 'видел сводку, продолжай' });
expect(readLines(repliesPath)[0].text).toBe('видел сводку, продолжай');
});
// 🆕 Долг перед куском 5 (проверки С12/П63): формат `owner-replies.jsonl` у ответа
// на вопрос несёт номер вопроса и работника, которому он задан. Поля необязательные —
// старые зовущие (простое «видел» без вопроса) их не подают и не ломаются.
it('номер вопроса и работник пишутся, когда их подали', () => {
recordOwnerAck(repliesPath, { kind: 'answer', at: 7, text: 'да', number: 3, worker_id: 'w-2' });
const row = readLines(repliesPath)[0];
expect(row.number).toBe(3);
expect(row.worker_id).toBe('w-2');
});
it('номер и работника не подали — в строке их нет, старый вызов не ломается', () => {
recordOwnerAck(repliesPath, { kind: 'explicit_ack', at: 8 });
const row = readLines(repliesPath)[0];
expect(row.number).toBeUndefined();
expect(row.worker_id).toBeUndefined();
});
});