af12b1ceb4
Мелочи приёмочного листа v12 §8. Каждая правка с тестом; где защита уже стояла в коде — тест проверен вырезанием этой защиты. Предел веса картинки. Две прежние проверки были пустышками: сравнивали константу саму с собой и с тем же числом в ответе сервера. Вырезание правила max: оставляло обе зелёными. Настоящий тест грузит перевес и ждёт отказа — это четвёртая найденная пустышка за ветку. Обход модерации. Объявление без статуса и причина отказа длиннее колонки роняли запись в базу ВНЕ защиты, и обход обрывался на середине: остальные клиенты не узнавали, приняли их рекламу или отклонили, а деньги за отклонённый набор не возвращались. Запись ответа теперь под той же защитой, что и сеть; пустой статус не пишем вовсе, причину храним обрезанной. Робот. Адрес файла приходил в ответе сервера, а шли по нему со своим токеном без всякой сверки. Теперь адрес обязан вести на портал. Папка снимков экрана росла бесконечно, а на снимках видны логин и остаток счёта — старше двух недель убираются. Ещё: порядок посредников служебного канала — токен раньше служебного соединения; BannerGenerator больше не отдаёт молча файл тяжелее предела и берёт предел из общей константы; нулевой номер креатива ловится намеренно, а не случайно нестрогим сравнением. Портал 300/300, робот 60/60. Мест снятия заморозки денег по-прежнему четыре. Не тронуто намеренно: цена за 1000 показов и бюджет приходят от клиента — но это видимое поле мастера и принятое продуктовое решение, а не недосмотр. Решает владелец.
341 lines
16 KiB
JavaScript
341 lines
16 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync, mkdirSync, utimesSync, writeFileSync } from 'node:fs';
|
||
import { mkdtemp, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { runOnce } from '../src/runner.js';
|
||
|
||
const JOB = {
|
||
id: 7,
|
||
campaign_id: 42,
|
||
banners: [
|
||
{ banner_id: 1, width: 300, height: 250, file_url: 'https://lk.liderra.ru/f/1' },
|
||
{ banner_id: 2, width: 728, height: 90, file_url: 'https://lk.liderra.ru/f/2' },
|
||
],
|
||
};
|
||
|
||
async function withWorkDir(fn) {
|
||
const dir = await mkdtemp(join(tmpdir(), 'yc-runner-'));
|
||
try {
|
||
return await fn(dir);
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function stubs({ job = null, uploadThrows = null, uploadResult = { modalClosed: true, cabinetSaid: '' }, loggedIn = true } = {}) {
|
||
const sent = [];
|
||
const reports = [];
|
||
const downloaded = [];
|
||
const uploads = [];
|
||
let opened = 0;
|
||
let closed = 0;
|
||
|
||
return {
|
||
sent,
|
||
reports,
|
||
downloaded,
|
||
uploads,
|
||
opened: () => opened,
|
||
closed: () => closed,
|
||
portal: {
|
||
takeJob: async () => job,
|
||
downloadBanner: async (banner, path) => { downloaded.push(path); return path; },
|
||
reportDone: async (id) => reports.push({ ok: true, id }),
|
||
reportFailure: async (id, reason) => reports.push({ ok: false, id, reason }),
|
||
},
|
||
browser: {
|
||
open: async () => {
|
||
opened += 1;
|
||
|
||
return { context: { close: async () => { closed += 1; } }, page: { screenshot: async () => {} } };
|
||
},
|
||
isLoggedIn: async () => loggedIn,
|
||
uploadCreatives: async (page, config, files) => {
|
||
uploads.push(files);
|
||
if (uploadThrows) throw new Error(uploadThrows);
|
||
|
||
return uploadResult;
|
||
},
|
||
},
|
||
mailer: {
|
||
alarm: async (m) => sent.push({ kind: 'alarm', ...m }),
|
||
report: async (m) => sent.push({ kind: 'report', ...m }),
|
||
},
|
||
};
|
||
}
|
||
|
||
const opts = (workDir) => ({ timestamp: 't', workDir });
|
||
|
||
// Без пауз: в проверках ждать нечего, а повтор доклада порталу иначе тянул бы секунды.
|
||
const FAST_CONFIG = { humanDelayMs: 0, reportRetryDelayMs: 0 };
|
||
|
||
test('без работы ничего не делает и браузер не открывает', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: null });
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.idle, true);
|
||
assert.equal(s.opened(), 0);
|
||
assert.equal(s.reports.length, 0);
|
||
});
|
||
});
|
||
|
||
test('успешная загрузка отчитывается «готово» и шлёт письмо', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB });
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, true);
|
||
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
|
||
assert.equal(s.sent[0].kind, 'report');
|
||
assert.equal(s.sent[0].count, 2);
|
||
});
|
||
});
|
||
|
||
test('в кабинет уходят ПУТИ к файлам, а не описания баннеров', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB });
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
const given = s.uploads[0];
|
||
assert.equal(given.length, 2);
|
||
assert.ok(given.every((f) => typeof f === 'string'), `в кабинет ушло не то: ${JSON.stringify(given)}`);
|
||
assert.deepEqual(given, s.downloaded);
|
||
assert.ok(given[0].includes('300x250'));
|
||
});
|
||
});
|
||
|
||
test('файлы клиента живут в переданной рабочей папке', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB });
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.ok(s.downloaded.every((p) => p.startsWith(workDir)), `файлы легли мимо: ${s.downloaded[0]}`);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 🪤 Прежний тест на это был ПУСТЫШКОЙ: он всегда передавал рабочую папку явно, поэтому
|
||
* значение по умолчанию не проверялось вообще. Проверено вырезанием: подмена умолчания
|
||
* на `process.cwd()` оставляла все тесты зелёными.
|
||
*
|
||
* А умолчание тут и есть вся суть: робота запускает расписание, каталог запуска у него
|
||
* может быть любым. Считай мы папки от него — картинки клиента и снимки экрана
|
||
* с логином и остатком счёта раскидывало бы по случайным местам сервера.
|
||
*
|
||
* Поэтому запускаем ИЗ ЧУЖОГО каталога и без явной рабочей папки.
|
||
*/
|
||
test('без явной рабочей папки файлы всё равно ложатся к роботу, а не в каталог запуска', async () => {
|
||
const alien = await mkdtemp(join(tmpdir(), 'yc-alien-'));
|
||
const wasCwd = process.cwd();
|
||
const robotRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const s = stubs({ job: JOB });
|
||
|
||
try {
|
||
process.chdir(alien);
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, { timestamp: 't' });
|
||
|
||
assert.ok(s.downloaded.length > 0, 'файлы должны были скачаться');
|
||
assert.ok(
|
||
s.downloaded.every((p) => p.startsWith(robotRoot)),
|
||
`файлы легли мимо папки робота: ${s.downloaded[0]}`,
|
||
);
|
||
assert.ok(
|
||
s.downloaded.every((p) => !p.startsWith(alien)),
|
||
`файлы легли в каталог запуска: ${s.downloaded[0]}`,
|
||
);
|
||
} finally {
|
||
process.chdir(wasCwd);
|
||
// Робот чистит downloads сам, а вот папку снимков экрана он создаёт и оставляет.
|
||
await rm(join(robotRoot, 'downloads'), { recursive: true, force: true });
|
||
await rm(join(robotRoot, 'screenshots'), { recursive: true, force: true });
|
||
await rm(alien, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Снимки экрана копились навсегда. Это не просто мусор на диске: на снимке видна боковая
|
||
* панель кабинета — логин и остаток счёта. Чем дольше они лежат, тем больше на сервере
|
||
* копится того, что там лежать не должно. Свежие нужны — по ним разбирают вчерашний сбой,
|
||
* старые не нужны никому.
|
||
*/
|
||
test('старые снимки экрана убираются, свежие остаются', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const shots = join(workDir, 'screenshots');
|
||
mkdirSync(shots, { recursive: true });
|
||
|
||
const old = join(shots, 'job-1-старый.png');
|
||
const fresh = join(shots, 'job-2-свежий.png');
|
||
writeFileSync(old, 'снимок');
|
||
writeFileSync(fresh, 'снимок');
|
||
|
||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||
utimesSync(old, monthAgo, monthAgo);
|
||
|
||
const s = stubs({ job: JOB });
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(existsSync(old), false, 'месячной давности снимок должен был уйти');
|
||
assert.equal(existsSync(fresh), true, 'свежий снимок нужен для разбора сбоя');
|
||
});
|
||
});
|
||
|
||
test('файлы клиента удаляются при любом исходе — это чужие картинки, копиться им нельзя', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(existsSync(join(workDir, 'downloads', 'job-7')), false);
|
||
assert.equal(s.closed(), 1, 'браузер должен закрыться даже при сбое');
|
||
});
|
||
});
|
||
|
||
test('сбой загрузки отчитывается сбоем и шлёт алярм с причиной', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' });
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, false);
|
||
assert.equal(s.reports[0].ok, false);
|
||
assert.match(s.reports[0].reason, /кнопка не найдена/);
|
||
assert.match(s.sent[0].reason, /кнопка не найдена/);
|
||
});
|
||
});
|
||
|
||
test('слетевший вход не пытается грузить и отчитывается сбоем', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, loggedIn: false });
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, false);
|
||
assert.equal(s.uploads.length, 0);
|
||
assert.match(s.reports[0].reason, /вход/i);
|
||
});
|
||
});
|
||
|
||
test('окно не закрылось — докладываем «готово» порталу, но письмом зовём человека', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
// Приговор выносит портал по слепку креативов, а не робот по экрану: окно может
|
||
// остаться открытым и после удачной загрузки. Но человек должен это увидеть.
|
||
const s = stubs({ job: JOB, uploadResult: { modalClosed: false, cabinetSaid: 'Файл слишком большой' } });
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, true);
|
||
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
|
||
assert.equal(s.sent.some((m) => m.kind === 'alarm' && /Файл слишком большой/.test(m.reason)), true);
|
||
});
|
||
});
|
||
|
||
test('обрыв на докладе «готово» НЕ превращается в отчёт о сбое — креативы уже в кабинете', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
// Приговор об успехе выносит портал по слепку креативов. Если доклад «готово» не дошёл
|
||
// (502 от nginx, обрыв, таймаут), робот раньше падал в общий обработчик сбоя и слал
|
||
// «сбой» — на СВОЮ УДАЧНУЮ работу. Портал помечал задание сбойным, а креативы уже лежали
|
||
// в кабинете. Хуже того: если портал успел принять «готово», а ответ потерялся, «сбой»
|
||
// затирал правильный результат с уже проставленными номерами.
|
||
const s = stubs({ job: JOB });
|
||
s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); };
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(
|
||
s.reports.filter((r) => r.ok === false).length,
|
||
0,
|
||
`доложен сбой об удачной работе: ${JSON.stringify(s.reports)}`,
|
||
);
|
||
const alarm = s.sent.find((m) => m.kind === 'alarm');
|
||
assert.ok(alarm, 'человека надо позвать письмом');
|
||
assert.match(alarm.reason, /502/);
|
||
assert.equal(res.ok, true, 'работа сделана: креативы в кабинете');
|
||
});
|
||
});
|
||
|
||
test('доклад «готово» повторяется, если с первого раза не прошёл', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB });
|
||
let tries = 0;
|
||
s.portal.reportDone = async (id) => {
|
||
tries += 1;
|
||
if (tries < 3) throw new Error('обрыв связи');
|
||
s.reports.push({ ok: true, id });
|
||
};
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(tries, 3, `доклад пробовали ${tries} раз(а)`);
|
||
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
|
||
assert.equal(res.ok, true);
|
||
});
|
||
});
|
||
|
||
// Письмо обязано честно сказать, что в кабинете уже лежат креативы. Пока оно было одно
|
||
// на все случаи и всегда утверждало «робот ничего не менял», человек читал письмо
|
||
// «окно загрузки не закрылось» и в кабинет не шёл — а идти надо: повторная заливка
|
||
// оставит дубли, вычистить которые можно только руками.
|
||
test('письма после заливки помечены «креативы уже в кабинете»', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, uploadResult: { modalClosed: false, cabinetSaid: 'что-то не так' } });
|
||
s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); };
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
const alarms = s.sent.filter((m) => m.kind === 'alarm');
|
||
assert.ok(alarms.length > 0, 'человека надо позвать письмом');
|
||
assert.ok(alarms.every((m) => m.uploaded === true), 'письма после заливки обязаны быть помечены');
|
||
});
|
||
});
|
||
|
||
test('письмо о сбое ДО заливки не помечено «креативы уже в кабинете»', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
|
||
|
||
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
const alarm = s.sent.find((m) => m.kind === 'alarm');
|
||
assert.ok(alarm);
|
||
assert.notEqual(alarm.uploaded, true, 'работа не сделана — врать в обратную сторону тоже нельзя');
|
||
});
|
||
});
|
||
|
||
test('портал не принял отчёт о сбое — человек узнаёт об этом из письма', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
// Раньше провал отчёта глотался молча. А это самый опасный исход: задание остаётся
|
||
// «в работе» навсегда, очередь встаёт колом для ВСЕХ клиентов, и никто об этом
|
||
// не знает — в письме написана только исходная причина сбоя.
|
||
const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' });
|
||
s.portal.reportFailure = async () => { throw new Error('Портал ответил 422 на /done'); };
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, false);
|
||
const alarm = s.sent.find((m) => m.kind === 'alarm');
|
||
assert.ok(alarm, 'письмо-алярм должно уйти');
|
||
assert.match(alarm.reason, /кнопка не найдена/, 'исходная причина должна остаться');
|
||
assert.match(alarm.reason, /422/, 'провал отчёта порталу должен быть виден человеку');
|
||
});
|
||
});
|
||
|
||
test('письмо не ушло — задание всё равно закрыто, робот не зависает', async () => {
|
||
await withWorkDir(async (workDir) => {
|
||
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
|
||
s.mailer.alarm = async () => { throw new Error('почта недоступна'); };
|
||
|
||
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
|
||
|
||
assert.equal(res.ok, false);
|
||
assert.equal(s.reports[0].ok, false);
|
||
});
|
||
});
|