editorial-2018-11-practice-php-integration-tests;
+- editorial-2018-11-mechanism-php-integration-tests;
+- editorial-2018-11-field-php-integration-tests.
+
+Артефакты П10:
+
+- web/scripts/upgrade-2018-11.mjs;
+- web/public/assets/editorial/2018/php-integration-contract-2018.svg;
+- web/public/assets/editorial/2018/php-test-boundary-2018.svg;
+- web/public/assets/editorial/2018/php-false-green-trace-2018.svg;
+- этот файл.
+
+Сам скрипт П10 не меняет web/data/articles.json: публикационный
+слой подключает его отдельным registry только по slug. В общем checkout во
+время работы могли появляться параллельные артефакты других пакетов; они не
+входят в этот список и П10 их не редактирует. Дата ревью: 31 июля 2026 года.
+
+## 1. Факты и техника — пройдено
+
+### Карта утверждений и первичных источников
+
+| Утверждение | Первичный источник | Оговорка в материале |
+| --- | --- | --- |
+| PHPUnit 7 запускает классы, наследующие PHPUnit\Framework\TestCase, и тесты из файлов *Test.php | [Getting Started with PHPUnit 7](https://phpunit.de/getting-started/phpunit-7.html) | Пример помечен как PHP 7.2 / PHPUnit 7.5; версия закрепляется проектом, а не статьёй |
+| setUp() и tearDown() создают и очищают fixture вокруг теста | [PHPUnit 7.5 Fixtures](https://docs.phpunit.de/en/7.5/fixtures.html) | Статья не выдаёт fixture за изоляцию всей системы: отдельно названы вторые соединения и HTTP |
+| Mock/test double позволяет контролировать соседа класса, но не является доказательством SQL, PDO или сети | [PHPUnit 7.5 Test Doubles](https://docs.phpunit.de/en/7.5/test-doubles.html) | Unit- и integration-тесты отвечают на разные вопросы и оба остаются нужны |
+| PDO::beginTransaction() выключает autocommit; rollBack() откатывает изменения и возвращает его, но MySQL может сделать неявный commit для DDL | [PDO::beginTransaction](https://www.php.net/manual/en/pdo.begintransaction.php), [PDO::rollBack](https://www.php.net/manual/en/pdo.rollback.php) | Миграции и DDL не запускаются внутри транзакции теста; показан только путь данных |
+| getenv() читает конфигурацию, а curl_exec() и curl_getinfo() позволяют различить transport error и HTTP-статус | [getenv](https://www.php.net/manual/en/function.getenv.php), [curl_exec](https://www.php.net/manual/en/function.curl-exec.php), [curl_getinfo](https://www.php.net/manual/en/function.curl-getinfo.php) | Пример допускает только локальный учебный callback; внешние URL, production-секреты и результаты вызова не выдумываются |
+
+### Технический проход
+
+- Во всех трёх ревизиях ровно один главный вопрос: контракт PDO-репозитория; граница unit/integration; фальшивая зелень в связке БД/HTTP/конфигурации.
+- Код вручную сверён с синтаксисом PHP 7.2: типы параметров и возврата, array(), методы PHPUnit 7; в тексте нет claim о запущенном контейнере, рабочем пароле или зелёном результате PHP-теста.
+- В коде HTTP учебный endpoint ограничен локальным адресом. Это не выдано за универсальную политику безопасности: для тестовой сети отдельно названы allowlist и отдельные credentials.
+- SQL, окружение, PDO и cURL не смешиваются в один диагноз. Каждой точке соответствует наблюдаемый факт и следующее действие.
+- DDL, второй PDO-коннект и HTTP не объявлены автоматически откатываемыми. Граница уборки показана явно.
+
+## 2. Редактура и голос М1 — пройдено
+
+| Ревизия | Симптом и цена в первых двух абзацах | Один вопрос | Артефакт | Голос и ограничение |
+| --- | --- | --- | --- | --- |
+| Практика | Unit зелёный, но запись/чтение ломается; цена — неделя правок не той логики | Какой договор должен проверить PDO integration test | Тест INSERT → SELECT → rollback, таблица контракта, схема | Короткие шаги 2018 года; тестовая БД и контейнер не выдаются за запущенные |
+| Механизм | Mock зелёный, но SQL/конфигурация не работают; цена — ложная уверенность при рефакторинге | Где граница unit и integration | Соседние unit- и integration-примеры, таблица вопросов | Нет поздних SLO/observability-терминов; история ограничена PHP 7.2/PHPUnit 7.5 |
+| Поле | Регистрация 500 после записи или не уведомляет; цена — потеря следа и подмена реального клиента | Как увидеть разрыв БД/HTTP/config | Локальный callback, request ID, cURL-адаптер, трассировочная таблица | Учебная трасса не названа инцидентом; production endpoint запрещён |
+
+- М1 сохранён: текст идёт по формуле «симптом → причина → проверка → действие → результат → ограничение», использует короткие предметные абзацы и допускает «разберём», «сначала проверяем» только рядом с конкретным действием.
+- Не используются вводные о «современном мире», обещания универсального решения, SLO, Kubernetes, feature flags, продуктовые метрики или нераскрытый зрелый жаргон.
+- В каждой статье есть не менее пяти смысловых разделов, таблица с thead и scope, код, упорядоченный маршрут, один собственный SVG с содержательным alt/figcaption, ограничения и точный заголовок <h2>Проверяемые источники</h2>.
+
+## 3. Визуал и выпуск — пройдено после локальных проверок
+
+- php-integration-contract-2018.svg объясняет узкий контракт PHPUnit → PDO → test DB → rollback; данные и DDL не показаны как часть одного обратимого действия.
+- php-test-boundary-2018.svg контрастно разделяет подменённый репозиторий в unit-тесте и настоящий PDO/cURL-адаптер в integration-тесте. Пунктир отмечает именно границу подмены.
+- php-false-green-trace-2018.svg показывает порядок config → PDO → cURL → 202 и нижний integration-маршрут с request ID; смысл не зависит только от цвета.
+- Все SVG имеют собственные title/desc, вертикальный viewBox 720 × 900, контрастный текст и без скриптов, внешних ресурсов или raster-вложений.
+
+### Фактически выполненные команды и доказательства
+
+Выполнены следующие проверки:
+
+ node --check web/scripts/upgrade-2018-11.mjs
+ node web/scripts/upgrade-2018-11.mjs --print-revisions | node --check завершился с кодом 0.
+- Прямой --print-revisions выдал валидный JSON ровно с тремя slug: practice, mechanism и field П10. Import модуля не печатал побочных данных и вернул revisions.length === 3.
+- xmllint принял все три SVG. Проверка пяти файлов не нашла строк с хвостовыми пробелами.
+- Структурный аудит прошёл: практика — 8 753 знака основного тела и 7 541 знак без кода/источников; механизм — 9 044 и 7 578; поле — 10 956 и 7 900. У каждой ревизии 9 h2, одна схема, таблица, код, упорядоченный маршрут, два или более источника и явный симптом в первых 800 знаках.
+- SVG открыты через локальную статическую выдачу на viewport 375 × 812. Для каждого clientWidth === scrollWidth === 375; после переделки вертикальная компоновка сохранила читаемые подписи. DOM каждой схемы содержит SVG, но не содержит script или inline event handler.
+- В окружении нет PHP CLI: php --version вернул command not found. Поэтому PHP-фрагменты прошли фактологический и ручной синтаксический проход, но не выдаются за запущенные тесты. Для реального прогона нужны отдельные test-only DSN и локальный callback.
+
+После интеграции в registry строгий аудит опубликованного слоя прошёл для
+всех трёх slug. Он подтвердил сохранность date/author базового архива,
+диапазон 8 753 / 9 044 / 10 956 знаков тела, обязательные figure, таблицы,
+код, маршруты и источники. `npm run build` завершился с кодом 0 и
+сгенерировал 374 статические страницы.
+
+## 4. Дополнительный выпускной проход — пройдено
+
+Независимый draft-gate выявил, что автономные ревизии не должны переопределять
+date и author: эти поля принадлежат базовому архиву и
+защищают хронологию и авторство публикации. Из всех трёх exported objects эти
+ключи удалены. Сохранены только поля ревизии: slug,
+title, categories, cover,
+excerpt, contentHtml и readingMinutes.
+
+Повторные результаты:
+
+- npm run audit:draft -- scripts/upgrade-2018-11.mjs завершился с
+ кодом 0: 8 753 / 9 044 / 10 956 знаков тела и три PASS.
+- node --check scripts/upgrade-2018-11.mjs завершился с кодом 0.
+- xmllint --noout принял все три SVG.
+
+npm вывел только не блокирующие предупреждения о пользовательских
+настройках store-dir, cache-dir и
+public-hoist-pattern; сам draft-gate прошёл.
+
+## 5. Выпусковой вердикт
+
+Пакет принят к публикации. У связанного файла ревизий нет права менять
+date или author; registry накладывает только
+редакционные поля по стабильному slug. PHP-примеры не выдаются за реально
+запущенные тесты: ограничение окружения и требование отдельного test-only DSN
+остаются явно зафиксированными выше.
diff --git a/editorial/reviews/2018-12-draft.md b/editorial/reviews/2018-12-draft.md
new file mode 100644
index 0000000..822d513
--- /dev/null
+++ b/editorial/reviews/2018-12-draft.md
@@ -0,0 +1,77 @@
+# Декабрь 2018 — тройное ревью и выпускной gate «Рефакторинг Bitrix без большого переписывания»
+
+Статус: **принят в публикационный слой 31 июля 2026 года**. Пакет П11
+сохраняет стабильные slug, дату и автора базового архива:
+
+- editorial-2018-12-practice-legacy-refactoring;
+- editorial-2018-12-mechanism-legacy-refactoring;
+- editorial-2018-12-field-legacy-refactoring.
+
+Созданы только:
+
+- web/scripts/upgrade-2018-12.mjs;
+- web/public/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg;
+- web/public/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg;
+- web/public/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg;
+- этот файл.
+
+Сам П11 не меняет web/data/articles.json: публикационный слой
+подключает его отдельным registry только по slug. В рабочем дереве могут
+находиться независимые изменения других партий. Дата ревью: 31 июля 2026 года.
+
+## Проход 1. Факты и техника — пройдено
+
+| Утверждение или фрагмент | Первичный источник | Проверенная граница |
+| --- | --- | --- |
+| CModule::IncludeModule('iblock') возвращает булев результат подключения модуля | [CModule::IncludeModule](https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php) | Во всех примерах это явная проверка перед использованием legacy API; она не выдаётся за настройку прав или окружения |
+| CIBlockElement::GetList возвращает выборку по фильтру и допускает выбор полей ID, IBLOCK_ID, CODE | [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php) | Повторная выборка проверяет один элемент по ID, а не все ссылки каталога или импорт |
+| CIBlockElement::Update возвращает true/false; текст ошибки доступен через LAST_ERROR | [CIBlockElement::Update](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y) | В примерах передаётся только CODE или NAME; ни один текст не обещает атомарность с HTML, кешем или интеграцией |
+| Обработчик OnBeforeIBlockElementUpdate запускается до обновления и может изменить поля либо отменить действие | [OnBeforeIBlockElementUpdate](https://dev.1c-bitrix.ru/api_help/iblock/events/onbeforeiblockelementupdate.php) | Статьи требуют проверить зарегистрированные обработчики на конкретном контуре, а не объявляют результат Update окончательным состоянием всего сайта |
+| CUtil::translit принимает язык и параметры регистра, замен и длины | [CUtil::translit](https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php) | Параметры приведены как учебные. Уникальность, старые URL и SEO-правила оставлены за пределами примера |
+| Исключение прекращает обычный путь функции до обработчика формы | [PHP Manual — Exceptions](https://www.php.net/exceptions) | Исключение используется только для разделения ошибки входа/Bitrix API и сообщения формы; оно не названо транзакцией нескольких систем |
+
+- Проверены три разных вопроса: защищённый шов одного Update; причина опасного смешения формы, инфоблока и следующего эффекта; контролируемая замена одного legacy-участка с отдельным откатом маршрута и данных.
+- PHP-фрагменты написаны в синтаксисе, доступном PHP 7.2. Они являются учебными и воспроизводимыми при наличии Bitrix-окружения, но не выдаются за выполненные в этом workspace: здесь нет ядра Bitrix, test-only данных и доступа к конкретному проекту.
+- Удалён не относящийся к тексту источник filter_var; в финальном списке остались только источники, на которые опирается соответствующий материал.
+- Массив PROPERTY_VALUES в примеры не добавлен: документация Update требует полного набора свойств при его передаче, а пакет намеренно демонстрирует изменение одного поля.
+- Модуль экспортирует ровно три ревизии без файлового ввода-вывода. CLI печатает только JSON при --print-revisions, поэтому импорт скрипта безопасен для будущей интеграции.
+
+## Проход 2. Редактура и голос М1 — пройдено
+
+| Ревизия | Симптом и цена в первых двух абзацах | Один главный вопрос | Артефакт и ограничение |
+| --- | --- | --- | --- |
+| Практика | После правки карточка теряет привычный URL; цена — 404 и неясный след для шаблона/выгрузки | Как вынести защищённый шов вокруг изменения CODE | ProductCodeWriter, таблица контракта и повторная выборка; не обещаются уникальность, SEO и безопасность всех связей |
+| Механизм | Форма показывает ошибку после уже выполненной записи; цена — повтор побочного эффекта и потеря причины | Почему смешение POST, API, HTML и интеграции опасно | Упрощённый save.php, отделённая функция записи и таблица следов; не заявляется транзакция между системами |
+| Поле | Вызовы формирования CODE разбросаны; цена — сломанный URL и риск затереть чужое изменение откатом | Как заменить один legacy-участок с проверкой и откатом | Снимок до изменения, явный маршрут writer и сравнение после записи; один элемент не выдаётся за миграцию импорта |
+
+- Автоматический аудит зафиксировал основной объём: **8 837**, **9 492** и **9 445** знаков. Все значения находятся в диапазоне 5 000–15 000; тема П11 также выдерживает плановые 7–9 тыс. близко к середине диапазона без искусственного наполнения.
+- В каждой статье есть не менее пяти смысловых h2, таблица с thead и scope, собственный рисунок с содержательными alt/figcaption, код, упорядоченный маршрут, ограничения и точный заголовок <h2>Проверяемые источники</h2>.
+- Тон сохранён для М1 / 2018: короткая цепочка «симптом → причина → проверка → действие», локальные PHP/Bitrix-термины, осторожные выводы. Нет SLO, Kubernetes, feature flags, продуктовых метрик, обещаний «переписать всё» и выдуманных результатов запуска.
+- Мобильный проход нашёл слишком мелкие подписи в исходных SVG. Подписи были сокращены до действий и увеличены; развёрнутое объяснение осталось в figcaption, где оно читается без масштабирования схемы.
+
+## Проход 3. Визуал и выпуск — пройдено после исправления подписей
+
+- bitrix-legacy-safe-seam-2018.svg объясняет границу формы, writer, Update(CODE) и повторной выборки; шаблон и интеграция явно остаются снаружи.
+- bitrix-mixed-responsibility-2018.svg показывает четыре разных следа save.php и то, что поздний сбой не отменяет раннюю запись автоматически.
+- bitrix-legacy-replacement-rollback-2018.svg разделяет откат маршрута и восстановление поля, поэтому диаграмма не предлагает опасное автоматическое перезаписывание данных.
+- SVG открыты в браузере на ширине 1 280px и 375px: у всех трёх есть title, desc, role="img", корректный viewBox и нет горизонтального переполнения. Дополнительно отрендерены в PNG на ширине 375px; после исправления видны заголовки, действия и предупреждение, без обрезанных строк.
+- У всех рисунков валидный XML, нет JavaScript, внешних ресурсов и растровых вложений.
+
+### Выполненные проверки
+
+```text
+node --check web/scripts/upgrade-2018-12.mjs
+node --input-type=module -e "import('./web/scripts/upgrade-2018-12.mjs') ..."
+cd web && npm run audit:draft -- scripts/upgrade-2018-12.mjs
+xmllint --noout web/public/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg \
+ web/public/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg \
+ web/public/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg
+```
+
+После интеграции основной редактор повторно выполнил strict audit: все три
+slug прошли требования к структуре, визуалу, таблицам, коду, источникам и
+объёму 8 837 / 9 492 / 9 445 знаков тела. npm run build
+завершился с кодом 0 и сгенерировал 374 статические страницы.
+
+Выпусковой вердикт: **принят к публикации**. articles.json не
+менялся; registry накладывает только редакционные поля по стабильному slug.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index ce5e773..4977e91 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -4,6 +4,9 @@ import { revisions as june2018Revisions } from '../scripts/upgrade-2018-06.mjs';
import { revisions as july2018Revisions } from '../scripts/upgrade-2018-07.mjs';
import { revisions as august2018Revisions } from '../scripts/upgrade-2018-08.mjs';
import { revisions as september2018Revisions } from '../scripts/upgrade-2018-09.mjs';
+import { revisions as october2018January2019Revisions } from '../scripts/upgrade-2018-10-2019-01.mjs';
+import { revisions as november2018Revisions } from '../scripts/upgrade-2018-11.mjs';
+import { revisions as december2018Revisions } from '../scripts/upgrade-2018-12.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -13,4 +16,7 @@ export const editorialRevisions = [
...july2018Revisions,
...august2018Revisions,
...september2018Revisions,
+ ...october2018January2019Revisions,
+ ...november2018Revisions,
+ ...december2018Revisions,
];
diff --git a/web/public/assets/editorial/2018/bitrix-file-state-ownership-2018.svg b/web/public/assets/editorial/2018/bitrix-file-state-ownership-2018.svg
new file mode 100644
index 0000000..138b75d
--- /dev/null
+++ b/web/public/assets/editorial/2018/bitrix-file-state-ownership-2018.svg
@@ -0,0 +1,64 @@
+
diff --git a/web/public/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg b/web/public/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg
new file mode 100644
index 0000000..82b1033
--- /dev/null
+++ b/web/public/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg
@@ -0,0 +1,35 @@
+
diff --git a/web/public/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg b/web/public/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg
new file mode 100644
index 0000000..91bb7ff
--- /dev/null
+++ b/web/public/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg
@@ -0,0 +1,37 @@
+
diff --git a/web/public/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg b/web/public/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg
new file mode 100644
index 0000000..82a7eef
--- /dev/null
+++ b/web/public/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg
@@ -0,0 +1,38 @@
+
diff --git a/web/public/assets/editorial/2018/legacy-photo-form-contract-2018.svg b/web/public/assets/editorial/2018/legacy-photo-form-contract-2018.svg
new file mode 100644
index 0000000..f3ccccd
--- /dev/null
+++ b/web/public/assets/editorial/2018/legacy-photo-form-contract-2018.svg
@@ -0,0 +1,56 @@
+
diff --git a/web/public/assets/editorial/2018/php-false-green-trace-2018.svg b/web/public/assets/editorial/2018/php-false-green-trace-2018.svg
new file mode 100644
index 0000000..c76729b
--- /dev/null
+++ b/web/public/assets/editorial/2018/php-false-green-trace-2018.svg
@@ -0,0 +1,34 @@
+
diff --git a/web/public/assets/editorial/2018/php-integration-contract-2018.svg b/web/public/assets/editorial/2018/php-integration-contract-2018.svg
new file mode 100644
index 0000000..8f3eae9
--- /dev/null
+++ b/web/public/assets/editorial/2018/php-integration-contract-2018.svg
@@ -0,0 +1,27 @@
+
diff --git a/web/public/assets/editorial/2018/php-test-boundary-2018.svg b/web/public/assets/editorial/2018/php-test-boundary-2018.svg
new file mode 100644
index 0000000..ce5d8ac
--- /dev/null
+++ b/web/public/assets/editorial/2018/php-test-boundary-2018.svg
@@ -0,0 +1,38 @@
+
diff --git a/web/public/assets/editorial/2019/webpack-jquery-order-2019.svg b/web/public/assets/editorial/2019/webpack-jquery-order-2019.svg
new file mode 100644
index 0000000..b963a17
--- /dev/null
+++ b/web/public/assets/editorial/2019/webpack-jquery-order-2019.svg
@@ -0,0 +1,58 @@
+
diff --git a/web/scripts/audit-editorial-draft.mjs b/web/scripts/audit-editorial-draft.mjs
index e428305..d3f929b 100644
--- a/web/scripts/audit-editorial-draft.mjs
+++ b/web/scripts/audit-editorial-draft.mjs
@@ -6,10 +6,15 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const execFileAsync = promisify(execFile);
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
-const scriptArgument = process.argv[2];
+const argumentsAfterCommand = process.argv.slice(2);
+const scriptArgument = argumentsAfterCommand.find((argument) => !argument.startsWith('--'));
+const expectedCountArgument = argumentsAfterCommand.find((argument) => argument.startsWith('--expected-count='));
+const expectedCount = expectedCountArgument
+ ? Number(expectedCountArgument.slice('--expected-count='.length))
+ : 3;
-if (!scriptArgument) {
- throw new Error('Usage: node scripts/audit-editorial-draft.mjs ' + text + '
'; +} + +function heading(text) { + return '' + escapeHtml(String(code).trim()) + '';
+}
+
+function figure(src, alt, caption) {
+ return '| ' + header + ' | ').join(''); + const body = rows.map((row) => ( + '
|---|
| ' + cell + ' | ').join('') + '
PHP → PDO → тестовая БД → PDO → PHP, его нужно пройти настоящим адаптером.'),
+ paragraph('Контракт здесь короткий: при заданных данных репозиторий записывает ровно те поля, которые нужны сценарию; затем он читает ту же запись и возвращает ожидаемые значения. В него не надо включать весь сайт, почту и внешний API. Чем уже граница, тем понятнее причина падения: конфигурация, соединение, SQL, схема или преобразование результата.'),
+ figure(
+ '/assets/editorial/2018/php-integration-contract-2018.svg',
+ 'Схема интеграционного контракта PHP-репозитория: PHPUnit передаёт учебный объект в PDO-репозиторий, тот пишет и читает тестовую БД в отдельном контейнере или стенде; после проверки транзакция откатывается.',
+ 'Тест доказывает не «работает весь сайт», а связку конфигурации, PDO, SQL и схемы для одного сценария записи и чтения.',
+ ),
+ heading('Сначала называю вход, выход и следы операции'),
+ paragraph('Перед кодом полезно записать контракт словами. Для примера возьмём таблицу customers с полями id, email и name. Вход — валидный адрес и имя. Выход — идентификатор, а после findById() тот же адрес и имя. След операции — одна строка в тестовой БД. Если вместо этого нужен уникальный индекс, нормализация регистра или часовой пояс, это уже отдельный проверяемый случай, а не скрытая деталь первого теста.'),
+ dataTable(
+ ['Часть контракта', 'Что задаём', 'Что проверяем', 'Что не доказывает тест'],
+ [
+ ['Конфигурация', 'TEST_DATABASE_DSN, пользователь, пароль вне репозитория', 'Подключение создаётся только к тестовой БД', 'Доступность production-БД или права боевого пользователя'],
+ ['Запись', 'Адрес anna@example.test и имя Анна', 'Метод вернул числовой ID и SQL принял значения', 'Работу формы, шаблона и браузера'],
+ ['Чтение', 'ID из той же операции', 'Поля не потерялись и не поменяли тип без причины', 'Все возможные выборки каталога'],
+ ['Очистка', 'Открытая транзакция на соединении теста', 'После теста данные не остаются в этой транзакции', 'Откат DDL или вызова внешнего HTTP-сервиса'],
+ ['Ошибка', 'Отсутствующий DSN или неверная схема', 'Падение объясняет границу, а не маскируется пустым массивом', 'Что ошибка автоматически исправится на стенде'],
+ ],
+ ),
+ heading('Тестовая конфигурация должна быть отдельной'),
+ paragraph('Подключение нельзя прятать в конструкторе репозитория под строкой mysql:host=localhost;dbname=site. В тесте это опасно: читатель не видит, к какой базе обратится команда, а случайно оставленный пароль легко попадёт в Git. Берём DSN и учётные данные из переменных с префиксом TEST_. Сам префикс не является защитой, поэтому ниже есть явная проверка имени базы и понятная остановка при пустом значении.'),
+ paragraph('Тестовая БД может жить в отдельном контейнере, локальном сервисе или выделенном стенде. Контракт от этого не меняется, но окружение должно быть изолировано от рабочих данных. В этой заметке не утверждается, что какой-либо контейнер был запущен: команда запуска и образ зависят от версии MySQL, драйвера PDO и правил проекта. Сначала проверяем адрес, затем разрешаем тесту открыть соединение.'),
+ codeBlock([
+ ' PDO::ERRMODE_EXCEPTION,',
+ ' PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,',
+ ' ));',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Проверка слова test — только страховка от очевидной опечатки, а не модель прав доступа. В реальном проекте надёжнее отдельный пользователь без доступа к production-схемам, отдельная сеть и имя БД из закрытой тестовой конфигурации. Если DSN пустой или выглядит сомнительно, лучше остановить запуск с ошибкой, чем заменить его значением по умолчанию.'),
+ heading('Пишу один путь через настоящий PDO-репозиторий'),
+ paragraph('Следующий фрагмент показывает форму теста, а не готовый слой доступа к данным для любого проекта. CustomerRepository здесь использует переданный PDO, поэтому тот же SQL увидят драйвер и тестовая схема. В setUp() создаётся фикстура и открывается транзакция; в tearDown() она откатывается даже после падения проверки. PHPUnit вызывает эти методы вокруг теста, но их конкретные сигнатуры стоит сверить с закреплённой версией фреймворка.'),
+ codeBlock([
+ 'pdo = TestPdo::fromEnvironment();',
+ ' $this->pdo->beginTransaction();',
+ ' }',
+ '',
+ ' protected function tearDown(): void',
+ ' {',
+ ' if ($this->pdo instanceof PDO && $this->pdo->inTransaction()) {',
+ ' $this->pdo->rollBack();',
+ ' }',
+ ' }',
+ '',
+ ' public function testStoresAndReadsCustomer(): void',
+ ' {',
+ ' $repository = new CustomerRepository($this->pdo);',
+ " $id = $repository->add('anna@example.test', 'Анна');",
+ '',
+ ' $stored = $repository->findById($id);',
+ '',
+ " $this->assertSame('anna@example.test', $stored['email']);",
+ ' $this->assertSame(\'Анна\', $stored[\'name\']);',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Важно, что тест не проверяет SQL строкой или моковым ожиданием. Он вызывает публичные методы репозитория, а доказательство получает после чтения обратно. Если в add() перепутан столбец, драйвер не принимает тип или findById() меняет имя ключа, зелёный результат невозможен при корректно настроенной тестовой схеме. Если же упало соединение, это тоже полезный сигнал: контракт конфигурации пока не выполнен.'),
+ heading('Транзакция чистит данные, но не отменяет всё'),
+ paragraph('PDO переводит соединение в режим транзакции после beginTransaction(); rollBack() возвращает изменения данных назад и включает autocommit. Это удобно для коротких тестов, которые делают INSERT, UPDATE и DELETE. Но MySQL может сделать неявный commit при DDL, например CREATE TABLE или DROP TABLE. Поэтому миграции и создание схемы не прячем внутрь этого теста: их выполняют отдельным подготовительным шагом.'),
+ paragraph('Ещё одна граница — несколько соединений. Откат одного PDO не очистит запись, сделанную вторым соединением, очередью или HTTP-клиентом. Если код открывает соединение сам, сначала передайте ему тестовую фабрику или выделите адаптер. Только после этого можно честно сказать, что тест контролирует следы операции.'),
+ heading('Порядок запуска без случайного доступа к данным'),
+ orderedList([
+ 'Создать отдельную схему и пользователя для тестов по правилам проекта; не копировать production DSN в команду PHPUnit.',
+ 'Применить к тестовой схеме заранее подготовленную миграцию и отдельно записать её версию.',
+ 'Перед запуском вывести только имя тестовой базы или иной безопасный идентификатор, не печатая пароль.',
+ 'Запустить один класс через ./vendor/bin/phpunit tests/Integration/CustomerRepositoryIntegrationTest.php после проверки версии PHPUnit.',
+ 'Если тест падает, сначала разделить ошибку подключения, SQL и ожидание результата; не заменять реальный репозиторий моком ради зелёного вывода.',
+ 'После прохождения добавить второй короткий тест лишь для следующего контракта, например уникальности, а не раздувать один сценарий до проверки всего приложения.',
+ ]),
+ heading('Когда этот тест не подходит'),
+ paragraph('Интеграционный тест репозитория не доказывает, что HTML-форма передала нужное поле, cron запущен, письмо доставлено или партнёрский API отвечает. Для каждого такого перехода нужна своя небольшая граница. Не стоит также запускать десятки одинаковых записей против общей БД параллельно без изоляции: тогда тест может падать от чужих данных, а не от кода.'),
+ paragraph('Если проект пока не имеет отдельной схемы, честный статус — «интеграционный тест отложен из-за отсутствия безопасной среды», а не mock, названный интеграцией. Сначала сделайте минимальную тестовую конфигурацию и один путь записи-чтения. После этого остальные адаптеры можно покрывать тем же спокойным правилом: настоящий ресурс, узкий контракт, наблюдаемый результат и явная уборка.'),
+ heading('Итог: проверяем не название теста, а путь данных'),
+ paragraph('Зелёный unit-тест полезен, но он не заменяет путь через PDO и схему. Для репозитория достаточно начать с одного контракта: безопасная тестовая конфигурация, настоящая запись, чтение тем же адаптером и контролируемая очистка. Когда этот маршрут падает, он показывает границу ошибки; когда проходит, он оставляет следующему разработчику воспроизводимый способ проверить ту же связку.'),
+ sourceList([sources.phpunit7, sources.fixtures, sources.pdoTransaction, sources.pdoRollback, sources.getenv]),
+ ].join('\n'),
+};
+
+const mechanismArticle = {
+ slug: 'editorial-2018-11-mechanism-php-integration-tests',
+ title: 'PHP. Unit и integration: где заканчивается mock и начинается настоящий запрос',
+ categories: ['PHP', 'Тестирование'],
+ cover: '/assets/editorial/2018/php-test-boundary-2018.svg',
+ excerpt: 'Разбираем границу между unit- и integration-тестом PHP: что дают mock-объекты, что они не могут проверить и как выбрать минимальный настоящий переход к БД или HTTP.',
+ readingMinutes: 12,
+ contentHtml: [
+ paragraph('Все unit-тесты сервиса зелёные, но первая реальная запись падает с ошибкой SQL или читает не то значение из конфигурации. Цена такой зелени — ложная уверенность при рефакторинге: mock подтвердил договор с самим тестом, а не с драйвером БД, схемой и внешним адресом.'),
+ paragraph('Один вопрос этой заметки: как провести границу между unit- и integration-тестом PHP, чтобы не назвать mock настоящей проверкой? В 2018 году достаточно простой модели. Сначала проверяем правило в классе без сети и БД, затем отдельно проводим один реальный переход через адаптер. Не надо заставлять каждый тест поднимать всё приложение.'),
+ heading('Слово «unit» описывает контролируемую границу'),
+ paragraph('Unit-тест оставляет под контролем сам класс и подменяет его соседей. Подстановка нужна не потому, что БД «плохая», а потому, что мы хотим быстро проверить одно правило: например, запрещает ли сервис дублирующий адрес до записи. В таком тесте объект-заглушка возвращает заранее выбранный ответ, а test case проверяет реакцию сервиса. Он не зависит от таблицы, сети и текущего значения переменной окружения.'),
+ paragraph('Integration-тест оставляет настоящий переход там, где важен договор двух частей. Для PHP-репозитория это драйвер PDO, запрос и тестовая схема. Для HTTP-клиента это формирование запроса, cURL и управляемый тестовый endpoint. Оба вида тестов могут вызывать один сервис, но отвечают на разные вопросы. Ошибка начинается, когда они получают одинаковое название и от одного ждут доказательства другого.'),
+ figure(
+ '/assets/editorial/2018/php-test-boundary-2018.svg',
+ 'Схема границы тестов PHP: unit-тест заменяет порт репозитория и проверяет решение сервиса; integration-тест оставляет реальный PDO-адаптер или HTTP-клиент и проверяет договор на границе процесса.',
+ 'Пунктир обозначает место подстановки. Всё, что осталось за ним, unit-тест не способен проверить независимо от числа ожиданий.',
+ ),
+ heading('Один сценарий можно разложить на два точных вопроса'),
+ paragraph('Представим регистрацию пользователя. Правило «не создавать запись для уже занятого email» удобно проверить unit-тестом: он получает подставной репозиторий, который сообщает, что адрес существует. Но сам SQL с WHERE email = ?, тип колонки и сопоставление строки с массивом PHP остаются за границей. Их должен покрыть отдельный integration-тест конкретного PDO-репозитория.'),
+ dataTable(
+ ['Вопрос', 'Unit-тест', 'Integration-тест', 'Признак лишней работы'],
+ [
+ ['Правило дубликата', 'Подставной репозиторий отвечает true', 'Не обязателен в каждом варианте правила', 'Поднимать БД, чтобы проверить одно условие if'],
+ ['SQL и имена столбцов', 'Не проверяет', 'Выполняет настоящий запрос в тестовой схеме', 'Сравнивать строку SQL с копией этой же строки в тесте'],
+ ['Тип результата PDO', 'Можно задать массив вручную', 'Показывает фактический FETCH_ASSOC и преобразование', 'Считать mock доказательством работы драйвера'],
+ ['HTTP-запрос', 'Проверяет, что клиент был вызван с нужными данными', 'Проверяет URL, код ответа и разбор ответа на локальном endpoint', 'Посылать тест в боевой API'],
+ ['Конфигурация', 'Передаёт строку явно в конструктор', 'Берёт test-only значение из окружения и проверяет отказ при его отсутствии', 'Использовать production значение по умолчанию'],
+ ],
+ ),
+ heading('Unit-тест: правило без настоящей БД'),
+ paragraph('Ниже минимальный пример на PHPUnit 7. Он не называет объект mock только ради модного слова: репозиторий подставлен, потому что тест проверяет решение RegistrationService до момента записи. Вход и ожидаемый отказ видны прямо в коде. Такой тест быстро падает, если автор случайно удалит проверку существующего адреса, и не требует доступной БД для каждого запуска.'),
+ codeBlock([
+ 'customers = $customers;',
+ ' }',
+ '',
+ ' public function register(string $email): void',
+ ' {',
+ ' if ($this->customers->existsByEmail($email)) {',
+ " throw new DomainException('Email is already registered');",
+ ' }',
+ ' }',
+ '}',
+ '',
+ 'final class RegistrationServiceTest extends TestCase',
+ '{',
+ ' public function testRejectsAnExistingEmail(): void',
+ ' {',
+ ' $customers = $this->createMock(CustomerLookup::class);',
+ " $customers->method('existsByEmail')->with('anna@example.test')->willReturn(true);",
+ '',
+ ' $service = new RegistrationService($customers);',
+ ' $this->expectException(DomainException::class);',
+ " $service->register('anna@example.test');",
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Этот код сознательно не содержит PDO, getenv() и URL. Если он зелёный, мы знаем ровно одно: при ответе true сервис бросает ожидаемое исключение. Мы не знаем, вернёт ли реальный запрос true, доступна ли нужная таблица и не передал ли bootstrap в репозиторий другой DSN. Чем точнее сформулирован вывод, тем меньше соблазн считать этот тест универсальной страховкой.'),
+ heading('Integration-тест: настоящий адаптер вместо предположения'),
+ paragraph('Чтобы проверить репозиторий, unit-тест выше не расширяют ожиданиями на SQL. Создают второй тест для PdoCustomerLookup. Он передаёт адаптеру PDO, подключённый только к тестовой схеме, кладёт известную строку в пределах транзакции и делает настоящий запрос. Ожидаемое значение выводится не из настройки mock-а, а из таблицы через тот же путь, по которому пойдёт приложение.'),
+ codeBlock([
+ 'pdo = TestPdo::fromEnvironment();',
+ ' $this->pdo->beginTransaction();',
+ " $this->pdo->prepare('INSERT INTO customers (email, name) VALUES (?, ?)')",
+ " ->execute(array('anna@example.test', 'Анна'));",
+ ' }',
+ '',
+ ' protected function tearDown(): void',
+ ' {',
+ ' if ($this->pdo->inTransaction()) {',
+ ' $this->pdo->rollBack();',
+ ' }',
+ ' }',
+ '',
+ ' public function testFindsExistingEmail(): void',
+ ' {',
+ ' $lookup = new PdoCustomerLookup($this->pdo);',
+ '',
+ " $this->assertTrue($lookup->existsByEmail('anna@example.test'));",
+ " $this->assertFalse($lookup->existsByEmail('missing@example.test'));",
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Здесь целевое поведение всё ещё небольшое: два адреса, один настоящий запрос, одна транзакция. Если в таблице вместо email теперь mail, тест покажет реальную ошибку. Если драйвер возвращает строку в неожиданной кодировке или DSN не открывается, это уже не «красный unit-тест», а след того, что договор адаптера или окружения изменился.'),
+ heading('Как появляются фальшиво-зелёные проверки'),
+ paragraph('Фальшивая зелень возникает не из-за самого mock-объекта. Она появляется, когда его результат становится единственным доказательством внешней границы. Test double заранее научен вернуть true, поэтому он никогда не увидит отсутствие миграции, ошибочный DNS, пустой TEST_CALLBACK_URL или код ответа 500. Такой объект нужен для unit-вопроса, но его нельзя использовать для ответа на другой вопрос.'),
+ dataTable(
+ ['Зелёный тест говорит', 'Чего он не видел', 'Минимальная настоящая проверка', 'Следующее действие'],
+ [
+ ['Сервис вызвал save()', 'SQL, транзакцию и индекс', 'Один INSERT и чтение через PDO в test DB', 'Добавить integration-тест адаптера'],
+ ['Клиент получил URL строкой', 'DNS, cURL, статус и тело ответа', 'Локальный HTTP endpoint с ожидаемым статусом', 'Проверить код и разбор ответа'],
+ ['Конструктор получил DSN', 'Как bootstrap прочёл окружение', 'Запуск с TEST_ переменными и отказ без них', 'Зафиксировать test-only конфигурацию'],
+ ['Mock вернул массив', 'Настоящий формат строки БД или JSON', 'Адаптер читает учебный ответ ресурса', 'Проверить преобразование на границе'],
+ ],
+ ),
+ heading('Выбираю границу по риску, а не по названию папки'),
+ paragraph('Папки tests/Unit и tests/Integration помогают ориентироваться, но не делают код правильным сами. Сначала называем побочный эффект: запись в БД, HTTP-вызов, файловая система, очередь или конфигурация. Затем оставляем реальным только один из них. Если тест одновременно поднимает БД, отправляет письмо и строит HTML, он слишком широкий для поиска причины. Если он заменяет все ресурсы, он не ловит ошибки склейки.'),
+ orderedList([
+ 'Выписать один симптом, который прошёл мимо unit-тестов: SQL, HTTP, конфигурация или преобразование данных.',
+ 'Назвать класс, который владеет границей, например PdoCustomerLookup или CallbackClient.',
+ 'Оставить настоящий только этот адаптер, а остальные соседние части заменить простыми контролируемыми объектами.',
+ 'Подготовить test-only ресурс: отдельную схему, локальный HTTP endpoint или временный каталог; production ресурс не использовать.',
+ 'Проверить положительный и один отрицательный путь, который показывает понятную ошибку границы.',
+ 'Оставить unit-тест правила рядом с integration-тестом адаптера: они дополняют, а не дублируют друг друга.',
+ ]),
+ heading('Версии и ограничения нельзя прятать'),
+ paragraph('Пример рассчитан на синтаксис PHP 7.2 и PHPUnit 7.5. Эти версии уже не поддерживаются на дату редакционного пересмотра, поэтому в новом проекте их не стоит выбирать по этой статье. Для исторического кода важно закрепить фактическую версию в composer.lock и сверить методы createMock(), setUp() и конфигурацию именно с ней.'),
+ paragraph('Интеграционный тест не заменяет ручную проверку прав production-пользователя и не даёт разрешения обращаться к внешнему партнёру из CI. Его задача скромнее: сделать конкретную техническую границу наблюдаемой в контролируемой среде. Когда эта среда отсутствует, это известное ограничение проекта, которое нужно исправить организационно, а не спрятать под зелёным mock-ом.'),
+ heading('Итог: два теста вместо одного громкого названия'),
+ paragraph('Unit-тест быстро подтверждает локальное правило. Integration-тест подтверждает договор с настоящим адаптером. Первый должен быть маленьким и не требовать БД, второй — узким и не уходить в production. Когда в отчёте видны оба пути, зелёный цвет перестаёт означать «мы надеемся» и начинает означать конкретно проверенный переход.'),
+ sourceList([sources.phpunit7, sources.fixtures, sources.doubles, sources.pdoTransaction, sources.getenv]),
+ ].join('\n'),
+};
+
+const fieldArticle = {
+ slug: 'editorial-2018-11-field-php-integration-tests',
+ title: 'PHP. Зелёный тест, нерабочая форма: разбираем БД, HTTP и конфигурацию',
+ categories: ['PHP', 'Тестирование'],
+ cover: '/assets/editorial/2018/php-false-green-trace-2018.svg',
+ excerpt: 'Полевой разбор ложной зелени: unit-тест подменил репозиторий и HTTP-клиент, а ошибка живёт в DSN, запросе или URL. Собираем короткую трассу без вызова внешнего сервиса.',
+ readingMinutes: 13,
+ contentHtml: [
+ paragraph('Unit-тест регистрации зелёный, но форма на тестовом стенде отвечает 500 после записи или не отправляет уведомление. Цена ошибки — двойная: можно потерять след между БД и HTTP, а затем «починить» тест подстановкой, которая снова никогда не увидит реальный DSN, cURL и код ответа.'),
+ paragraph('Разберём один вопрос: как поймать фальшиво-зелёный сценарий PHP, если в нём сходятся БД, HTTP и конфигурация? Это учебная трасса, а не отчёт о чужом инциденте. Мы не будем вызывать партнёрский URL и не станем выдавать команды за уже выполненные: вместо этого подготовим test-only БД и локальный HTTP-обработчик, которыми управляет сам проект.'),
+ figure(
+ '/assets/editorial/2018/php-false-green-trace-2018.svg',
+ 'Трасса фальшиво-зелёного PHP-сценария: unit-тест заменяет репозиторий и HTTP-клиент, поэтому не видит DSN и URL; integration-тест проходит через PDO, тестовую БД, локальный callback и фиксирует отдельный request ID.',
+ 'Один ID связывает запись, HTTP-попытку и проверку ответа. Он не нужен для модной наблюдаемости: это короткий способ не спутать три соседние ошибки.',
+ ),
+ heading('Сначала сохраняю порядок фактов, а не объяснение'),
+ paragraph('Полевой разбор начинается с одной исходной команды и одним учебным идентификатором, например registration-test-42. Его передаём в запись и заголовок локального callback. Тогда можно спросить последовательно: создалась ли строка, был ли собран URL, дошёл ли HTTP-запрос до тестового обработчика, какой статус вернулся и какое исключение увидел вызывающий код. Без этого порядка фраза «форма не работает» смешивает три разные границы.'),
+ paragraph('В нормальном тестовом контуре DSN и URL имеют отдельные переменные: TEST_DATABASE_DSN и TEST_CALLBACK_URL. Не подставляем боевой адрес как запасной вариант. Пустая переменная — полезный красный сигнал, потому что она показывает ошибку конфигурации до записи или сетевой попытки. Секреты не печатаем в exception и не кладём в HTML-отчёт.'),
+ dataTable(
+ ['Точка трассы', 'Что записать безопасно', 'Что означает сбой', 'Первое действие'],
+ [
+ ['Чтение конфигурации', 'Есть ли непустые TEST_ имена, без значений пароля', 'Запуск не получил test-only окружение', 'Остановить тест до соединения'],
+ ['PDO-соединение', 'Имя тестовой схемы и тип исключения', 'DSN, драйвер или права тестового пользователя', 'Проверить отдельную конфигурацию и миграцию'],
+ ['INSERT / SELECT', 'Учебный request ID и факт чтения обратно', 'SQL, схема или преобразование результата', 'Сузить тест до репозитория и повторить'],
+ ['HTTP-вызов', 'URL без query-секретов, статус, текст cURL-ошибки', 'Локальный endpoint недоступен или ответ не соответствует договору', 'Проверить порт, маршрут и ожидаемый статус'],
+ ['Ответ сервиса', 'Тип исключения и request ID', 'Код скрыл ошибку или смешал границы', 'Вернуть понятную ошибку вызывающему уровню'],
+ ],
+ ),
+ heading('Локальный callback вместо внешнего партнёра'),
+ paragraph('Для integration-теста HTTP-граница должна быть настоящей, но управляемой. В отдельном терминале проекта можно запустить встроенный PHP-сервер и направить TEST_CALLBACK_URL на 127.0.0.1. Такой маршрут не доказывает доступность партнёра и не должен это обещать. Зато он показывает, что наш cURL-код собрал URL, отправил тело и корректно обработал статус, не передавая данные за пределы машины.'),
+ paragraph('Обработчик ниже принимает только учебный запрос, сохраняет тело в системную временную папку и возвращает 202. Имя файла включает заранее выбранный ID из заголовка. Перед повторным запуском файл нужно удалить вручную в тестовой директории или в tearDown(); пример не советует чистить широкие каталоги и не требует прав администратора. Команда сервера приведена как способ воспроизведения, а не как выполненный здесь прогон.'),
+ codeBlock([
+ 'http://127.0.0.1:8088/callback.phplocalhost: у процесса PHP и у браузера это могут быть разные сетевые пространства. Это ещё одна причина хранить URL в test-only переменной и называть его в ошибке без токенов.'),
+ heading('Показываю, почему unit-тест здесь недостаточен'),
+ paragraph('Локальное правило регистрации всё ещё стоит покрыть unit-тестом. Но в следующем фрагменте оба побочных эффекта заменены объектами в памяти. Он подтвердит порядок вызовов и реакцию сервиса, однако всегда останется зелёным при пустом DSN, отсутствующем драйвере PDO или неверном URL. В этом и состоит его ограничение, а не дефект самого теста.'),
+ codeBlock([
+ 'messages[] = array($requestId, $registrationId);',
+ ' }',
+ '}',
+ '',
+ '$repository = new MemoryRegistrationRepository();',
+ '$callback = new SpyCallbackClient();',
+ '$service = new RegistrationService($repository, $callback);',
+ "$service->register('registration-test-42', 'anna@example.test');",
+ '',
+ '$this->assertSame(array(array(\'registration-test-42\', 42)), $callback->messages);',
+ ].join('\n')),
+ paragraph('Такой unit-тест остаётся полезным: он быстро защищает правило, что уведомление отправляется после успешного создания. Но его вывод надо читать буквально. Он не делал INSERT, не открывал cURL и не читал getenv(). Поэтому рядом появляется integration-тест с реальным PdoRegistrationRepository и CurlCallbackClient, направленным только на локальный endpoint.'),
+ heading('Делаю настоящий HTTP-переход проверяемым'),
+ paragraph('cURL-адаптер обязан отличать ошибку транспорта от ответа сервера. curl_exec() возвращает данные или false; статус читаем через curl_getinfo(). Не считаем любой непустой ответ успехом. Для учебного callback договор простой: ожидаем 202 и JSON с признаком accepted. Таймаут и заголовок задаются в коде явно, чтобы тест не зависел от неявных ini-настроек.'),
+ codeBlock([
+ 'url = $url;',
+ ' }',
+ '',
+ ' public function send(string $requestId, int $registrationId): void',
+ ' {',
+ ' $handle = curl_init($this->url);',
+ ' if ($handle === false) {',
+ " throw new RuntimeException('Cannot create test callback handle');",
+ ' }',
+ ' curl_setopt_array($handle, array(',
+ ' CURLOPT_POST => true,',
+ " CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'X-Test-Request-Id: ' . $requestId),",
+ " CURLOPT_POSTFIELDS => json_encode(array('registrationId' => $registrationId)),",
+ ' CURLOPT_RETURNTRANSFER => true,',
+ ' CURLOPT_TIMEOUT => 3,',
+ ' ));',
+ '',
+ ' $body = curl_exec($handle);',
+ ' $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);',
+ ' $error = curl_error($handle);',
+ ' curl_close($handle);',
+ '',
+ " if ($body === false || $status !== 202 || $body !== '{\"accepted\":true}') {",
+ " throw new RuntimeException('Test callback failed: status=' . $status . ' error=' . $error);",
+ ' }',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Проверка 127.0.0.1 выше намеренно учебная и не подходит как общая политика URL. Её задача — не дать этому конкретному тесту случайно послать данные за пределы локальной машины. В проекте с отдельной тестовой сетью правило будет другим: allowlist test-хоста, отдельные credentials и запрещённый production DNS. Важно, что ограничение находится до вызова cURL, а не в надежде на внимательность запускающего.'),
+ heading('Integration-тест связывает только три нужные части'),
+ paragraph('Тест ниже предполагает, что тестовая схема уже подготовлена, а локальный callback поднят отдельно. Он не создаёт таблицы на лету и не обращается к production. Транзакция очистит запись в БД, но HTTP-вызов не откатится вместе с ней, поэтому обработчик пишет учебное тело в файл с request ID, который можно проверить и удалить после теста. Это явная граница: БД и сеть имеют разный способ уборки.'),
+ codeBlock([
+ 'pdo = TestPdo::fromEnvironment();',
+ ' $this->pdo->beginTransaction();',
+ ' }',
+ '',
+ ' protected function tearDown(): void',
+ ' {',
+ ' if ($this->pdo->inTransaction()) {',
+ ' $this->pdo->rollBack();',
+ ' }',
+ ' }',
+ '',
+ ' public function testWritesAndNotifiesLocalCallback(): void',
+ ' {',
+ " $url = (string) getenv('TEST_CALLBACK_URL');",
+ ' $service = new RegistrationService(',
+ ' new PdoRegistrationRepository($this->pdo),',
+ ' new CurlCallbackClient($url)',
+ ' );',
+ '',
+ " $id = $service->register('registration-test-42', 'anna@example.test');",
+ '',
+ ' $this->assertInternalType(\'int\', $id);',
+ ' $this->assertTrue(is_file(sys_get_temp_dir() . \'/callback-registration-test-42.json\'));',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Этот пример не доказывает доставку сообщения партнёру и не должен отправляться в общий параллельный контур без уникального request ID. Для параллельных запусков добавьте ID на основе безопасного имени теста и удаляйте только созданный им файл. Если endpoint не запущен, тест должен сообщить о недоступной локальной границе, а не незаметно переключиться на другой URL.'),
+ heading('Порядок разбора, когда тест зеленее реальности'),
+ orderedList([
+ 'Сохранить текст исходной ошибки и выбрать один учебный request ID; не менять DSN, URL и SQL одновременно.',
+ 'Проверить наличие TEST_DATABASE_DSN и TEST_CALLBACK_URL без вывода паролей и токенов.',
+ 'Запустить отдельно интеграционный тест репозитория: запись и чтение через PDO должны быть видны до HTTP-шага.',
+ 'Поднять или проверить только локальный callback, затем убедиться, что URL теста не совпадает с внешним адресом.',
+ 'Добавить настоящий cURL-адаптер в тест и различить transport error, HTTP status и неверное тело ответа.',
+ 'После причины вернуть unit-тесту его узкую роль, а integration-тест оставить возле адаптеров как защиту от повторной склейки.',
+ ]),
+ heading('Что этот маршрут не обещает'),
+ paragraph('Локальная связка не проверяет реальную сеть партнёра, его авторизацию, лимиты, очередь, браузерную форму или поведение production БД под нагрузкой. Она также не делает распределённую транзакцию: если БД уже записала строку, а callback ответил ошибкой, политика повтора и компенсации должна быть спроектирована отдельно. Не надо прятать эту проблему в catch и объявлять сценарий атомарным.'),
+ paragraph('Версия PHP, драйвер PDO и PHPUnit должны быть закреплены проектом. На дату пересмотра PHP 7 и PHPUnit 7 уже устарели; примеры сохраняют исторический контекст 2018 года, но не заменяют план обновления. Перед применением к существующему коду сверяем актуальные параметры cURL, метод очистки схемы и правила тестовой инфраструктуры именно в этом проекте.'),
+ heading('Итог: зелёный цвет должен иметь границу'),
+ paragraph('Когда один unit-тест заменяет БД и HTTP, он может честно подтвердить порядок вызовов, но не саму склейку. Полевой integration-тест делает эту склейку короткой и управляемой: test-only конфигурация, реальный PDO, локальный callback, один request ID и раздельная уборка следов. Такой путь не лечит все ошибки, зато сразу показывает, какая из трёх границ действительно сломана.'),
+ sourceList([sources.phpunit7, sources.fixtures, sources.pdoTransaction, sources.getenv, sources.curlExec, sources.curlGetinfo]),
+ ].join('\n'),
+};
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
+
+const isDirectExecution = Boolean(process.argv[1])
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectExecution && process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+}
diff --git a/web/scripts/upgrade-2018-12.mjs b/web/scripts/upgrade-2018-12.mjs
new file mode 100644
index 0000000..25641b5
--- /dev/null
+++ b/web/scripts/upgrade-2018-12.mjs
@@ -0,0 +1,467 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const escapeHtml = (value) => String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+const paragraph = (content) => '' + content + '
'; +const heading = (content) => '' + escapeHtml(source.trim()) + '';
+
+function figure(src, alt, caption) {
+ return [
+ 'CODE пришло пустым или ушло не в тот инфоблок. Цена ошибки — не только 404 для посетителя. Следующая выгрузка или шаблон может прочитать уже другой адрес, и разбирать придётся не одну строку в обработчике, а весь след изменения.'),
+ paragraph('Разберём один вопрос: как вынести маленький защищённый шов вокруг CIBlockElement::Update, когда нужно менять только символьный код элемента? Это учебный пример для старого Bitrix и PHP 7.2. Он не предполагает, что у нас есть доступ к вашему модулю, тестовой базе или журналу изменений. Сначала ограничиваем вход, затем меняем одно поле и читаем его обратно.'),
+ heading('Симптом показывает место, а не весь объём переделки'),
+ paragraph('В legacy-файле изменение часто прячется между разбором $_POST, подключением шаблона, проверкой прав и отправкой письма. В таком месте легко решить, что нужен новый модуль каталога. Пока доказан только другой факт: один переход вход формы → CODE элемента нельзя безопасно увидеть и повторить. Значит, первым делом отделяем этот переход, а не переносим все соседние строки.'),
+ paragraph('Шов — это небольшая функция или класс, у которого видны вход, ожидаемое изменение и ошибка. Он не обязан знать, как отрисовывается форма и кому потом отправляется уведомление. Для примера шов получает ID элемента, новый код и ожидаемый ID инфоблока. На выходе он возвращает короткий отчёт: поле уже имело нужное значение либо было обновлено. Остальные части старого файла пока остаются на месте.'),
+ figure(
+ '/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg',
+ 'Карта шва legacy-модуля Bitrix: форма передаёт ID и CODE в выделенный CodeWriter, тот проверяет инфоблок, вызывает CIBlockElement Update и читает элемент обратно; шаблон и внешняя интеграция остаются по сторонам.',
+ 'Шов обводит только переход к полю CODE. Он не изображает замену всего шаблона, импорта или каталога.',
+ ),
+ heading('До кода записываем, что вправе изменить'),
+ paragraph('Узкий контракт полезнее списка пожеланий. Входом считаем положительный ID, непустой символьный код и заранее известный инфоблок. Изменением считаем только поле CODE. До вызова API проверяем, что элемент существует и относится к этому инфоблоку. После вызова читаем тот же элемент и сравниваем фактическое значение. Если код уже совпал, писать в БД второй раз не нужно: это отдельный наблюдаемый результат, а не ошибка.'),
+ dataTable(
+ ['Часть шва', 'Проверка до записи', 'Действие', 'Признак готовности'],
+ [
+ ['Модуль', 'CModule::IncludeModule("iblock") вернул true', 'Разрешить работу с API инфоблоков', 'Нет скрытого подключения класса из другого файла'],
+ ['Элемент', 'Выборка по ID вернула строку', 'Сравнить IBLOCK_ID и текущий CODE', 'Не меняем чужой инфоблок и не создаём элемент по ошибке'],
+ ['Вход', 'ID положительный, код после trim() не пустой', 'Передать ровно одно поле в Update', 'Форма не превращает пустое значение в случайное обновление'],
+ ['Запись', 'Update() вернул true', 'Прочитать элемент той же выборкой', 'Возвращённый CODE совпадает с ожидаемым'],
+ ['Отказ', 'API вернул false или проверка не прошла', 'Остановить текущий путь с понятным сообщением', 'Нет продолжения к шаблону как после успешной записи'],
+ ],
+ ),
+ paragraph('Такой контракт не гарантирует уникальность кода на любом сайте. В одном проекте код формирует компонент, в другом — импорт, в третьем на него влияют обработчики события. Задача этого шага скромнее: не дать конкретному вызову Update изменить неизвестный элемент или умолчать о неуспехе. Правило уникальности, транслитерация и маршруты каталога добавляются отдельными проверками, если они действительно принадлежат вашему случаю.'),
+ heading('Выделяем CodeWriter, а не новый «слой приложения»'),
+ paragraph('Ниже обычный PHP-класс. Он использует старый API Bitrix потому, что именно он уже находится в рассматриваемом коде. Перед работой он подключает модуль iblock, читает элемент через CIBlockElement::GetList и передаёт в Update только CODE. В примере нет автозагрузчика, контейнера зависимостей или обещания миграции на другой API: эти вещи не нужны, чтобы сделать один вызов понятнее.'),
+ codeBlock([
+ 'expectedIblockId = (int) $expectedIblockId;',
+ ' }',
+ '',
+ ' public function write($elementId, $code)',
+ ' {',
+ " if (!CModule::IncludeModule('iblock')) {",
+ " throw new RuntimeException('The iblock module is not available');",
+ ' }',
+ '',
+ ' $elementId = (int) $elementId;',
+ ' $code = trim((string) $code);',
+ '',
+ " if ($elementId <= 0 || $code === '') {",
+ " throw new InvalidArgumentException('Element ID and CODE are required');",
+ ' }',
+ '',
+ ' $before = $this->find($elementId);',
+ " if (!$before || (int) $before['IBLOCK_ID'] !== $this->expectedIblockId) {",
+ " throw new RuntimeException('Unexpected element or iblock');",
+ ' }',
+ '',
+ " if ((string) $before['CODE'] === $code) {",
+ " return array('changed' => false, 'code' => $code);",
+ ' }',
+ '',
+ ' $element = new CIBlockElement();',
+ " if (!$element->Update($elementId, array('CODE' => $code))) {",
+ " throw new RuntimeException($element->LAST_ERROR ?: 'Bitrix Update failed');",
+ ' }',
+ '',
+ ' $after = $this->find($elementId);',
+ " if (!$after || (string) $after['CODE'] !== $code) {",
+ " throw new RuntimeException('CODE was not read back after Update');",
+ ' }',
+ '',
+ " return array('changed' => true, 'code' => $after['CODE']);",
+ ' }',
+ '',
+ ' private function find($elementId)',
+ ' {',
+ ' $result = CIBlockElement::GetList(',
+ ' array(),',
+ " array('ID' => (int) $elementId),",
+ ' false,',
+ ' false,',
+ " array('ID', 'IBLOCK_ID', 'CODE')",
+ ' );',
+ '',
+ ' return $result->Fetch();',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Код не заменяет права доступа и не делает код уникальным сам по себе. Он также не оборачивает обработчики Bitrix в транзакцию: документация CIBlockElement::Update указывает, что до и после записи работают события. Поэтому ответ true нужен, но сам по себе не равен доказательству, что шаблон, индекс или внешняя система уже увидели нужный адрес. Здесь мы доказываем только то, что выбрали правильный элемент и прочитали обратно его CODE.'),
+ heading('Подключаем шов из старого обработчика одной строкой'),
+ paragraph('Старый файл может по-прежнему собирать $_POST, проверять сессию и решать, какой шаблон показать. Замена касается только места, где раньше напрямую вызывался CIBlockElement::Update. Обработчик получает отчёт и сам выбирает, как показать ошибку. Это важно: класс не должен делать echo, редирект или запись в глобальный $APPLICATION, иначе ответственность снова смешается.'),
+ codeBlock([
+ 'write($_POST['ID'], $_POST['CODE']);",
+ '',
+ " $message = $report['changed']",
+ " ? 'Символьный код обновлён.'",
+ " : 'Символьный код уже совпадает.';",
+ '} catch (InvalidArgumentException $error) {',
+ ' $message = $error->getMessage();',
+ '} catch (RuntimeException $error) {',
+ ' // В конкретном проекте здесь выбирают локальный журнал и вывод формы.',
+ ' $message = $error->getMessage();',
+ '}',
+ ].join('\n')),
+ paragraph('В таком виде изменение можно снять отдельно в истории: одна правка добавляет ProductCodeWriter и переключает один вызов. Не стоит в том же коммите переименовывать шаблоны, менять поля инфоблока и переписывать импорт. Если поведение расходится, разница будет лежать либо на входе, либо в шве, либо после него. Чем меньше одновременно изменённых мест, тем легче вернуть старую строку без отката соседней работы.'),
+ heading('Проверяем один след, затем расширяем покрытие'),
+ orderedList([
+ 'Найти прямой вызов CIBlockElement::Update и записать его входы: ID, инфоблок, поле и источник кода.',
+ 'Выбрать изолированный элемент на разрешённом тестовом контуре; не брать рабочую карточку ради быстрой проверки.',
+ 'Сохранить исходный CODE и ожидаемый новый код рядом с проверкой, не в комментарии памяти.',
+ 'Вызвать шов только для этого элемента и проверить отчёт changed.',
+ 'Снова выбрать элемент API-запросом и сравнить фактический CODE с ожидаемым.',
+ 'Если появилось расхождение, вернуть вызов на прежний путь или восстановить сохранённое значение по процедуре проекта; не добавлять второй Update «на всякий случай».',
+ 'Лишь после понятного результата подключать следующий вызов и отдельно разбирать его предусловия.',
+ ]),
+ heading('Границы защищённого шва'),
+ paragraph('Этот приём не устраняет все риски старого Bitrix-проекта. Он не говорит, какой CODE нужен для SEO, как синхронизировать его с торговыми предложениями и можно ли менять его у опубликованного элемента. Если на инфоблоке есть обработчик OnBeforeIBlockElementUpdate, он вправе изменить поля или отменить обновление. Значит, перед включением на конкретном сайте нужно посмотреть зарегистрированные обработчики и проверить их на разрешённом сценарии.'),
+ paragraph('Также не следует превращать каждую строку PHP в класс. Если в файле один вызов и он уже имеет ясный вход, достаточно функции с тем же контрактом. Шов оправдан, когда повторяемая операция сейчас смешана с формой или когда нужно зафиксировать её проверку. Цель не в количестве файлов. Цель — увидеть, что именно меняется, и получить точку, куда можно поставить следующий локальный тест.'),
+ heading('Итог: маленькая замена оставляет понятный след'),
+ paragraph('Для первого рефакторинга достаточно вынести один вызов Update, ограничить инфоблок и поле, а затем прочитать результат обратно. Это не переписывает модуль и не обещает, что все связи каталога стали безопасными. Зато следующий разработчик получает конкретный маршрут: вход, проверка, запись, повторная выборка и ясная точка отказа. Когда этот маршрут устойчив, рядом можно вынести следующий — но только после отдельной проверки.'),
+ sourceList([sources.includeModule, sources.getList, sources.update, sources.beforeUpdate, sources.phpExceptions]),
+ ].join('\n'),
+};
+
+const mechanismArticle = {
+ slug: 'editorial-2018-12-mechanism-legacy-refactoring',
+ title: 'Bitrix. Почему один save.php ломается от «маленькой» правки',
+ categories: ['Bitrix', 'PHP'],
+ cover: '/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg',
+ excerpt: 'Почему смешанная ответственность делает локальную правку опасной: отделяем обработку формы, изменение инфоблока и вывод результата, не строя большую архитектуру поверх старого Bitrix-кода.',
+ readingMinutes: 13,
+ contentHtml: [
+ paragraph('Кнопка «Сохранить» иногда выводит ошибку, хотя элемент инфоблока уже изменён, а при повторе пользователь запускает второй побочный эффект. Цена ошибки — дублирующая отправка, потерянная причина сбоя и опасная правка «только сообщения» в файле, который одновременно меняет данные, строит HTML и зовёт соседнюю интеграцию.'),
+ paragraph('Один вопрос этой заметки: почему смешанная ответственность в старом save.php делает даже локальный рефакторинг рискованным? В 2018 году для ответа не нужен большой набор паттернов. Достаточно показать порядок действий: форма передала данные, Bitrix изменил элемент, затем код решил, что показать или вызвать дальше. Если эти шаги не разделены, по одному сообщению в браузере нельзя понять, какой из них уже произошёл.'),
+ heading('Один HTTP-запрос может оставить несколько разных следов'),
+ paragraph('Старый обработчик обычно вырос постепенно. Сначала он сохранял название товара. Потом в него добавили проверку картинки, затем письмо менеджеру, затем очистку кеша и кусок шаблона. Все строки исполняются в одном PHP-процессе, но владеют разными состояниями. Поле инфоблока живёт в Битрикс, сообщение — в форме, а уведомление — в другом канале. Ошибка после первого действия не отменяет автоматически уже сделанное изменение.'),
+ paragraph('Опасность не в длине файла. Маленький файл тоже смешивает ответственность, если функция одновременно читает $_POST, меняет элемент, печатает HTML и решает, что делать с ошибкой. В нём невозможно выбрать простую проверку: мы не знаем, считать ли «успехом» ответ браузеру, результат Update() или факт, что следующее действие не было вызвано. Поэтому сначала даём каждому следу имя.'),
+ figure(
+ '/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg',
+ 'Схема смешанной ответственности в legacy save.php: один файл одновременно читает POST, изменяет CODE в инфоблоке, формирует HTML и запускает внешнее действие; ошибка на позднем шаге не сообщает, что уже сохранилось.',
+ 'Красные стрелки показывают независимые побочные эффекты. Их порядок нельзя восстановить только по одному сообщению формы.',
+ ),
+ heading('Сначала фиксируем наблюдаемые границы'),
+ paragraph('Для локальной переделки достаточно трёх ролей. Обработчик формы принимает и проверяет вход. Операция записи меняет один элемент инфоблока и возвращает результат или ошибку. Представление решает, какой текст показать. Внешняя отправка, кеш или импорт остаются отдельными соседями: их не надо прятать в новую функцию только потому, что они находятся рядом. Если они важны для сценария, порядок и отдельный признак их выполнения описываются позже.'),
+ dataTable(
+ ['Что делает старый файл', 'Какой след остаётся', 'Почему это опасно при смешении', 'Самый маленький шов'],
+ [
+ ['Читает $_POST', 'Непроверенные строки формы', 'Пустой ID может дойти до записи под видом обычной ошибки', 'Преобразовать вход в массив с ID и названием до работы с API'],
+ ['Вызывает CIBlockElement::Update', 'Изменение в инфоблоке или LAST_ERROR', 'HTML ниже по файлу не доказывает результат записи', 'Вернуть из функции отчёт или исключение'],
+ ['Выводит HTML', 'Текст в браузере', 'Сообщение «готово» может появиться не на том пути', 'Показывать текст после известного результата операции'],
+ ['Запускает интеграцию', 'Отдельный сетевой или файловый эффект', 'Повтор формы способен повторить уже выполненное действие', 'Оставить вызов рядом с явным условием успеха'],
+ ['Меняет глобальное состояние', 'Сессия, кеш, глобальные переменные', 'Тест и диагностика зависят от порядка строк', 'Передавать нужное значение аргументом в малую функцию'],
+ ],
+ ),
+ paragraph('Таблица не предлагает разнести старый сайт по слоям за один день. Она нужна для более короткого решения: выбрать единственный след, который сейчас нужен задаче, и не потерять его среди остальных. Например, если исправляем пустой символьный код, первым швом будет сохранение CODE. Письмо менеджеру и кеш можно временно оставить в старом файле, но не использовать их как доказательство того, что код элемента записан.'),
+ heading('Как выглядит смешение в коде'),
+ paragraph('Этот фрагмент намеренно похож на обычный legacy-обработчик. Он не взят из конкретного проекта и не должен быть скопирован в production. Его задача — показать, почему ошибка в конце не отвечает на вопрос о середине. Вызов sendPartnerNotice() обозначает уже существующую соседнюю операцию; статья не утверждает, что она выполнялась или что любой сайт должен её иметь.'),
+ codeBlock([
+ 'Update($elementId, array('NAME' => $name))) {",
+ ' echo $element->LAST_ERROR;',
+ ' return;',
+ ' }',
+ '',
+ ' // Детали этой интеграции здесь неизвестны.',
+ " sendPartnerNotice($elementId, $name);",
+ " echo 'Сохранено';",
+ '}',
+ ].join('\n')),
+ paragraph('В примере можно увидеть минимум три исхода: вход не прошёл, Bitrix отказал в обновлении, запись прошла, но следующий шаг вернул ошибку. Последний исход особенно неприятен. Если вокруг sendPartnerNotice() появится исключение, браузер может показать общую ошибку, хотя NAME уже записан. Повторить POST после этого — не нейтральная проверка. Поэтому не маскируем все пути одним текстом и не добавляем «повторить Update» после любого сбоя.'),
+ heading('Выносим запись в функцию с одним ответом'),
+ paragraph('Первое извлечение можно сделать обычной функцией. Вход ей передают явно: ID и нормализованное название. Она не печатает HTML, не читает глобальный $_POST и не вызывает интеграцию. Она либо возвращает ID обновлённого элемента, либо останавливает текущий путь исключением. PHP исключения здесь используются не как модная абстракция, а чтобы код формы не продолжился как после успешной записи.'),
+ codeBlock([
+ 'Update($elementId, array('NAME' => $name))) {",
+ " throw new RuntimeException($element->LAST_ERROR ?: 'Element update failed');",
+ ' }',
+ '',
+ ' return $elementId;',
+ '}',
+ '',
+ '// В обработчике формы остаётся только порядок сценария.',
+ 'try {',
+ " $savedId = saveProductName($_POST['ID'], $_POST['NAME']);",
+ " $message = 'Карточка сохранена: ' . $savedId;",
+ '} catch (InvalidArgumentException $error) {',
+ ' $message = $error->getMessage();',
+ '} catch (RuntimeException $error) {',
+ ' $message = $error->getMessage();',
+ '}',
+ ].join('\n')),
+ paragraph('После этого можно решить, что делать с интеграцией, но не смешивать решение с первым швом. Если уведомление допустимо только после успешной записи, его вызывают после saveProductName() и фиксируют отдельно, что именно считается успехом интеграции. Если интеграция упала, обработчик честно показывает её отдельную ошибку и не делает вид, что карточка не менялась. Восстановление поля, повтор сети и очередь — следующие задачи с собственными условиями, а не одна строка в catch.'),
+ heading('Почему обработчики Bitrix усиливают путаницу'),
+ paragraph('Метод CIBlockElement::Update не одинок: до изменения могут выполниться обработчики OnBeforeIBlockElementUpdate, которые могут изменить входные поля или отменить действие. После записи также есть события. Поэтому функция записи должна сохранить первоначальный смысл: она возвращает только результат вызова API и его сообщение. Ей не нужно обещать, что все слушатели, поиск, кеш или внешний каталог уже находятся в согласованном состоянии.'),
+ paragraph('Эта оговорка особенно полезна при разборе старого кода. Если новая функция внезапно меняет больше, чем старая строка, сначала смотрим обработчики и фактический набор полей. Не добавляем PROPERTY_VALUES «для полноты»: документация события отдельно предупреждает, что неосторожная работа с этим массивом может очистить остальные свойства, когда Update был вызван без них. Узкий массив полей — защита от лишнего изменения, а не неполнота примера.'),
+ heading('Порядок локальной переделки'),
+ orderedList([
+ 'Назвать один симптом: например, после формы неизвестно, записано ли название или ошибка случилась после записи.',
+ 'Выписать из файла все побочные эффекты в их фактическом порядке: изменение элемента, HTML, кеш, письмо, интеграция.',
+ 'Выбрать только один эффект для первой замены и описать вход, выход и отказ.',
+ 'Вынести его в функцию или небольшой класс без echo, $_POST и сторонней отправки.',
+ 'Подключить шов одним вызовом из старого файла и сохранить прежний порядок для действий, которые ещё не разбирались.',
+ 'На разрешённом тестовом контуре проверить положительный и отрицательный вход отдельно от шаблона.',
+ 'Если нужно менять следующий эффект, начать новый короткий разбор, а не расширять первый шов до всего файла.',
+ ]),
+ heading('Где такое разделение не решает проблему'),
+ paragraph('Функция записи не создаёт транзакцию между инфоблоком и внешним API. Она не отменяет уже сделанное уведомление и не знает, можно ли повторить сетевой запрос. Если сценарий требует атомарности нескольких систем, это отдельная задача: сначала нужно описать данные, порядок и допустимый повтор. Называть такую задачу «добавим try/catch» было бы опаснее, чем оставить честную границу.'),
+ paragraph('Также не надо принудительно выносить весь шаблон из PHP-файла, если ошибка живёт в одной операции записи. Старый формат может остаться старым. Результат локального рефакторинга измеряется проще: у операции есть собственный вход, собственный результат, понятная ошибка и короткий способ проверить, где оборвался сценарий. Это уже делает следующую правку меньше.'),
+ heading('Итог: порядок важнее размера файла'),
+ paragraph('Маленькая правка опасна, когда один файл выдаёт несколько несвязанных эффектов за один успех. Разделив форму, изменение инфоблока и последующее действие хотя бы на уровне функций, мы не строим новую архитектуру. Мы возвращаем причинность: сообщение формы не подменяет результат Update, а ошибка интеграции не стирает факт сохранения. С этой точки можно выбрать следующий шов без большого переписывания.'),
+ sourceList([sources.includeModule, sources.update, sources.beforeUpdate, sources.phpExceptions]),
+ ].join('\n'),
+};
+
+const fieldArticle = {
+ slug: 'editorial-2018-12-field-legacy-refactoring',
+ title: 'Bitrix. Замена старого обработчика: один элемент, проверка и откат',
+ categories: ['Bitrix', 'PHP'],
+ cover: '/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg',
+ excerpt: 'Полевой учебный разбор: как заменить один legacy-участок формирования CODE, проверить его на выбранном элементе и подготовить честный откат без обещаний о бесшовной миграции.',
+ readingMinutes: 14,
+ contentHtml: [
+ paragraph('Нужно заменить старый участок, который формирует CODE, но его вызовы разбросаны между импортом и формой редактирования. Цена ошибки — не «некрасивый рефакторинг»: один новый код может сломать URL элемента, а поспешный откат поверх неизвестного значения способен затереть изменение другого человека.'),
+ paragraph('Это учебный полевой разбор одного вопроса: как заменить один legacy-обработчик Bitrix на проверяемый путь и оставить возможность отката? Он не описывает реальный проект, запуск или результат релиза. Возьмём один элемент на разрешённом тестовом контуре, снимем его исходное значение, направим только этот вызов через новый код и после записи снова прочитаем элемент. Так можно увидеть расхождение до того, как расширять замену на импорт.'),
+ heading('Выбираем один участок, а не «весь импорт»'),
+ paragraph('Представим старую функцию legacyUpdateCode(). Она получает название, сама делает транслитерацию и сразу вызывает CIBlockElement::Update. Задача не в том, чтобы объявить её плохой. Она уже может обслуживать десятки строк импорта. В первом проходе меняем только один контролируемый вызов: выбранный ID, известный инфоблок и понятное ожидаемое значение. Остальные обращения продолжают пользоваться старой функцией, пока для них не записаны такие же условия.'),
+ paragraph('Полезно заранее выписать карту вызова на бумаге или в задаче: форма редактирования, import-скрипт, cron, обработчик события. Мы не утверждаем, что нашли эти места в конкретном репозитории — их надо искать в своём проекте. Карта нужна, чтобы не перепутать локальный опыт с глобальной заменой. Если один вызов проходит новый путь, это не разрешение переключить остальные молча.'),
+ figure(
+ '/assets/editorial/2018/bitrix-legacy-replacement-rollback-2018.svg',
+ 'Схема безопасной замены legacy-участка: снимок ID, IBLOCK_ID и старого CODE; выбор старого или нового CodeWriter для одного вызова; повторная выборка; при расхождении возврат маршрута и сохранённое исходное значение для согласованного отката.',
+ 'Откат на схеме сначала выключает новый маршрут. Восстановление поля выполняют только по сохранённому снимку и после проверки, что его не менял другой процесс.',
+ ),
+ heading('Снимок до изменения — это материал для проверки, а не журнал на словах'),
+ paragraph('До вызова сохраняем минимум: ID элемента, ID инфоблока, прежний CODE, новый расчётный код и время проверки. Эти значения можно положить в тестовый сценарий, временный защищённый журнал или запись задачи — способ зависит от правил проекта. Не стоит печатать в общий лог весь массив элемента: в нём могут оказаться поля, которые не нужны для данной операции. Нам достаточно того, что позволит сравнить один переход.'),
+ dataTable(
+ ['Шаг', 'Что фиксируем', 'Что считаем успехом', 'Что делаем при расхождении'],
+ [
+ ['До переключения', 'ID, IBLOCK_ID, старый CODE', 'Элемент существует и принадлежит ожидаемому инфоблоку', 'Не запускать новый путь для этого ID'],
+ ['Расчёт', 'Название и параметры транслитерации', 'Новый код не пустой и понятен человеку', 'Остановить сценарий, не писать заглушку'],
+ ['Запись', 'Выбранный writer и ответ Update', 'API сообщил успех без скрытой повторной записи', 'Вернуть управление старому маршруту для следующих попыток'],
+ ['Повторная выборка', 'Фактический CODE по тому же ID', 'Совпадает с расчётным значением', 'Сначала отключить новый маршрут, затем расследовать события и вход'],
+ ['Откат поля', 'Сохранённый старый код и текущий код', 'Текущий код всё ещё тот, который поставил опыт', 'Не перезаписывать элемент; согласовать восстановление вручную'],
+ ],
+ ),
+ paragraph('Последняя строка важнее всего. Откат маршрута и откат данных — разные действия. Переключатель может вернуть последующие вызовы на старую функцию. Но если новый путь уже изменил поле, автоматическое восстановление безопасно только при проверке, что между снимком и откатом значение не менял импорт, редактор или обработчик. Если такой гарантии нет, честнее остановиться и сравнить состояние, чем вернуть «старый» код поверх чужой работы.'),
+ heading('Новый writer отвечает только за расчёт и одно поле'),
+ paragraph('В примере ниже старый и новый writers существуют рядом. Это не постоянная архитектура и не рекомендация держать две реализации вечно. Две функции нужны на время локальной проверки, чтобы маршрут можно было вернуть без массового удаления кода. Новый вариант перед записью проверяет инфоблок, формирует символьный код стандартной функцией Bitrix и передаёт в Update только поле CODE.'),
+ codeBlock([
+ 'iblockId = (int) $iblockId;',
+ ' }',
+ '',
+ ' public function writeFromName($elementId, $name)',
+ ' {',
+ " if (!CModule::IncludeModule('iblock')) {",
+ " throw new RuntimeException('Module iblock is unavailable');",
+ ' }',
+ '',
+ ' $before = $this->find($elementId);',
+ " if (!$before || (int) $before['IBLOCK_ID'] !== $this->iblockId) {",
+ " throw new RuntimeException('Unexpected element or iblock');",
+ ' }',
+ '',
+ " $code = CUtil::translit(trim((string) $name), 'ru', array(",
+ " 'change_case' => 'L',",
+ " 'replace_space' => '-',",
+ " 'replace_other' => '-',",
+ " 'delete_repeat_replace' => true,",
+ " 'max_len' => 100,",
+ ' ));',
+ '',
+ " if ($code === '') {",
+ " throw new InvalidArgumentException('CODE is empty after transliteration');",
+ ' }',
+ '',
+ ' $element = new CIBlockElement();',
+ " if (!$element->Update((int) $elementId, array('CODE' => $code))) {",
+ " throw new RuntimeException($element->LAST_ERROR ?: 'Element update failed');",
+ ' }',
+ '',
+ " return array('before' => $before['CODE'], 'after' => $code);",
+ ' }',
+ '',
+ ' private function find($elementId)',
+ ' {',
+ ' $result = CIBlockElement::GetList(',
+ ' array(),',
+ " array('ID' => (int) $elementId),",
+ ' false,',
+ ' false,',
+ " array('ID', 'IBLOCK_ID', 'CODE')",
+ ' );',
+ '',
+ ' return $result->Fetch();',
+ ' }',
+ '}',
+ ].join('\n')),
+ paragraph('Параметры CUtil::translit здесь выбраны для примера: нижний регистр, дефисы вместо пробелов и ограничение длины. Они не доказывают, что именно такой код нужен вашему каталогу. Например, правила SEO могут требовать другой язык, сохранение старых URL или дополнительную проверку уникальности. До подключения нового writer эти условия следует назвать отдельно. Нельзя делать вывод о совпадении поведения только по тому, что обе функции вернули непустую строку.'),
+ heading('Маршрут выбираем явно и на короткое время'),
+ paragraph('Для контролируемого опыта достаточно простого переключателя в локальной конфигурации. Он не должен быть скрыт в шаблоне или зависеть от случайного параметра URL. В примере константу задаёт окружение, которое уже контролирует проект. По умолчанию остаётся старый путь. Новый маршрут включают только для заранее выбранной проверки, а не для всех вызовов импорта.'),
+ codeBlock([
+ 'writeFromName($elementId, $name);',
+ '}',
+ '',
+ '// Перед опытом сравниваем ID с заранее выбранным значением.',
+ "if ((int) $elementId !== 451) {",
+ " throw new RuntimeException('The checked route is not enabled for this element');",
+ '}',
+ ].join('\n')),
+ paragraph('Числа 7 и 451 в примере не являются настройкой для копирования. Они показывают, что контур должен назвать свой инфоблок и тестовый элемент явно. В рабочем коде значения берут из согласованной конфигурации, а не из формы. Если такого контура нет, не следует подменять его production-карточкой. Сначала подготовьте разрешённый элемент и способ увидеть его до и после вызова.'),
+ heading('Проверяем новую ветку и готовим откат до запуска'),
+ paragraph('В Bitrix Update вызывает события, поэтому сравнение не заканчивается на его булевом результате. После вызова снова выбираем элемент и проверяем CODE. Если новое значение не совпало с расчётным, первым действием будет выключить новый маршрут для следующих запросов. Затем смотрим вход, обработчики и фактическое значение. Откат поля не следует запускать автоматически из catch: в нём недостаточно информации о чужих изменениях.'),
+ orderedList([
+ 'Составить карту старых вызовов и выбрать один разрешённый сценарий, не заявляя, что карта уже полна.',
+ 'Снять перед опытом ID, инфоблок и прежний CODE; отдельно записать ожидаемую строку после транслитерации.',
+ 'Оставить переключатель нового writer выключенным по умолчанию и подготовить понятный способ вернуть его в false.',
+ 'Включить новый путь только для выбранного ID на тестовом контуре.',
+ 'После вызова прочитать элемент заново через API и сравнить фактическое поле с ожидаемым.',
+ 'При расхождении сразу отключить новый маршрут. Восстанавливать старый CODE можно только после проверки, что текущая строка принадлежит этому опыту.',
+ 'Сохранить итог проверки рядом с задачей и лишь затем решать, нужен ли второй сценарий или доработка правил транслитерации.',
+ ]),
+ heading('Чего не доказывает один удачный элемент'),
+ paragraph('Один элемент не проверяет все алфавиты, дубликаты, права редакторов, торговые предложения, SEO-шаблоны и работу импорта по расписанию. Он также не показывает, что в проекте нет обработчика, меняющего CODE после нашего вызова. Это не недостаток маленького опыта, если он честно ограничен. Для следующего сценария понадобятся новые входы, ожидаемый результат и такой же снимок до изменения.'),
+ paragraph('Не нужно изображать такую замену как «бесшовную миграцию». Две реализации на короткое время добавляют стоимость: их нужно держать рядом, понимать разницу и потом удалить старый путь отдельной задачей. Но эта стоимость видна. В отличие от массовой подмены, она оставляет точку возврата и позволяет остановиться после первого расхождения, а не искать причину среди сотен уже изменённых карточек.'),
+ heading('Итог: откат начинается до записи'),
+ paragraph('Локальная замена становится безопаснее, когда до вызова известны исходное значение, выбранный маршрут и критерий сравнения после записи. Сначала выключаем новый путь для следующих запросов, затем решаем вопрос данных по сохранённому снимку. Такой порядок не делает legacy-код современным сам по себе. Он даёт следующей правке то, чего обычно не хватает в старом обработчике: один контролируемый элемент, наблюдаемый результат и честный путь назад.'),
+ sourceList([sources.includeModule, sources.getList, sources.update, sources.beforeUpdate, sources.translit]),
+ ].join('\n'),
+};
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
+
+const isDirectExecution = Boolean(process.argv[1])
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectExecution) {
+ if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+ } else {
+ process.stderr.write('Usage: node web/scripts/upgrade-2018-12.mjs --print-revisions\n');
+ }
+}