Files
portal/bots/yandex-creatives/test/runner.test.js
T
Дмитрий af12b1ceb4 fix реклама за показы: предел веса картинки проверен по-настоящему, обход модерации не срывается целиком, робот не несёт токен на чужой адрес
Мелочи приёмочного листа v12 §8. Каждая правка с тестом; где защита уже стояла
в коде — тест проверен вырезанием этой защиты.

Предел веса картинки. Две прежние проверки были пустышками: сравнивали константу
саму с собой и с тем же числом в ответе сервера. Вырезание правила max: оставляло
обе зелёными. Настоящий тест грузит перевес и ждёт отказа — это четвёртая найденная
пустышка за ветку.

Обход модерации. Объявление без статуса и причина отказа длиннее колонки роняли
запись в базу ВНЕ защиты, и обход обрывался на середине: остальные клиенты не узнавали,
приняли их рекламу или отклонили, а деньги за отклонённый набор не возвращались.
Запись ответа теперь под той же защитой, что и сеть; пустой статус не пишем вовсе,
причину храним обрезанной.

Робот. Адрес файла приходил в ответе сервера, а шли по нему со своим токеном без
всякой сверки. Теперь адрес обязан вести на портал. Папка снимков экрана росла
бесконечно, а на снимках видны логин и остаток счёта — старше двух недель убираются.

Ещё: порядок посредников служебного канала — токен раньше служебного соединения;
BannerGenerator больше не отдаёт молча файл тяжелее предела и берёт предел из общей
константы; нулевой номер креатива ловится намеренно, а не случайно нестрогим сравнением.

Портал 300/300, робот 60/60. Мест снятия заморозки денег по-прежнему четыре.

Не тронуто намеренно: цена за 1000 показов и бюджет приходят от клиента — но это
видимое поле мастера и принятое продуктовое решение, а не недосмотр. Решает владелец.
2026-07-28 07:21:02 +03:00

341 lines
16 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 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);
});
});