135 lines
7.4 KiB
JavaScript
135 lines
7.4 KiB
JavaScript
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();
|
||
});
|
||
});
|