@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 1120" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Состояния изображения в форме Bitrix</title>
|
||||
<desc id="desc">Вертикальная схема пути файла: выбор в браузере, multipart POST, запись CFile и обновление PREVIEW_PICTURE элемента инфоблока.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#111827"/>
|
||||
<stop offset="1" stop-color="#172554"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-15%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#020617" flood-opacity=".28"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto">
|
||||
<path d="M0,0 L12,0 L6,12 Z" fill="#7dd3fc"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="640" height="1120" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="48" y="58" fill="#f8fafc" font-size="27" font-weight="700">Форма с изображением:</text>
|
||||
<text x="48" y="91" fill="#f8fafc" font-size="27" font-weight="700">четыре состояния файла</text>
|
||||
<text x="48" y="122" fill="#94a3b8" font-size="16">Preview не доказывает, что карточка уже обновлена.</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="160" width="544" height="142" rx="20" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="48" y="378" width="544" height="142" rx="20" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="48" y="596" width="544" height="142" rx="20" fill="#0f3a53" stroke="#38bdf8"/>
|
||||
<rect x="48" y="814" width="544" height="142" rx="20" fill="#172554" stroke="#818cf8"/>
|
||||
</g>
|
||||
|
||||
<circle cx="86" cy="202" r="20" fill="#334155"/>
|
||||
<text x="86" y="209" text-anchor="middle" fill="#e2e8f0" font-size="16" font-weight="700">1</text>
|
||||
<text x="120" y="207" fill="#f8fafc" font-size="22" font-weight="700">Браузер: File и preview</text>
|
||||
<text x="80" y="248" fill="#cbd5e1" font-size="17">Пользователь видит выбранный файл.</text>
|
||||
<text x="80" y="274" fill="#cbd5e1" font-size="17">Постоянного ID Bitrix ещё нет.</text>
|
||||
|
||||
<circle cx="86" cy="420" r="20" fill="#334155"/>
|
||||
<text x="86" y="427" text-anchor="middle" fill="#e2e8f0" font-size="16" font-weight="700">2</text>
|
||||
<text x="120" y="425" fill="#f8fafc" font-size="22" font-weight="700">Форма: multipart POST</text>
|
||||
<text x="80" y="466" fill="#cbd5e1" font-size="17">Проверяем имя поля и UPLOAD_ERR.</text>
|
||||
<text x="80" y="492" fill="#cbd5e1" font-size="17">Доказательство: ожидаемый $_FILES.</text>
|
||||
|
||||
<circle cx="86" cy="638" r="20" fill="#0c4a6e"/>
|
||||
<text x="86" y="645" text-anchor="middle" fill="#e0f2fe" font-size="16" font-weight="700">3</text>
|
||||
<text x="120" y="643" fill="#f8fafc" font-size="22" font-weight="700">CFile: запись в b_file</text>
|
||||
<text x="80" y="684" fill="#dbeafe" font-size="17">CFile::SaveFile вернул положительный ID.</text>
|
||||
<text x="80" y="710" fill="#dbeafe" font-size="17">Файл можно прочитать по этому ID.</text>
|
||||
|
||||
<circle cx="86" cy="856" r="20" fill="#3730a3"/>
|
||||
<text x="86" y="863" text-anchor="middle" fill="#e0e7ff" font-size="16" font-weight="700">4</text>
|
||||
<text x="120" y="861" fill="#f8fafc" font-size="22" font-weight="700">Элемент: PREVIEW_PICTURE</text>
|
||||
<text x="80" y="902" fill="#dbeafe" font-size="17">CIBlockElement::Update вернул true.</text>
|
||||
<text x="80" y="928" fill="#dbeafe" font-size="17">Свежая карточка читает новый ID.</text>
|
||||
|
||||
<path d="M320 302 V378" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<path d="M320 520 V596" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<path d="M320 738 V814" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<text x="344" y="348" fill="#bae6fd" font-size="15" font-weight="700">отправить</text>
|
||||
<text x="344" y="566" fill="#bae6fd" font-size="15" font-weight="700">SaveFile</text>
|
||||
<text x="344" y="784" fill="#bae6fd" font-size="15" font-weight="700">Update</text>
|
||||
|
||||
<rect x="48" y="1000" width="544" height="76" rx="16" fill="#0b1220" stroke="#334155"/>
|
||||
<text x="76" y="1034" fill="#f8fafc" font-size="18" font-weight="700">Проверка после каждого перехода</text>
|
||||
<text x="76" y="1060" fill="#cbd5e1" font-size="15">Искать расхождение на стрелке, а не рисовать новый preview.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 960" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Замена legacy обработчика с проверкой и откатом</title>
|
||||
<desc id="desc">Последовательность безопасной замены: снимок элемента до изменения, выбор старого или нового writer для одного ID, повторная выборка. При расхождении сначала выключается новый маршрут, а поле восстанавливается только после проверки снимка.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#0f172a"/><stop offset="1" stop-color="#172554"/></linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="9" flood-color="#020617" flood-opacity=".36"/></filter>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto"><path d="M0,0 L12,0 L6,12 Z" fill="#7dd3fc"/></marker>
|
||||
<marker id="warn" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto"><path d="M0,0 L12,0 L6,12 Z" fill="#fbbf24"/></marker>
|
||||
</defs>
|
||||
<rect width="760" height="960" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="52" y="62" fill="#f8fafc" font-size="29" font-weight="700">Один элемент, проверка, откат</text>
|
||||
<text x="52" y="93" fill="#94a3b8" font-size="20">Откат маршрута и поля — разные действия.</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="116" y="142" width="528" height="114" rx="20" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="116" y="332" width="528" height="136" rx="20" fill="#0f3a53" stroke="#38bdf8"/>
|
||||
<rect x="116" y="544" width="528" height="104" rx="20" fill="#172554" stroke="#818cf8"/>
|
||||
<rect x="116" y="724" width="528" height="160" rx="20" fill="#3f3012" stroke="#fbbf24"/>
|
||||
</g>
|
||||
<text x="148" y="184" fill="#f8fafc" font-size="22" font-weight="700">1. Снимок до изменения</text>
|
||||
<text x="148" y="215" fill="#cbd5e1" font-size="20">ID · IBLOCK_ID · старый CODE · ожидаемый CODE</text>
|
||||
<text x="148" y="377" fill="#f8fafc" font-size="22" font-weight="700">2. Маршрут одного вызова</text>
|
||||
<text x="148" y="408" fill="#dbeafe" font-size="20">старый / новый writer · только выбранный ID</text>
|
||||
<text x="148" y="438" fill="#dbeafe" font-size="20">Update(CODE) без соседних полей</text>
|
||||
<text x="148" y="588" fill="#f8fafc" font-size="22" font-weight="700">3. Повторная выборка</text>
|
||||
<text x="148" y="619" fill="#e0e7ff" font-size="20">CODE сопоставлен с ожиданием</text>
|
||||
<text x="148" y="769" fill="#fef3c7" font-size="22" font-weight="700">4. При расхождении</text>
|
||||
<text x="148" y="800" fill="#fde68a" font-size="20">сначала выключить новый маршрут</text>
|
||||
<text x="148" y="830" fill="#fde68a" font-size="20">сверить снимок и текущее значение</text>
|
||||
<text x="148" y="860" fill="#fde68a" font-size="20">после этого решать вопрос данных</text>
|
||||
<path d="M380 256 V332" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<path d="M380 468 V544" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<path d="M380 648 V724" fill="none" stroke="#fbbf24" stroke-width="4" marker-end="url(#warn)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 880" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Защищённый шов вокруг обновления CODE в Bitrix</title>
|
||||
<desc id="desc">Вертикальная карта legacy-модуля: форма передаёт ID и CODE в выделенный CodeWriter. Он проверяет инфоблок, обновляет одно поле и читает элемент обратно. Шаблон и интеграция остаются за границей шва.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#111827"/><stop offset="1" stop-color="#172554"/></linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="9" flood-color="#020617" flood-opacity=".36"/></filter>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto"><path d="M0,0 L12,0 L6,12 Z" fill="#7dd3fc"/></marker>
|
||||
</defs>
|
||||
<rect width="760" height="880" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="52" y="62" fill="#f8fafc" font-size="29" font-weight="700">Маленький шов: только CODE</text>
|
||||
<text x="52" y="93" fill="#94a3b8" font-size="20">Форма и интеграция не входят в одну функцию.</text>
|
||||
<rect x="52" y="138" width="656" height="524" rx="26" fill="#0b1220" stroke="#38bdf8" stroke-width="2" stroke-dasharray="9 9"/>
|
||||
<text x="78" y="174" fill="#7dd3fc" font-size="20" font-weight="700">ЗАЩИЩЁННЫЙ ШОВ</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="198" y="206" width="364" height="100" rx="18" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="138" y="374" width="484" height="122" rx="18" fill="#0f3a53" stroke="#38bdf8"/>
|
||||
<rect x="198" y="560" width="364" height="70" rx="18" fill="#172554" stroke="#818cf8"/>
|
||||
</g>
|
||||
<text x="380" y="245" text-anchor="middle" fill="#f8fafc" font-size="22" font-weight="700">Вход формы</text>
|
||||
<text x="380" y="277" text-anchor="middle" fill="#cbd5e1" font-size="22">ID + новый CODE</text>
|
||||
<text x="380" y="416" text-anchor="middle" fill="#f8fafc" font-size="23" font-weight="700">ProductCodeWriter</text>
|
||||
<text x="380" y="447" text-anchor="middle" fill="#dbeafe" font-size="20">проверка инфоблока · Update(CODE)</text>
|
||||
<text x="380" y="473" text-anchor="middle" fill="#dbeafe" font-size="20">повторная выборка</text>
|
||||
<text x="380" y="603" text-anchor="middle" fill="#f8fafc" font-size="20" font-weight="700">Отчёт: changed / ошибка</text>
|
||||
<path d="M380 306 V374" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<path d="M380 496 V560" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<rect x="52" y="714" width="294" height="108" rx="18" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="414" y="714" width="294" height="108" rx="18" fill="#1e293b" stroke="#475569"/>
|
||||
<text x="80" y="756" fill="#f8fafc" font-size="20" font-weight="700">Шаблон</text>
|
||||
<text x="80" y="787" fill="#cbd5e1" font-size="20">вывод результата</text>
|
||||
<text x="442" y="756" fill="#f8fafc" font-size="20" font-weight="700">Интеграция</text>
|
||||
<text x="442" y="787" fill="#cbd5e1" font-size="20">следующий шаг</text>
|
||||
<path d="M286 662 C250 679, 214 685, 200 714" fill="none" stroke="#64748b" stroke-width="3" marker-end="url(#arrow)"/>
|
||||
<path d="M474 662 C510 679, 546 685, 560 714" fill="none" stroke="#64748b" stroke-width="3" marker-end="url(#arrow)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Смешанная ответственность старого save.php</title>
|
||||
<desc id="desc">Диаграмма показывает, что один save.php читает POST, меняет инфоблок, выводит HTML и запускает интеграцию. Поздняя ошибка не отменяет раннюю запись автоматически.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#18181b"/><stop offset="1" stop-color="#1e1b4b"/></linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="9" flood-color="#020617" flood-opacity=".4"/></filter>
|
||||
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto"><path d="M0,0 L12,0 L6,12 Z" fill="#7dd3fc"/></marker>
|
||||
<marker id="arrowRed" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto"><path d="M0,0 L12,0 L6,12 Z" fill="#fb7185"/></marker>
|
||||
</defs>
|
||||
<rect width="760" height="900" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="52" y="62" fill="#f8fafc" font-size="29" font-weight="700">Когда save.php делает всё сразу</text>
|
||||
<text x="52" y="93" fill="#a1a1aa" font-size="20">Ошибка формы не равна состоянию инфоблока.</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="180" y="146" width="400" height="138" rx="22" fill="#312e81" stroke="#818cf8" stroke-width="2"/>
|
||||
<rect x="68" y="418" width="274" height="120" rx="18" fill="#1e293b" stroke="#475569"/>
|
||||
<rect x="418" y="418" width="274" height="120" rx="18" fill="#0f3a53" stroke="#38bdf8"/>
|
||||
<rect x="68" y="682" width="274" height="120" rx="18" fill="#3f1d2e" stroke="#fb7185"/>
|
||||
<rect x="418" y="682" width="274" height="120" rx="18" fill="#3f1d2e" stroke="#fb7185"/>
|
||||
</g>
|
||||
<text x="380" y="197" text-anchor="middle" fill="#f8fafc" font-size="25" font-weight="700">save.php</text>
|
||||
<text x="380" y="228" text-anchor="middle" fill="#ddd6fe" font-size="20">POST · API · HTML</text>
|
||||
<text x="380" y="256" text-anchor="middle" fill="#ddd6fe" font-size="20">один файл, несколько состояний</text>
|
||||
<text x="205" y="462" text-anchor="middle" fill="#f8fafc" font-size="21" font-weight="700">Вход формы</text>
|
||||
<text x="205" y="493" text-anchor="middle" fill="#cbd5e1" font-size="22">ID · NAME · CODE</text>
|
||||
<text x="555" y="462" text-anchor="middle" fill="#f8fafc" font-size="21" font-weight="700">Инфоблок</text>
|
||||
<text x="555" y="493" text-anchor="middle" fill="#dbeafe" font-size="22">Update(CODE)</text>
|
||||
<text x="205" y="726" text-anchor="middle" fill="#f8fafc" font-size="21" font-weight="700">HTML-ответ</text>
|
||||
<text x="205" y="757" text-anchor="middle" fill="#fecdd3" font-size="20">сообщение формы</text>
|
||||
<text x="555" y="726" text-anchor="middle" fill="#f8fafc" font-size="21" font-weight="700">Интеграция</text>
|
||||
<text x="555" y="757" text-anchor="middle" fill="#fecdd3" font-size="20">отдельный эффект</text>
|
||||
<path d="M300 284 L224 418" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrowBlue)"/>
|
||||
<path d="M460 284 L536 418" fill="none" stroke="#7dd3fc" stroke-width="4" marker-end="url(#arrowBlue)"/>
|
||||
<path d="M205 538 V682" fill="none" stroke="#fb7185" stroke-width="4" marker-end="url(#arrowRed)"/>
|
||||
<path d="M555 538 V682" fill="none" stroke="#fb7185" stroke-width="4" marker-end="url(#arrowRed)"/>
|
||||
<text x="54" y="846" fill="#fda4af" font-size="20" font-weight="700">Поздний сбой не отменяет раннюю запись автоматически.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 1040" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Контракт legacy-формы с изображением</title>
|
||||
<desc id="desc">Вертикальная диаграмма разделяет браузерный preview, передачу multipart POST, серверный выбор действия и новое чтение карточки Bitrix.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#18181b"/>
|
||||
<stop offset="1" stop-color="#1f2937"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-15%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#030712" flood-opacity=".3"/>
|
||||
</filter>
|
||||
<marker id="yellow" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto">
|
||||
<path d="M0,0 L12,0 L6,12 Z" fill="#fbbf24"/>
|
||||
</marker>
|
||||
<marker id="blue" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto">
|
||||
<path d="M0,0 L12,0 L6,12 Z" fill="#60a5fa"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="640" height="1040" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="48" y="58" fill="#f9fafb" font-size="27" font-weight="700">Legacy-форма:</text>
|
||||
<text x="48" y="91" fill="#f9fafb" font-size="27" font-weight="700">preview не равен результату</text>
|
||||
<text x="48" y="122" fill="#9ca3af" font-size="16">Каждый сигнал имеет одну роль и одного владельца.</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="166" width="544" height="190" rx="20" fill="#111827" stroke="#4b5563"/>
|
||||
<rect x="48" y="460" width="544" height="210" rx="20" fill="#172554" stroke="#60a5fa"/>
|
||||
<rect x="48" y="776" width="544" height="150" rx="20" fill="#0f172a" stroke="#4f46e5"/>
|
||||
</g>
|
||||
|
||||
<text x="80" y="211" fill="#f9fafb" font-size="22" font-weight="700">1. Браузер и jQuery</text>
|
||||
<text x="80" y="251" fill="#fef3c7" font-size="18" font-weight="700">CATALOG_PREVIEW</text>
|
||||
<text x="80" y="277" fill="#d1d5db" font-size="16">Бинарный файл для multipart POST.</text>
|
||||
<text x="80" y="310" fill="#93c5fd" font-size="18" font-weight="700">.js-photo-state и preview</text>
|
||||
<text x="80" y="336" fill="#d1d5db" font-size="16">Только обратная связь для человека.</text>
|
||||
|
||||
<path d="M320 356 V460" fill="none" stroke="#fbbf24" stroke-width="4" marker-end="url(#yellow)"/>
|
||||
<text x="344" y="420" fill="#fde68a" font-size="15" font-weight="700">отправить FormData</text>
|
||||
|
||||
<text x="80" y="505" fill="#f9fafb" font-size="22" font-weight="700">2. PHP и Bitrix</text>
|
||||
<text x="80" y="547" fill="#bfdbfe" font-size="18" font-weight="700">Проверить multipart POST</text>
|
||||
<text x="80" y="573" fill="#dbeafe" font-size="16">Имя поля, upload error, размер файла.</text>
|
||||
<text x="80" y="610" fill="#bfdbfe" font-size="18" font-weight="700">Выбрать одну ветку</text>
|
||||
<text x="80" y="636" fill="#dbeafe" font-size="16">Новый файл, оставить или удалить.</text>
|
||||
|
||||
<path d="M320 670 V776" fill="none" stroke="#60a5fa" stroke-width="4" marker-end="url(#blue)"/>
|
||||
<text x="344" y="734" fill="#bfdbfe" font-size="15" font-weight="700">вернуть результат</text>
|
||||
|
||||
<text x="80" y="821" fill="#f9fafb" font-size="22" font-weight="700">3. Свежая карточка</text>
|
||||
<text x="80" y="861" fill="#c7d2fe" font-size="17">CIBlockElement::Update вернул true.</text>
|
||||
<text x="80" y="887" fill="#c7d2fe" font-size="17">Новое чтение подтверждает ID изображения.</text>
|
||||
|
||||
<rect x="48" y="956" width="544" height="46" rx="14" fill="#0b1220" stroke="#374151"/>
|
||||
<text x="76" y="986" fill="#e5e7eb" font-size="15">Критерий: один POST, одна ветка, затем новая страница.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Трасса фальшиво-зелёного сценария PHP</title>
|
||||
<desc id="desc">Unit-тест подменяет базу и HTTP-клиент, поэтому не видит ошибки конфигурации. Интеграционный маршрут проходит через PDO, тестовую базу, локальный callback и связывает шаги request ID.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#172942"/><stop offset="1" stop-color="#322f55"/></linearGradient>
|
||||
<marker id="arrowBlue" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10z" fill="#8ed7ff"/></marker>
|
||||
<marker id="arrowGreen" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10z" fill="#7ce3b9"/></marker>
|
||||
</defs>
|
||||
<rect width="720" height="900" rx="30" fill="url(#bg)"/>
|
||||
<text x="52" y="65" fill="#fff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Где появляется ложная зелень</text>
|
||||
<text x="52" y="102" fill="#c3d2e8" font-family="Arial, sans-serif" font-size="22">Сначала видим границу, потом её проверяем</text>
|
||||
<rect x="55" y="150" width="610" height="165" rx="20" fill="#414366" stroke="#d2c4ff" stroke-width="3"/>
|
||||
<text x="86" y="199" fill="#fff" font-family="Arial, sans-serif" font-size="27" font-weight="700">UNIT: заранее заданный успех</text>
|
||||
<text x="86" y="239" fill="#e5e0ff" font-family="Arial, sans-serif" font-size="21">Подмены не читают DSN, не делают SQL</text>
|
||||
<text x="86" y="273" fill="#e5e0ff" font-family="Arial, sans-serif" font-size="21">и не видят cURL или HTTP status.</text>
|
||||
<path d="M360 315 V378" stroke="#8ed7ff" stroke-width="5" marker-end="url(#arrowBlue)"/>
|
||||
<rect x="55" y="398" width="610" height="210" rx="20" fill="#1e4260" stroke="#8ed7ff" stroke-width="3"/>
|
||||
<text x="86" y="445" fill="#ddf3ff" font-family="Arial, sans-serif" font-size="27" font-weight="700">Короткая трасса</text>
|
||||
<rect x="92" y="477" width="110" height="64" rx="12" fill="#305d7d"/><text x="147" y="517" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="21" font-weight="700">config</text>
|
||||
<path d="M202 509 H255" stroke="#8ed7ff" stroke-width="4" marker-end="url(#arrowBlue)"/>
|
||||
<rect x="267" y="477" width="90" height="64" rx="12" fill="#28604f"/><text x="312" y="517" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="21" font-weight="700">PDO</text>
|
||||
<path d="M357 509 H410" stroke="#8ed7ff" stroke-width="4" marker-end="url(#arrowBlue)"/>
|
||||
<rect x="422" y="477" width="90" height="64" rx="12" fill="#5b4d87"/><text x="467" y="517" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="21" font-weight="700">cURL</text>
|
||||
<path d="M512 509 H555" stroke="#8ed7ff" stroke-width="4" marker-end="url(#arrowBlue)"/>
|
||||
<rect x="567" y="477" width="66" height="64" rx="12" fill="#2a6175"/><text x="600" y="517" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="19" font-weight="700">202</text>
|
||||
<text x="92" y="576" fill="#c6e3f4" font-family="Arial, sans-serif" font-size="19">TEST_ vars</text>
|
||||
<text x="267" y="576" fill="#c6e3f4" font-family="Arial, sans-serif" font-size="19">test DB</text>
|
||||
<text x="422" y="576" fill="#c6e3f4" font-family="Arial, sans-serif" font-size="19">local callback</text>
|
||||
<rect x="55" y="667" width="610" height="170" rx="20" fill="#14372f" stroke="#7ce3b9" stroke-width="3"/>
|
||||
<text x="86" y="716" fill="#e5fff3" font-family="Arial, sans-serif" font-size="27" font-weight="700">INTEGRATION: реальный маршрут</text>
|
||||
<text x="86" y="757" fill="#c6efdc" font-family="Arial, sans-serif" font-size="22">registration-test-42 связывает шаги</text>
|
||||
<text x="86" y="792" fill="#c6efdc" font-family="Arial, sans-serif" font-size="22">и оставляет контролируемые следы.</text>
|
||||
<path d="M360 608 V660" stroke="#7ce3b9" stroke-width="4" stroke-dasharray="9 8" marker-end="url(#arrowGreen)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Контракт интеграционного теста PHP с базой данных</title>
|
||||
<desc id="desc">PHPUnit передаёт данные в PDO-репозиторий, тот записывает и читает отдельную тестовую базу данных, после чего транзакция откатывается.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#11213a"/><stop offset="1" stop-color="#1d3655"/></linearGradient>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10z" fill="#84d5ff"/></marker>
|
||||
</defs>
|
||||
<rect width="720" height="900" rx="30" fill="url(#bg)"/>
|
||||
<text x="52" y="65" fill="#f3f8ff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Контракт: запись и чтение</text>
|
||||
<text x="52" y="102" fill="#bfd0e4" font-family="Arial, sans-serif" font-size="22">Один путь через PDO и отдельную test DB</text>
|
||||
<rect x="62" y="160" width="596" height="126" rx="18" fill="#203d60" stroke="#84d5ff" stroke-width="3"/>
|
||||
<text x="360" y="211" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="30" font-weight="700">PHPUnit</text>
|
||||
<text x="360" y="250" text-anchor="middle" fill="#d3efff" font-family="Arial, sans-serif" font-size="23">вход, ожидание, фикстура</text>
|
||||
<path d="M360 286 V350" stroke="#84d5ff" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<rect x="62" y="370" width="596" height="142" rx="18" fill="#183952" stroke="#72e0bb" stroke-width="3"/>
|
||||
<text x="360" y="425" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="30" font-weight="700">PDO-репозиторий</text>
|
||||
<text x="360" y="464" text-anchor="middle" fill="#cbf5e6" font-family="Arial, sans-serif" font-size="23">SQL, типы, преобразование</text>
|
||||
<path d="M360 512 V575" stroke="#84d5ff" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<rect x="62" y="595" width="596" height="142" rx="18" fill="#3c365d" stroke="#bdadff" stroke-width="3"/>
|
||||
<ellipse cx="186" cy="638" rx="57" ry="19" fill="#887ec2"/>
|
||||
<path d="M129 638 V690 C129 716 243 716 243 690 V638" fill="#645997"/>
|
||||
<ellipse cx="186" cy="690" rx="57" ry="19" fill="#645997"/>
|
||||
<text x="421" y="654" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="29" font-weight="700">test DB</text>
|
||||
<text x="421" y="695" text-anchor="middle" fill="#eeeaff" font-family="Arial, sans-serif" font-size="22">отдельная схема</text>
|
||||
<rect x="62" y="778" width="596" height="72" rx="16" fill="#10243a" stroke="#456e95" stroke-width="2"/>
|
||||
<text x="92" y="823" fill="#d8efff" font-family="Arial, sans-serif" font-size="23">config → INSERT → SELECT → rollback</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Граница unit и integration тестов PHP</title>
|
||||
<desc id="desc">Unit-тест заменяет репозиторий подставным объектом и проверяет правило сервиса. Integration-тест проходит через настоящий PDO или HTTP-адаптер к тестовому ресурсу.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#152943"/><stop offset="1" stop-color="#253350"/></linearGradient>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10z" fill="#8fd8ff"/></marker>
|
||||
</defs>
|
||||
<rect width="720" height="900" rx="30" fill="url(#bg)"/>
|
||||
<text x="52" y="65" fill="#f8fbff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Граница: правило и адаптер</text>
|
||||
<text x="52" y="102" fill="#bfd1e7" font-family="Arial, sans-serif" font-size="22">Подмена полезна, но не проверяет ресурс</text>
|
||||
<rect x="55" y="150" width="610" height="250" rx="20" fill="#1d3a59" stroke="#8fd8ff" stroke-width="3"/>
|
||||
<text x="86" y="197" fill="#d5efff" font-family="Arial, sans-serif" font-size="27" font-weight="700">UNIT</text>
|
||||
<rect x="88" y="228" width="158" height="86" rx="14" fill="#2e587b"/>
|
||||
<text x="167" y="279" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="23" font-weight="700">TestCase</text>
|
||||
<path d="M246 271 H321" stroke="#8fd8ff" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<rect x="335" y="228" width="145" height="86" rx="14" fill="#27634f"/>
|
||||
<text x="407" y="279" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">сервис</text>
|
||||
<path d="M480 271 H540" stroke="#d7c8ff" stroke-width="4" stroke-dasharray="9 8" marker-end="url(#arrow)"/>
|
||||
<rect x="550" y="228" width="85" height="86" rx="14" fill="#564b86"/>
|
||||
<text x="592" y="279" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">fake</text>
|
||||
<text x="86" y="361" fill="#b9d8f2" font-family="Arial, sans-serif" font-size="21">Проверяет локальное решение. SQL и HTTP остаются снаружи.</text>
|
||||
<rect x="55" y="455" width="610" height="328" rx="20" fill="#193f36" stroke="#78e1ba" stroke-width="3"/>
|
||||
<text x="86" y="503" fill="#cff9e6" font-family="Arial, sans-serif" font-size="27" font-weight="700">INTEGRATION</text>
|
||||
<rect x="88" y="548" width="158" height="86" rx="14" fill="#2e587b"/>
|
||||
<text x="167" y="599" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="23" font-weight="700">TestCase</text>
|
||||
<path d="M246 591 H320" stroke="#8fd8ff" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<rect x="335" y="548" width="145" height="86" rx="14" fill="#27634f"/>
|
||||
<text x="407" y="585" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="22" font-weight="700">PDO / cURL</text>
|
||||
<text x="407" y="612" text-anchor="middle" fill="#d5f7e8" font-family="Arial, sans-serif" font-size="18">адаптер</text>
|
||||
<path d="M480 591 H540" stroke="#78e1ba" stroke-width="4" marker-end="url(#arrow)"/>
|
||||
<rect x="550" y="548" width="85" height="86" rx="14" fill="#265b63"/>
|
||||
<text x="592" y="584" text-anchor="middle" fill="#fff" font-family="Arial, sans-serif" font-size="18" font-weight="700">test</text>
|
||||
<text x="592" y="611" text-anchor="middle" fill="#d6f4ff" font-family="Arial, sans-serif" font-size="18">DB/HTTP</text>
|
||||
<text x="86" y="688" fill="#c9f0df" font-family="Arial, sans-serif" font-size="21">Проверяет договор с контролируемым ресурсом.</text>
|
||||
<text x="86" y="726" fill="#a9d8c5" font-family="Arial, sans-serif" font-size="19">Один настоящий переход за тест — понятная причина падения.</text>
|
||||
<line x1="360" y1="400" x2="360" y2="455" stroke="#7890ad" stroke-width="3" stroke-dasharray="8 8"/>
|
||||
<text x="387" y="436" fill="#b8c9dc" font-family="Arial, sans-serif" font-size="18">граница подмены</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 1130" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Порядок jQuery и legacy-плагина в Webpack production</title>
|
||||
<desc id="desc">Вертикальная схема показывает правильный порядок через runtime, bootstrap с window.jQuery и runtime require плагина, а ниже плохой путь со статическим импортом.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#0f172a"/>
|
||||
<stop offset="1" stop-color="#312e81"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-15%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#020617" flood-opacity=".32"/>
|
||||
</filter>
|
||||
<marker id="good" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto">
|
||||
<path d="M0,0 L12,0 L6,12 Z" fill="#34d399"/>
|
||||
</marker>
|
||||
<marker id="bad" markerWidth="12" markerHeight="12" refX="6" refY="10" orient="auto">
|
||||
<path d="M0,0 L12,0 L6,12 Z" fill="#fb7185"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="640" height="1130" rx="28" fill="url(#bg)"/>
|
||||
<g font-family="Inter,Arial,sans-serif">
|
||||
<text x="48" y="58" fill="#f8fafc" font-size="27" font-weight="700">Webpack production:</text>
|
||||
<text x="48" y="91" fill="#f8fafc" font-size="27" font-weight="700">порядок до legacy-плагина</text>
|
||||
<text x="48" y="122" fill="#c4b5fd" font-size="16">ProvidePlugin не заменяет явный момент window.jQuery.</text>
|
||||
|
||||
<text x="48" y="170" fill="#a7f3d0" font-size="21" font-weight="700">Рабочий путь</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="198" width="544" height="116" rx="20" fill="#064e3b" stroke="#34d399"/>
|
||||
<rect x="48" y="374" width="544" height="116" rx="20" fill="#065f46" stroke="#34d399"/>
|
||||
<rect x="48" y="550" width="544" height="116" rx="20" fill="#047857" stroke="#34d399"/>
|
||||
<rect x="48" y="726" width="544" height="116" rx="20" fill="#064e3b" stroke="#34d399"/>
|
||||
</g>
|
||||
<text x="80" y="242" fill="#d1fae5" font-size="21" font-weight="700">1. runtime и vendors chunk</text>
|
||||
<text x="80" y="278" fill="#a7f3d0" font-size="16">В фактическом графе есть один jquery.</text>
|
||||
<path d="M320 314 V374" fill="none" stroke="#34d399" stroke-width="4" marker-end="url(#good)"/>
|
||||
|
||||
<text x="80" y="418" fill="#d1fae5" font-size="21" font-weight="700">2. bootstrap</text>
|
||||
<text x="80" y="454" fill="#a7f3d0" font-size="16">window.jQuery = $ и проверка экземпляра.</text>
|
||||
<path d="M320 490 V550" fill="none" stroke="#34d399" stroke-width="4" marker-end="url(#good)"/>
|
||||
|
||||
<text x="80" y="594" fill="#ecfdf5" font-size="21" font-weight="700">3. runtime require плагина</text>
|
||||
<text x="80" y="630" fill="#d1fae5" font-size="16">Плагин читает уже существующий глобал.</text>
|
||||
<path d="M320 666 V726" fill="none" stroke="#34d399" stroke-width="4" marker-end="url(#good)"/>
|
||||
|
||||
<text x="80" y="770" fill="#d1fae5" font-size="21" font-weight="700">4. ready handler формы</text>
|
||||
<text x="80" y="806" fill="#a7f3d0" font-size="16">DOM есть, метод плагина доступен один раз.</text>
|
||||
|
||||
<text x="48" y="900" fill="#fecdd3" font-size="21" font-weight="700">Плохой путь</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="48" y="928" width="544" height="120" rx="20" fill="#4c0519" stroke="#fb7185"/>
|
||||
</g>
|
||||
<text x="80" y="970" fill="#ffe4e6" font-size="21" font-weight="700">static import legacy-плагина</text>
|
||||
<text x="80" y="1006" fill="#fecdd3" font-size="16">Плагин исполняется раньше тела bootstrap.</text>
|
||||
<text x="80" y="1032" fill="#fecdd3" font-size="16">window.jQuery ещё пуст, форма падает в production.</text>
|
||||
|
||||
<rect x="48" y="1074" width="544" height="34" rx="12" fill="#111827" stroke="#4f46e5"/>
|
||||
<text x="70" y="1097" fill="#c7d2fe" font-size="13">Проверка: stats.json, чистый dist, глобал до require.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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 <revision-script.mjs>');
|
||||
if (!scriptArgument || !Number.isInteger(expectedCount) || expectedCount < 1) {
|
||||
throw new Error('Usage: node scripts/audit-editorial-draft.mjs <revision-script.mjs> [--expected-count=N]');
|
||||
}
|
||||
|
||||
const scriptPath = isAbsolute(scriptArgument)
|
||||
@@ -65,8 +70,8 @@ try {
|
||||
const imported = await import(pathToFileURL(scriptPath).href);
|
||||
const revisions = imported.revisions;
|
||||
|
||||
if (!Array.isArray(revisions) || revisions.length !== 3) {
|
||||
throw new Error('Module must export exactly three revisions');
|
||||
if (!Array.isArray(revisions) || revisions.length !== expectedCount) {
|
||||
throw new Error('Module must export exactly ' + expectedCount + ' revision(s)');
|
||||
}
|
||||
|
||||
if (JSON.stringify(cliRevisions) !== JSON.stringify(revisions)) {
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(code) {
|
||||
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||||
const body = rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('');
|
||||
return '<div class="table-scroll"><table><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map(({ title, url, note }) => (
|
||||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + title + '</a> — ' + note + '</li>'
|
||||
)).join('') + '</ul>';
|
||||
}
|
||||
|
||||
function visibleText(html) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
const bodyHtml = bodyParts.join('\n');
|
||||
const bodyLength = visibleText(bodyHtml).length;
|
||||
|
||||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||||
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||||
}
|
||||
|
||||
const contentHtml = [
|
||||
bodyHtml,
|
||||
heading('Проверяемые источники'),
|
||||
sourceList(sources),
|
||||
].join('\n');
|
||||
|
||||
const requiredFragments = [
|
||||
'<figure>',
|
||||
'<figcaption>',
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<pre><code>',
|
||||
'<ol>',
|
||||
'<h2>Проверяемые источники</h2>',
|
||||
];
|
||||
|
||||
for (const fragment of requiredFragments) {
|
||||
if (!contentHtml.includes(fragment)) {
|
||||
throw new Error(meta.slug + ': missing required fragment ' + fragment);
|
||||
}
|
||||
}
|
||||
|
||||
if ((contentHtml.match(/<h2>/g) || []).length < 6) {
|
||||
throw new Error(meta.slug + ': fewer than six sections');
|
||||
}
|
||||
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||||
}
|
||||
|
||||
return { ...meta, contentHtml, bodyLength };
|
||||
}
|
||||
|
||||
const bitrixFileInput = {
|
||||
title: '1С-Битрикс: FileInput',
|
||||
url: 'https://dev.1c-bitrix.ru/api_d7/bitrix/main/ui/fileinput/index.php',
|
||||
note: 'описание контрола, его методов createInstance, prepareFile и show, а также параметров загрузки',
|
||||
};
|
||||
|
||||
const bitrixSaveFile = {
|
||||
title: '1С-Битрикс: CFile::SaveFile',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/savefile.php?print=Y',
|
||||
note: 'метод сохраняет файловый массив и регистрирует его в b_file, возвращая числовой идентификатор',
|
||||
};
|
||||
|
||||
const bitrixMakeFileArray = {
|
||||
title: '1С-Битрикс: CFile::MakeFileArray',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/makefilearray.php?print=Y',
|
||||
note: 'формирует файловый массив, в том числе по ID существующего файла',
|
||||
};
|
||||
|
||||
const bitrixUpdate = {
|
||||
title: '1С-Битрикс: CIBlockElement::Update',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
|
||||
note: 'Update принимает массив полей, возвращает true или false, а текст ошибки остаётся в LAST_ERROR',
|
||||
};
|
||||
|
||||
const phpFiles = {
|
||||
title: 'PHP Manual: $_FILES',
|
||||
url: 'https://www.php.net/manual/en/reserved.variables.files.php',
|
||||
note: 'структура данных, переданных HTTP POST с файлом',
|
||||
};
|
||||
|
||||
const phpUploadedFile = {
|
||||
title: 'PHP Manual: move_uploaded_file',
|
||||
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
|
||||
note: 'функция работает только с файлом, который PHP признал HTTP POST upload; источник нужен для границы временного файла',
|
||||
};
|
||||
|
||||
const jqueryOn = {
|
||||
title: 'jQuery API: .on()',
|
||||
url: 'https://api.jquery.com/on/',
|
||||
note: 'делегированный обработчик работает на потомках существующего контейнера и может быть привязан с namespace',
|
||||
};
|
||||
|
||||
const jqueryReady = {
|
||||
title: 'jQuery API: .ready()',
|
||||
url: 'https://api.jquery.com/ready/',
|
||||
note: 'обработчик запускается, когда DOM готов к безопасному изменению',
|
||||
};
|
||||
|
||||
const webpackShimming = {
|
||||
title: 'webpack: Shimming',
|
||||
url: 'https://webpack.js.org/guides/shimming/',
|
||||
note: 'Webpack понимает модули, но старые библиотеки могут ожидать глобальные зависимости; globals следует оставлять только для нужной совместимости',
|
||||
};
|
||||
|
||||
const webpackProvide = {
|
||||
title: 'webpack: ProvidePlugin',
|
||||
url: 'https://webpack.js.org/plugins/provide-plugin/',
|
||||
note: 'ProvidePlugin подставляет модуль для свободного идентификатора в скомпилированном модуле; отдельно показано сопоставление window.jQuery',
|
||||
};
|
||||
|
||||
const webpackV4Migration = {
|
||||
title: 'webpack: migration to v4',
|
||||
url: 'https://webpack.js.org/migrate/4/',
|
||||
note: 'в webpack 4 CommonsChunkPlugin заменён настройкой optimization.splitChunks',
|
||||
};
|
||||
|
||||
const webpackSplitChunks = {
|
||||
title: 'webpack: SplitChunksPlugin',
|
||||
url: 'https://webpack.js.org/plugins/split-chunks-plugin/',
|
||||
note: 'в webpack 4 общие модули извлекаются правилами splitChunks, что меняет состав начальных ассетов',
|
||||
};
|
||||
|
||||
const imageOwnershipArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2018-10-mechanism-image-workflow',
|
||||
title: 'Bitrix API. Форма с изображением: где заканчивается редактор и начинается файл',
|
||||
categories: ['Bitrix', 'PHP', 'Формы'],
|
||||
cover: '/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
|
||||
excerpt: 'Картинка появляется в форме, но после сохранения карточка остаётся прежней. Разбираем четыре состояния файла и ставим проверку на границе между контролом, b_file и элементом инфоблока.',
|
||||
readingMinutes: 10,
|
||||
},
|
||||
[
|
||||
paragraph('В форме картинка уже видна, а после сохранения у товара остаётся старая обложка. Цена ошибки не только в пустом поле: оператор уверен, что обновил карточку, а каталог продолжает показывать не тот товар.'),
|
||||
paragraph('В октябрьском проекте я бы не начинал с повторного вызова редактора. Сначала разделил бы четыре состояния: выбор файла в браузере, данные формы, запись файла в Bitrix и ссылка на неё в элементе инфоблока. Пока они названы одним словом «картинка», причина прячется между двумя успешными шагами.'),
|
||||
heading('Один экран формы не означает один объект'),
|
||||
paragraph('Контрол <code>\\Bitrix\\Main\\UI\\FileInput</code> формирует интерфейс выбора и загрузки. Его <code>show()</code> возвращает разметку и JavaScript для страницы, но сам показ контрола не доказывает, что файл уже связан с нужным элементом. Официальная документация отдельно называет <code>prepareFile()</code> как способ получить файловый массив для дальнейшей обработки. Значит, после интерфейса всё равно остаётся серверный путь.'),
|
||||
paragraph('Для диагностики я записываю не красивый preview, а идентификаторы и границы. У выбранного в браузере файла нет постоянного Bitrix ID. У строки из <code>b_file</code> уже есть ID, но она может быть ни с чем не связана. У поля <code>PREVIEW_PICTURE</code> элемента есть отдельное состояние, и оно изменится только после успешного <code>CIBlockElement::Update()</code>.'),
|
||||
dataTable(
|
||||
['Состояние', 'Кто им владеет', 'Что считаю доказательством', 'Следующий шаг'],
|
||||
[
|
||||
['Файл выбран в диалоге', 'браузер и DOM формы', 'видно имя, размер или preview до отправки', 'не считать это сохранением'],
|
||||
['Файловый массив в запросе', 'PHP-обработчик', '<code>$_FILES</code> содержит ожидаемое поле и <code>UPLOAD_ERR_OK</code>', 'проверить лимит и передать в Bitrix'],
|
||||
['Файл зарегистрирован', 'таблица <code>b_file</code>', '<code>CFile::SaveFile()</code> вернул положительный ID', 'получить файл по ID и сохранить связь'],
|
||||
['Изображение карточки изменено', 'элемент инфоблока', '<code>CIBlockElement::Update()</code> вернул <code>true</code>', 'запросить элемент заново и открыть карточку'],
|
||||
],
|
||||
),
|
||||
figure(
|
||||
'/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
|
||||
'Карта состояний изображения: браузерный File, поле формы, зарегистрированный CFile ID и PREVIEW_PICTURE элемента инфоблока. Между этапами показаны POST, CFile SaveFile и CIBlockElement Update.',
|
||||
'Preview нужен пользователю, но подтверждённой считается только связь между ID файла и полем элемента.',
|
||||
),
|
||||
heading('Контрол показываю с явными ограничениями'),
|
||||
paragraph('Ниже не универсальный шаблон, а минимальная точка проверки для формы с одной картинкой. Идентификатор текущего файла приходит из уже сохранённой карточки. Поле формы получает осмысленное имя, а ограничения задаются рядом с контролом. Если на старой установке отсутствует этот класс или часть источников FileInput отключена, сначала проверяю версию модуля main и права пользователя, а не копирую настройки вслепую.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
|
||||
'',
|
||||
'$currentFileId = (int) $arResult[\'PREVIEW_PICTURE\'];',
|
||||
'',
|
||||
'echo FileInput::createInstance(array(',
|
||||
' \'id\' => \'catalog_preview\',',
|
||||
' \'name\' => \'CATALOG[PREVIEW_PICTURE]\',',
|
||||
' \'upload\' => true,',
|
||||
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
|
||||
' \'medialib\' => false,',
|
||||
' \'fileDialog\' => true,',
|
||||
' \'cloud\' => false,',
|
||||
' \'delete\' => true,',
|
||||
' \'edit\' => true,',
|
||||
' \'maxCount\' => 1,',
|
||||
' \'maxSize\' => 5 * 1024 * 1024,',
|
||||
'))->show($currentFileId);',
|
||||
].join('\n')),
|
||||
paragraph('Значение <code>maxSize</code> помогает пользователю раньше увидеть предел, но не заменяет серверную проверку. DOM может быть создан старым шаблоном, обновлён Ajax-ом или отправлен вручную. Поэтому имя поля и фактический массив, пришедший на сервер, я сверяю в тестовом запросе. В этом месте удобнее увидеть несовпадение <code>CATALOG[PREVIEW_PICTURE]</code> и обработчика, чем позже искать «потерянный» файл.'),
|
||||
heading('Сохраняю файл до привязки и проверяю оба ответа'),
|
||||
paragraph('В учебном обработчике ниже файл приходит из обычного multipart-поля. В проекте с FileInput вместо <code>$_FILES</code> может оказаться результат его подготовки, но контракт одинаковый: на входе — файловый массив, на выходе — подтверждённый ID или понятная ошибка. Я не сохраняю путь из браузера и не записываю имя файла как связь с карточкой.'),
|
||||
codeBlock([
|
||||
'function saveCatalogImage(array $upload)',
|
||||
'{',
|
||||
' if (($upload[\'error\'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {',
|
||||
' throw new RuntimeException(\'Image upload did not finish\');',
|
||||
' }',
|
||||
'',
|
||||
' if ((int) ($upload[\'size\'] ?? 0) < 1 || (int) $upload[\'size\'] > 5 * 1024 * 1024) {',
|
||||
' throw new RuntimeException(\'Image size is outside the form limit\');',
|
||||
' }',
|
||||
'',
|
||||
' $upload[\'MODULE_ID\'] = \'catalog\';',
|
||||
' $fileId = (int) CFile::SaveFile($upload, \'catalog\');',
|
||||
'',
|
||||
' if ($fileId < 1 || !CFile::GetFileArray($fileId)) {',
|
||||
' throw new RuntimeException(\'Bitrix did not register the uploaded file\');',
|
||||
' }',
|
||||
'',
|
||||
' return $fileId;',
|
||||
'}',
|
||||
'',
|
||||
'$fileId = saveCatalogImage($_FILES[\'CATALOG_PREVIEW\']);',
|
||||
].join('\n')),
|
||||
paragraph('Метод <code>CFile::SaveFile()</code> регистрирует файл в <code>b_file</code> и возвращает числовой ID. Проверка через <code>CFile::GetFileArray()</code> здесь не украшение: она отделяет случай «обработчик получил форму» от случая «у нас есть объект, на который можно ссылаться». На старом проекте я также сохраняю в закрытый лог ID элемента, ID файла и текст <code>LAST_ERROR</code>, но не складываю в журнал сам файл или персональные поля формы.'),
|
||||
codeBlock([
|
||||
'$element = new CIBlockElement();',
|
||||
'$picture = CFile::MakeFileArray($fileId);',
|
||||
'',
|
||||
'$updated = $element->Update($elementId, array(',
|
||||
' \'PREVIEW_PICTURE\' => $picture,',
|
||||
'));',
|
||||
'',
|
||||
'if (!$updated) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('Для обновления изображения инфоблока нужен файловый массив. <code>CFile::MakeFileArray()</code> умеет собрать его по существующему ID, а <code>CIBlockElement::Update()</code> возвращает <code>false</code> и оставляет текст ошибки в <code>LAST_ERROR</code>. Это две разные проверки; положительный <code>$fileId</code> не делает обновление элемента успешным сам по себе.'),
|
||||
heading('Проверяю путь в том порядке, в котором он ломается'),
|
||||
orderedList([
|
||||
'Открываю карточку с известным текущим ID картинки и отмечаю его до изменения.',
|
||||
'Выбираю небольшой тестовый JPEG и в браузерной сетевой вкладке сверяю имя поля и ответ отправки формы.',
|
||||
'На сервере временно фиксирую только код upload-ошибки, ID элемента и ID созданного файла.',
|
||||
'После <code>Update()</code> повторно читаю <code>PREVIEW_PICTURE</code> у этого же элемента, а не доверяю старому <code>$arResult</code>.',
|
||||
'Открываю карточку новым запросом в браузере и проверяю, что URL изображения указывает на ожидаемый файл.',
|
||||
'Только после этого удаляю временную диагностику или оставляю безопасный лог ошибки для следующего случая.',
|
||||
]),
|
||||
heading('Не путаю замену с удалением'),
|
||||
paragraph('Самый неприятный крайний случай — форма отправлена без нового файла. Для одной карточки это может означать «сохранить старую картинку», а флаг удаления означает противоположное. Нельзя получать это решение из пустого preview в DOM: пустой preview может появиться из-за перерисовки формы. Политику формулирую явно: нет нового файла и нет флага удаления — поле остаётся как было; новый файл — заменяем после успешного сохранения; удаление — передаём в Bitrix отдельным согласованным полем.'),
|
||||
paragraph('Если загрузка и редактирование сделаны в два HTTP-запроса, появляется ещё одна граница. Пользователь может закрыть вкладку после первого запроса. Тогда созданный ID не должен автоматически становиться картинкой чужого элемента. В старой системе достаточно хранить ID в сессии или в черновике, сверять владельца при финальном сохранении и отдельно убирать неиспользованные файлы по согласованному регламенту. Это не повод усложнять маленькую форму очередями; это повод не считать временный файл завершённым результатом.'),
|
||||
heading('Ограничения примера'),
|
||||
paragraph('Здесь не задан общий список допустимых MIME-типов и размеров картинки: он зависит от каталога, старой версии Bitrix и требований редакторов. Ограничение в контроле не защищает сервер, поэтому реальные проверки типа, размера, прав и пределов PHP надо добавлять в обработчик. Также не стоит переносить пример в свойство типа файл без проверки формата <code>PROPERTY_VALUES</code>: документация <code>CIBlockElement::Update()</code> отдельно оговаривает работу с файловыми свойствами.'),
|
||||
heading('Итог'),
|
||||
paragraph('Редактор отвечает за выбор и preview, <code>CFile</code> — за зарегистрированный файл, а элемент инфоблока — за ссылку на него. Если проверить каждую передачу отдельно, «картинка была в форме» перестаёт быть ложным признаком готовности. В следующей правке достаточно повторить один тестовый POST и сравнить ID файла с <code>PREVIEW_PICTURE</code> после свежего чтения элемента.'),
|
||||
],
|
||||
[bitrixFileInput, bitrixSaveFile, bitrixMakeFileArray, bitrixUpdate, phpFiles, phpUploadedFile],
|
||||
);
|
||||
|
||||
const imageFormArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2018-10-field-image-workflow',
|
||||
title: 'Bitrix API. Legacy-форма с редактором изображения: разбор типичной ошибки',
|
||||
categories: ['Bitrix', 'PHP', 'jQuery', 'Формы'],
|
||||
cover: '/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
|
||||
excerpt: 'Разбор формы, где preview обновляется, а в карточку попадает старый файл. Фиксируем контракт между jQuery, FileInput и серверным сохранением, чтобы не путать DOM с подтверждённым ID.',
|
||||
readingMinutes: 11,
|
||||
},
|
||||
[
|
||||
paragraph('Редактор изображения показывает новую фотографию, но после отправки формы карточка снова открывается со старой. Цена ошибки заметна не сразу: менеджер повторяет загрузку, а в файловом хранилище появляются лишние записи без понятной связи с товаром.'),
|
||||
paragraph('В такой legacy-форме я не пытаюсь заставить jQuery «запомнить картинку». Нужно решить, что именно передаёт форма: новый бинарный файл, ID уже существующего файла или команду удалить старый. Preview — только экранный сигнал. Сервер должен получить одно из этих состояний и вернуть результат, который форма может показать без догадок.'),
|
||||
heading('Фиксирую контракт формы до правки плагина'),
|
||||
paragraph('Перед работой я выписываю имена полей и один реальный POST. Старые шаблоны часто держат одновременно обычный <code>input type=file</code>, скрытый <code>PHOTO_ID</code> и HTML редактора. Если после Ajax-перерисовки в документе остаются два поля с одинаковым <code>name</code>, браузер отправит оба, а обработчик выберет не то значение. Поэтому в контракте у каждого поля одна роль.'),
|
||||
dataTable(
|
||||
['Поле или сигнал', 'Значение', 'Владелец', 'Что означает на сервере'],
|
||||
[
|
||||
['<code>CATALOG_PREVIEW</code>', 'бинарный файл из multipart POST', 'браузер до отправки', 'кандидат на новую картинку'],
|
||||
['<code>KEEP_PICTURE</code>', '0 или 1', 'форма и сценарий редактирования', 'сохраняем существующую картинку, если нового файла нет'],
|
||||
['<code>DELETE_PICTURE</code>', '0 или 1', 'явное действие пользователя', 'запрашиваем удаление, не выводим его из пустого DOM'],
|
||||
['<code>.js-photo-state</code>', 'текст статуса', 'jQuery', 'не попадает в модель данных и не является ID файла'],
|
||||
],
|
||||
),
|
||||
figure(
|
||||
'/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
|
||||
'Контракт legacy-формы: выбранный файл живёт в multipart POST, preview и статус принадлежат DOM, а сервер возвращает зарегистрированный ID и результат обновления карточки.',
|
||||
'У preview нет права менять карточку; его задача — показать пользователю, какой файл выбран до подтверждения сервера.',
|
||||
),
|
||||
heading('Не прячу имена полей внутри редактора'),
|
||||
paragraph('Сначала оставляю контрол и обработчик читаемыми. В примере ниже FileInput рисует интерфейс для одного изображения, а вокруг него есть контейнер для статуса. Если конкретная версия Bitrix возвращает файл через свой формат, я проверяю его отдельным тестовым POST и передаю дальше как файловый массив. Не подменяю это знание строкой из скрытого поля.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
|
||||
'',
|
||||
'$currentId = (int) $arResult[\'PREVIEW_PICTURE\'];',
|
||||
'?>',
|
||||
'<form id="catalog-photo-form" method="post" enctype="multipart/form-data">',
|
||||
' <div class="js-photo-editor">',
|
||||
' <?php',
|
||||
' echo FileInput::createInstance(array(',
|
||||
' \'id\' => \'catalog_preview\',',
|
||||
' \'name\' => \'CATALOG_PREVIEW\',',
|
||||
' \'upload\' => true,',
|
||||
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
|
||||
' \'maxCount\' => 1,',
|
||||
' \'maxSize\' => 5 * 1024 * 1024,',
|
||||
' \'delete\' => true,',
|
||||
' ))->show($currentId);',
|
||||
' ?>',
|
||||
' </div>',
|
||||
' <label><input type="checkbox" name="KEEP_PICTURE" value="1" checked> оставить текущую картинку</label>',
|
||||
' <label><input type="checkbox" name="DELETE_PICTURE" value="1"> удалить картинку</label>',
|
||||
' <p class="js-photo-state" aria-live="polite"></p>',
|
||||
' <button type="submit">Сохранить</button>',
|
||||
'</form>',
|
||||
].join('\n')),
|
||||
paragraph('Контрол FileInput доступен в D7-ядре и умеет формировать HTML и JavaScript. Но значение <code>name</code> — часть договора с PHP, а не косметика. После первой подстановки шаблона я смотрю исходный HTML и реальный Request Payload: имя, число file-полей, код ответа и наличие <code>enctype=multipart/form-data</code>. Если форма отправляется Ajax-ом, проверяю, что код строит <code>FormData</code>, а не сериализует только текстовые inputs.'),
|
||||
heading('jQuery показывает состояние, но не делает файл сохранённым'),
|
||||
paragraph('Устаревший шаблон может переотрисовать блок формы через <code>.html()</code>. Прямой обработчик на старом input после этого исчезнет, а повторная инициализация способна добавить второй обработчик. Я привязываю событие к стабильному контейнеру и использую namespace: перед повторной инициализацией снимаю именно свой обработчик. В браузере это даёт один статус выбора и не меняет серверную модель.'),
|
||||
codeBlock([
|
||||
'(function ($) {',
|
||||
' function bindPhotoForm(root) {',
|
||||
' var $root = $(root);',
|
||||
'',
|
||||
' $root.off(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\')',
|
||||
' .on(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\', function () {',
|
||||
' var file = this.files && this.files[0];',
|
||||
' var message = file',
|
||||
' ? \'Выбран файл: \' + file.name + \'. Сохранение ещё не выполнено.\'',
|
||||
' : \'Новый файл не выбран.\';',
|
||||
'',
|
||||
' $root.find(\'.js-photo-state\').text(message);',
|
||||
' $root.find(\'input[name=KEEP_PICTURE]\').prop(\'checked\', !file);',
|
||||
' $root.find(\'input[name=DELETE_PICTURE]\').prop(\'checked\', false);',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' $(function () {',
|
||||
' bindPhotoForm(document);',
|
||||
' });',
|
||||
'}(jQuery));',
|
||||
].join('\n')),
|
||||
paragraph('Метод <code>.on()</code> с селектором делегирует событие от потомка к уже существующему контейнеру. Это подходит для полей, которые появятся после перерисовки. Обработчик выше намеренно не кладёт имя или data URL в <code>PHOTO_ID</code>: такой ID существует только после серверного шага. Если нужны размеры картинки для preview, их можно показать рядом, но финальную проверку и привязку оставляю обработчику.'),
|
||||
heading('На сервере выбираю ровно один путь'),
|
||||
paragraph('Для формы редактирования полезно свести три пользовательских действия к трём веткам. Новый файл важнее флага «оставить»; явное удаление нельзя смешивать с новым файлом в одном запросе. При конфликте возвращаю ошибку формы, а не выбираю вариант по порядку полей. Это проще объяснить оператору и проще проверить через один POST.'),
|
||||
codeBlock([
|
||||
'function updatePreviewPicture($elementId, array $post, array $files)',
|
||||
'{',
|
||||
' $hasNewFile = isset($files[\'CATALOG_PREVIEW\'])',
|
||||
' && ($files[\'CATALOG_PREVIEW\'][\'error\'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;',
|
||||
' $deleteRequested = ($post[\'DELETE_PICTURE\'] ?? \'\') === \'1\';',
|
||||
'',
|
||||
' if ($hasNewFile && $deleteRequested) {',
|
||||
' throw new RuntimeException(\'Choose a new picture or deletion, not both\');',
|
||||
' }',
|
||||
'',
|
||||
' if (!$hasNewFile && !$deleteRequested) {',
|
||||
' return; // карточка сохраняет текущую картинку',
|
||||
' }',
|
||||
'',
|
||||
' $fields = array();',
|
||||
' if ($hasNewFile) {',
|
||||
' $fields[\'PREVIEW_PICTURE\'] = $files[\'CATALOG_PREVIEW\'];',
|
||||
' } else {',
|
||||
' $fields[\'PREVIEW_PICTURE\'] = array(\'del\' => \'Y\');',
|
||||
' }',
|
||||
'',
|
||||
' $element = new CIBlockElement();',
|
||||
' if (!$element->Update((int) $elementId, $fields)) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('Это минимальный сценарий именно для поля изображения элемента. Если приложение сначала создаёт отдельную запись в <code>b_file</code>, я храню возвращённый ID в серверной сессии или черновике и при финальном сохранении снова проверяю права на элемент. Для повторного использования существующего файла документация Bitrix предлагает <code>CFile::MakeFileArray()</code>, принимающий и ID файла. Важно не смешивать эту ветку с «пользователь выбрал файл, но ещё не отправил форму».'),
|
||||
heading('Проверяю не только счастливый путь'),
|
||||
orderedList([
|
||||
'Открываю существующую карточку, фиксирую текущий ID изображения и отправляю форму без нового файла: ID должен остаться прежним.',
|
||||
'Выбираю небольшой тестовый файл, проверяю один POST с multipart-частью и убеждаюсь, что серверный ответ содержит успешный результат обновления.',
|
||||
'Перезагружаю страницу отдельным запросом; preview и URL картинки должны соответствовать новому значению элемента.',
|
||||
'Нажимаю «удалить» без нового файла и проверяю, что обработчик получает явный флаг, а не выводит удаление из пустого preview.',
|
||||
'Имитирую Ajax-перерисовку контейнера и ещё раз меняю файл: статус должен смениться один раз, без двойного обработчика.',
|
||||
'Отправляю новый файл вместе с удалением и ожидаю понятную ошибку формы, а не неявный выбор одной ветки.',
|
||||
]),
|
||||
heading('Где legacy-интеграция чаще всего обманывает'),
|
||||
paragraph('Первый обман — вызывать <code>serialize()</code> для формы с файлом. Метод собирает текстовые поля и не переносит бинарное содержимое; для Ajax нужен <code>FormData</code> и правильные параметры запроса. Второй — считать, что серверный <code>200</code> подтвердил картинку: обработчик мог вернуть HTML ошибки или не обновить элемент. Третий — брать «последний созданный файл» из базы. В форме одновременно работают люди, поэтому связь должна идти из конкретного запроса и конкретного элемента.'),
|
||||
paragraph('Ещё один риск связан с повторной инициализацией. Namespace в <code>change.photoWorkflow</code> даёт узкую очистку только нашего события; нельзя заменять его общим <code>off(\'change\')</code>, потому что так легко сломать чужой плагин на той же форме. В 2018 году это особенно важно для старых шаблонов, где порядок подключения JavaScript не документирован.'),
|
||||
heading('Ограничения и следующий шаг'),
|
||||
paragraph('Пример не определяет политику доступа, допустимые расширения и обработку больших изображений. Их надо связать с ролью пользователя, настройками PHP и правилами каталога на конкретной установке. Если FileInput уже загружает файл отдельным действием, не копируйте ветку с <code>$_FILES</code>: сначала посмотрите, какое значение возвращает контрол и кто владеет временным ID.'),
|
||||
paragraph('Критерий готовности простой: после новой загрузки форма делает один понятный запрос, <code>Update()</code> проходит, а свежая страница показывает новое изображение. После сохранения без файла старая картинка остаётся. Этого достаточно, чтобы следующая правка формы не превратила DOM-preview в ложное подтверждение данных.'),
|
||||
],
|
||||
[bitrixFileInput, bitrixUpdate, bitrixMakeFileArray, jqueryOn, jqueryReady, phpFiles],
|
||||
);
|
||||
|
||||
const jqueryWebpackArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2019-01-mechanism-jquery-webpack',
|
||||
title: 'jQuery в Webpack. Почему legacy-плагин ломается только в production',
|
||||
categories: ['JavaScript', 'Webpack', 'jQuery'],
|
||||
cover: '/assets/editorial/2019/webpack-jquery-order-2019.svg',
|
||||
excerpt: 'В development маска работает, а после production-сборки читает undefined вместо window.jQuery. Разбираем порядок выполнения модуля, назначение ProvidePlugin и проверку графа ассетов.',
|
||||
readingMinutes: 11,
|
||||
},
|
||||
[
|
||||
paragraph('В development старая маска телефона работает, а после production-сборки браузер сообщает, что <code>window.jQuery</code> не определён. Цена ошибки — релиз с формой, которую нельзя заполнить, хотя локальный сервер и привычный исходный код выглядели исправными.'),
|
||||
paragraph('В январе 2019 года я бы не лечил этот сбой таймаутом. У legacy-плагина есть жёсткое ожидание: в момент его выполнения должна существовать конкретная глобальная переменная. У Webpack другая модель: он собирает модули и может вынести общую зависимость в отдельный chunk. Нужно проверить, кто создаёт <code>window.jQuery</code>, когда это происходит и какой production-asset выполняет плагин.'),
|
||||
heading('Почему development даёт ложную уверенность'),
|
||||
paragraph('На локальном сервере плагин нередко случайно получает jQuery из старого тега <code>script</code>, из общего layout или из более простого bundle. Production меняет условия: включается минификация, меняется имя файлов, а Webpack 4 может выделить общие зависимости через <code>optimization.splitChunks</code>. Само выделение не является ошибкой. Ошибка появляется, когда HTML подключает ассеты не тем способом или bootstrap-код запускает плагин до назначения глобала.'),
|
||||
paragraph('Поэтому фиксирую минимальный пример из трёх частей: entry, который создаёт глобал; legacy-плагин, который читает его при загрузке; и production HTML с фактическими тегами script. Пока в диагностике есть только исходники, а собранного <code>dist</code> нет, порядок исполнения остаётся предположением.'),
|
||||
dataTable(
|
||||
['Наблюдение', 'Вероятная причина', 'Короткая проверка', 'Действие'],
|
||||
[
|
||||
['В dev всё работает, в production — <code>undefined</code>', 'layout подключает локальный jQuery раньше bundle', 'открыть Network и сравнить production script tags', 'убрать случайный CDN-скрипт или сделать порядок явным'],
|
||||
['В модуле доступен <code>$</code>, но плагин читает <code>window.jQuery</code>', 'ProvidePlugin обслуживает свободный идентификатор, а не скрытую договорённость плагина', 'поставить остановку перед загрузкой плагина и проверить <code>window.jQuery</code>', 'назначить глобал до runtime require плагина'],
|
||||
['После splitChunks два разных jQuery', 'одна копия пришла из layout, другая — из bundle', 'проверить modules со словом <code>jquery</code> в stats.json', 'оставить один владелец и проверить граф production-сборки'],
|
||||
['Форма ломается после кеша', 'HTML ссылается на старый набор hashed-ассетов', 'сравнить HTML и имена файлов текущего dist', 'публиковать HTML и ассеты как один артефакт'],
|
||||
],
|
||||
),
|
||||
figure(
|
||||
'/assets/editorial/2019/webpack-jquery-order-2019.svg',
|
||||
'Временная шкала production-загрузки: runtime, vendors chunk с jQuery, bootstrap назначает window.jQuery, затем require запускает legacy-плагин и форму. Красным отмечен плохой вариант со статическим импортом плагина до тела bootstrap.',
|
||||
'Для старого плагина важен не только факт установки jQuery, но момент, в который он читает window.jQuery.',
|
||||
),
|
||||
heading('Разделяю свободный идентификатор и глобальный объект'),
|
||||
paragraph('Webpack описывает <code>ProvidePlugin</code> как способ автоматически подставить модуль, когда компилятор встречает свободный идентификатор в скомпилированном модуле. Конфигурация с <code>$</code> и <code>jQuery</code> помогает нашему исходному коду, где они используются без import. Но старый плагин может вообще не иметь свободного идентификатора: он может обратиться именно к <code>window.jQuery</code> во время выполнения своего файла. Это другой контракт, его нельзя проверить поиском по собственным модулям.'),
|
||||
paragraph('Документация Webpack отдельно показывает сопоставление <code>window.jQuery</code> для библиотек с такой зависимостью. На старом проекте я всё равно предпочитаю маленький bootstrap-модуль с явным присваиванием: в нём видно владельца глобала и точку, после которой разрешено запускать plugin. Это не идеальная модульная архитектура, а ограниченный шов для уже существующего кода.'),
|
||||
codeBlock([
|
||||
'const webpack = require(\'webpack\');',
|
||||
'',
|
||||
'module.exports = {',
|
||||
' mode: \'production\',',
|
||||
' entry: {',
|
||||
' site: \'./src/bootstrap-legacy.js\',',
|
||||
' },',
|
||||
' plugins: [',
|
||||
' new webpack.ProvidePlugin({',
|
||||
' $: \'jquery\',',
|
||||
' jQuery: \'jquery\',',
|
||||
' }),',
|
||||
' ],',
|
||||
' optimization: {',
|
||||
' splitChunks: { chunks: \'all\' },',
|
||||
' },',
|
||||
'};',
|
||||
].join('\n')),
|
||||
paragraph('Эта настройка не даёт права подключить plugin в любом месте и ожидать правильный момент. Она лишь делает <code>$</code> и <code>jQuery</code> доступными там, где Webpack анализирует их как свободные имена. Глобальный объект, порядок загрузки документа и исполнение файлов остаются отдельной частью расследования.'),
|
||||
heading('Не ставлю статический import перед созданием глобала'),
|
||||
paragraph('В этом месте легко написать читающийся, но неверный код. Статические <code>import</code> описывают зависимости модуля до того, как выполнится его тело. Если legacy-плагин читает <code>window.jQuery</code> сразу при инициализации, он может сработать раньше строки с присваиванием. В примере ниже сначала настраивается глобал, затем плагин подключается через runtime <code>require()</code>.'),
|
||||
codeBlock([
|
||||
'import $ from \'jquery\';',
|
||||
'',
|
||||
'function exposeLegacyJQuery(jq) {',
|
||||
' if (window.jQuery && window.jQuery !== jq) {',
|
||||
' throw new Error(\'Two jQuery instances reached the page\');',
|
||||
' }',
|
||||
'',
|
||||
' window.$ = jq;',
|
||||
' window.jQuery = jq;',
|
||||
'}',
|
||||
'',
|
||||
'exposeLegacyJQuery($);',
|
||||
'require(\'inputmask/dist/jquery.inputmask\');',
|
||||
'require(\'./legacy-form\');',
|
||||
].join('\n')),
|
||||
paragraph('Здесь <code>require()</code> выбран не потому, что он современнее import, а потому, что его вызов стоит после <code>exposeLegacyJQuery()</code> в runtime-порядке текущего модуля. Если плагин не читает глобал при загрузке, такой шов может быть не нужен. Но в конкретном production-сбое сначала проверяю это предположение на минимальном плагине и только затем меняю конфигурацию всего проекта.'),
|
||||
heading('Собираю production-доказательство, а не только скрин консоли'),
|
||||
paragraph('После исправления я запускаю production-сборку с JSON-статистикой. В stats-файле ищу модули jQuery и связь с entry, а в браузере смотрю Network и порядок инициаторов. Цель не в том, чтобы вручную угадать хеш vendor-файла: порядок должен обеспечивать runtime Webpack или генератор HTML, а не строка с именем вчерашнего chunk.'),
|
||||
codeBlock([
|
||||
'{',
|
||||
' "scripts": {',
|
||||
' "build:profile": "webpack --mode production --profile --json > dist/stats.json",',
|
||||
' "serve:dist": "npx http-server dist -c-1"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'npm run build:profile',
|
||||
'npm run serve:dist',
|
||||
].join('\n')),
|
||||
paragraph('На тестовой странице перед отправкой формы достаточно временно проверить два факта: <code>window.jQuery === $</code> в bootstrap-коде и наличие метода, который добавляет плагин. Затем эту диагностику убираю или оставляю за отдельным development-флагом. Production-страница должна открываться со свежим HTML, потому что новый bundle с прошлым списком тегов script проверяет не нашу конфигурацию, а несовместимый набор артефактов.'),
|
||||
heading('Порядок проверки после сборки'),
|
||||
orderedList([
|
||||
'Собираю чистый production <code>dist</code> и сохраняю имя entry, runtime и общих chunks из фактического вывода.',
|
||||
'Открываю страницу без локального CDN-скрипта jQuery и проверяю Network: все стартовые assets пришли без 404 и с одной версией артефакта.',
|
||||
'Ставлю временную проверку в <code>bootstrap-legacy.js</code>: до runtime require <code>window.jQuery === $</code> должно быть <code>true</code>.',
|
||||
'После require проверяю ровно один ожидаемый метод legacy-плагина, затем инициализирую форму через <code>$(function () { ... })</code>.',
|
||||
'Открываю stats.json и ищу modules, содержащие <code>jquery</code>; повторная копия требует объяснения, а не только сравнения общего размера bundle.',
|
||||
'Повторяю сценарий после очистки кеша и после прямого открытия страницы, чтобы не перепутать рабочий старый asset с новым релизом.',
|
||||
]),
|
||||
heading('Инициализирую форму после DOM, а не до него'),
|
||||
paragraph('Даже правильный глобал не создаёт input в DOM. jQuery <code>.ready()</code> выполняет обработчик, когда документ готов к безопасному изменению, поэтому конечная инициализация формы должна жить после bootstrap-пути. Это отдельная проверка от загрузки plugins: если <code>window.jQuery</code> есть, а селектор не находит поля, причина лежит уже в разметке или в моменте появления формы.'),
|
||||
codeBlock([
|
||||
'require(\'./bootstrap-legacy\');',
|
||||
'',
|
||||
'jQuery(function ($) {',
|
||||
' var $phone = $(\'#order-phone\');',
|
||||
'',
|
||||
' if ($phone.length !== 1 || typeof $phone.inputmask !== \'function\') {',
|
||||
' throw new Error(\'Legacy mask is not ready for #order-phone\');',
|
||||
' }',
|
||||
'',
|
||||
' $phone.inputmask(\'+7 (999) 999-99-99\');',
|
||||
'});',
|
||||
].join('\n')),
|
||||
paragraph('Синтаксис с аргументом <code>$</code> внутри <code>jQuery(function ($) { ... })</code> снимает зависимость этой конкретной функции от внешнего alias. Но он не отменяет bootstrap: плагин всё ещё должен быть загружен и зарегистрирован до вызова <code>inputmask()</code>. Так граница проблемы остаётся видимой: глобал для старого плагина, локальный alias для кода формы, реальный DOM для финальной инициализации.'),
|
||||
heading('Ограничения решения'),
|
||||
paragraph('Глобальный jQuery — технический долг, а не совет для нового модуля. Новые компоненты лучше импортировать явно и не делать <code>window</code> общим API. Также не следует выключать <code>splitChunks</code> только ради одного плагина: сначала нужно показать, что именно нарушено — порядок тегов, два экземпляра библиотеки или статический import.'),
|
||||
paragraph('Число chunks и их хеши зависят от версии Webpack, loaders и графа зависимостей. Поэтому статья не обещает фиксированное имя <code>vendors~site.js</code>. Её критерий другой: production HTML ссылается на выпуск одной сборки, глобал назначен до legacy-плагина, а проверка формы пройдена без случайного script из layout.'),
|
||||
heading('Итог'),
|
||||
paragraph('Production-сбой с jQuery в Webpack обычно не лечится ещё одним alias. Нужно назвать, какой код читает <code>window.jQuery</code>, создать его в bootstrap до runtime require и проверить фактический граф assets. После этого legacy-граница остаётся маленькой, а следующая миграция может заменить её модулем без скрытой глобальной зависимости.'),
|
||||
],
|
||||
[webpackShimming, webpackProvide, webpackV4Migration, webpackSplitChunks, jqueryReady, jqueryOn],
|
||||
);
|
||||
|
||||
export const revisions = [imageOwnershipArticle, imageFormArticle, jqueryWebpackArticle]
|
||||
.map(({ bodyLength, ...revision }) => revision);
|
||||
|
||||
const isDirectInvocation = process.argv[1]
|
||||
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectInvocation) {
|
||||
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-10-2019-01.mjs --print-revisions\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const paragraph = (content) => '<p>' + content + '</p>';
|
||||
const heading = (content) => '<h2>' + content + '</h2>';
|
||||
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return [
|
||||
'<figure>',
|
||||
'<img src="' + src + '" alt="' + alt + '" />',
|
||||
'<figcaption>' + caption + '</figcaption>',
|
||||
'</figure>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||||
const body = rows.map((row) => (
|
||||
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
|
||||
)).join('');
|
||||
|
||||
return '<div class="table-scroll"><table><thead><tr>' + head
|
||||
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url }) => (
|
||||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a></li>'
|
||||
)).join('') + '</ul>';
|
||||
}
|
||||
|
||||
const sources = {
|
||||
phpunit7: {
|
||||
label: 'PHPUnit — Getting Started with PHPUnit 7',
|
||||
url: 'https://phpunit.de/getting-started/phpunit-7.html',
|
||||
},
|
||||
fixtures: {
|
||||
label: 'PHPUnit 7.5 — Fixtures',
|
||||
url: 'https://docs.phpunit.de/en/7.5/fixtures.html',
|
||||
},
|
||||
doubles: {
|
||||
label: 'PHPUnit 7.5 — Test Doubles',
|
||||
url: 'https://docs.phpunit.de/en/7.5/test-doubles.html',
|
||||
},
|
||||
pdoTransaction: {
|
||||
label: 'PHP Manual — PDO::beginTransaction',
|
||||
url: 'https://www.php.net/manual/en/pdo.begintransaction.php',
|
||||
},
|
||||
pdoRollback: {
|
||||
label: 'PHP Manual — PDO::rollBack',
|
||||
url: 'https://www.php.net/manual/en/pdo.rollback.php',
|
||||
},
|
||||
getenv: {
|
||||
label: 'PHP Manual — getenv',
|
||||
url: 'https://www.php.net/manual/en/function.getenv.php',
|
||||
},
|
||||
curlExec: {
|
||||
label: 'PHP Manual — curl_exec',
|
||||
url: 'https://www.php.net/manual/en/function.curl-exec.php',
|
||||
},
|
||||
curlGetinfo: {
|
||||
label: 'PHP Manual — curl_getinfo',
|
||||
url: 'https://www.php.net/manual/en/function.curl-getinfo.php',
|
||||
},
|
||||
};
|
||||
|
||||
const practiceArticle = {
|
||||
slug: 'editorial-2018-11-practice-php-integration-tests',
|
||||
title: 'PHP. Интеграционный тест репозитория: фиксируем контракт с БД',
|
||||
categories: ['PHP', 'Тестирование'],
|
||||
cover: '/assets/editorial/2018/php-integration-contract-2018.svg',
|
||||
excerpt: 'Как написать небольшой интеграционный тест PHP-репозитория: отдельно настроить тестовую БД, проверить запись и чтение через PDO, а затем безопасно убрать учебные данные.',
|
||||
readingMinutes: 12,
|
||||
contentHtml: [
|
||||
paragraph('Unit-тест сервиса зелёный, а после отправки формы в таблице появляется запись с пустым полем или её нельзя прочитать тем же кодом. Цена ошибки — не один 500-й ответ: команда может неделю менять бизнес-логику, хотя запрос, тип столбца или строка подключения никогда не были проверены вместе.'),
|
||||
paragraph('Разберём один вопрос: <strong>какой контракт должен зафиксировать интеграционный тест PHP-репозитория, чтобы он действительно проверял работу с БД?</strong> Ниже учебный пример для PHP 7.2 и PHPUnit 7.5. Он не запускает контейнер из статьи и не содержит рабочего пароля: тестовую БД, пользователя и способ её запуска определяет конкретный проект.'),
|
||||
heading('Интеграция начинается там, где PHP перестаёт быть единственным исполнителем'),
|
||||
paragraph('В unit-тесте мы можем передать репозиторию подставной объект и проверить решение внутри класса. Это полезно для правил валидации, расчётов и веток ошибок. Но такой тест не отправляет SQL драйверу, не знает схему таблицы и не читает переменную окружения. Когда важен путь <code>PHP → PDO → тестовая БД → PDO → PHP</code>, его нужно пройти настоящим адаптером.'),
|
||||
paragraph('Контракт здесь короткий: при заданных данных репозиторий записывает ровно те поля, которые нужны сценарию; затем он читает ту же запись и возвращает ожидаемые значения. В него не надо включать весь сайт, почту и внешний API. Чем уже граница, тем понятнее причина падения: конфигурация, соединение, SQL, схема или преобразование результата.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/php-integration-contract-2018.svg',
|
||||
'Схема интеграционного контракта PHP-репозитория: PHPUnit передаёт учебный объект в PDO-репозиторий, тот пишет и читает тестовую БД в отдельном контейнере или стенде; после проверки транзакция откатывается.',
|
||||
'Тест доказывает не «работает весь сайт», а связку конфигурации, PDO, SQL и схемы для одного сценария записи и чтения.',
|
||||
),
|
||||
heading('Сначала называю вход, выход и следы операции'),
|
||||
paragraph('Перед кодом полезно записать контракт словами. Для примера возьмём таблицу <code>customers</code> с полями <code>id</code>, <code>email</code> и <code>name</code>. Вход — валидный адрес и имя. Выход — идентификатор, а после <code>findById()</code> тот же адрес и имя. След операции — одна строка в тестовой БД. Если вместо этого нужен уникальный индекс, нормализация регистра или часовой пояс, это уже отдельный проверяемый случай, а не скрытая деталь первого теста.'),
|
||||
dataTable(
|
||||
['Часть контракта', 'Что задаём', 'Что проверяем', 'Что не доказывает тест'],
|
||||
[
|
||||
['Конфигурация', '<code>TEST_DATABASE_DSN</code>, пользователь, пароль вне репозитория', 'Подключение создаётся только к тестовой БД', 'Доступность production-БД или права боевого пользователя'],
|
||||
['Запись', 'Адрес <code>anna@example.test</code> и имя <code>Анна</code>', 'Метод вернул числовой ID и SQL принял значения', 'Работу формы, шаблона и браузера'],
|
||||
['Чтение', 'ID из той же операции', 'Поля не потерялись и не поменяли тип без причины', 'Все возможные выборки каталога'],
|
||||
['Очистка', 'Открытая транзакция на соединении теста', 'После теста данные не остаются в этой транзакции', 'Откат DDL или вызова внешнего HTTP-сервиса'],
|
||||
['Ошибка', 'Отсутствующий DSN или неверная схема', 'Падение объясняет границу, а не маскируется пустым массивом', 'Что ошибка автоматически исправится на стенде'],
|
||||
],
|
||||
),
|
||||
heading('Тестовая конфигурация должна быть отдельной'),
|
||||
paragraph('Подключение нельзя прятать в конструкторе репозитория под строкой <code>mysql:host=localhost;dbname=site</code>. В тесте это опасно: читатель не видит, к какой базе обратится команда, а случайно оставленный пароль легко попадёт в Git. Берём DSN и учётные данные из переменных с префиксом <code>TEST_</code>. Сам префикс не является защитой, поэтому ниже есть явная проверка имени базы и понятная остановка при пустом значении.'),
|
||||
paragraph('Тестовая БД может жить в отдельном контейнере, локальном сервисе или выделенном стенде. Контракт от этого не меняется, но окружение должно быть изолировано от рабочих данных. В этой заметке не утверждается, что какой-либо контейнер был запущен: команда запуска и образ зависят от версии MySQL, драйвера PDO и правил проекта. Сначала проверяем адрес, затем разрешаем тесту открыть соединение.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// tests/Support/TestPdo.php — PHP 7.2',
|
||||
'final class TestPdo',
|
||||
'{',
|
||||
' public static function fromEnvironment(): PDO',
|
||||
' {',
|
||||
" $dsn = (string) getenv('TEST_DATABASE_DSN');",
|
||||
" $user = (string) getenv('TEST_DATABASE_USER');",
|
||||
" $password = (string) getenv('TEST_DATABASE_PASSWORD');",
|
||||
'',
|
||||
" if ($dsn === '' || strpos($dsn, 'test') === false) {",
|
||||
" throw new RuntimeException('TEST_DATABASE_DSN must name an isolated test database');",
|
||||
' }',
|
||||
'',
|
||||
' return new PDO($dsn, $user, $password, array(',
|
||||
' PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,',
|
||||
' PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,',
|
||||
' ));',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('Проверка слова <code>test</code> — только страховка от очевидной опечатки, а не модель прав доступа. В реальном проекте надёжнее отдельный пользователь без доступа к production-схемам, отдельная сеть и имя БД из закрытой тестовой конфигурации. Если DSN пустой или выглядит сомнительно, лучше остановить запуск с ошибкой, чем заменить его значением по умолчанию.'),
|
||||
heading('Пишу один путь через настоящий PDO-репозиторий'),
|
||||
paragraph('Следующий фрагмент показывает форму теста, а не готовый слой доступа к данным для любого проекта. <code>CustomerRepository</code> здесь использует переданный <code>PDO</code>, поэтому тот же SQL увидят драйвер и тестовая схема. В <code>setUp()</code> создаётся фикстура и открывается транзакция; в <code>tearDown()</code> она откатывается даже после падения проверки. PHPUnit вызывает эти методы вокруг теста, но их конкретные сигнатуры стоит сверить с закреплённой версией фреймворка.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'use PHPUnit\\Framework\\TestCase;',
|
||||
'',
|
||||
'final class CustomerRepositoryIntegrationTest extends TestCase',
|
||||
'{',
|
||||
' /** @var PDO */',
|
||||
' private $pdo;',
|
||||
'',
|
||||
' protected function setUp(): void',
|
||||
' {',
|
||||
' $this->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 строкой или моковым ожиданием. Он вызывает публичные методы репозитория, а доказательство получает после чтения обратно. Если в <code>add()</code> перепутан столбец, драйвер не принимает тип или <code>findById()</code> меняет имя ключа, зелёный результат невозможен при корректно настроенной тестовой схеме. Если же упало соединение, это тоже полезный сигнал: контракт конфигурации пока не выполнен.'),
|
||||
heading('Транзакция чистит данные, но не отменяет всё'),
|
||||
paragraph('PDO переводит соединение в режим транзакции после <code>beginTransaction()</code>; <code>rollBack()</code> возвращает изменения данных назад и включает autocommit. Это удобно для коротких тестов, которые делают <code>INSERT</code>, <code>UPDATE</code> и <code>DELETE</code>. Но MySQL может сделать неявный commit при DDL, например <code>CREATE TABLE</code> или <code>DROP TABLE</code>. Поэтому миграции и создание схемы не прячем внутрь этого теста: их выполняют отдельным подготовительным шагом.'),
|
||||
paragraph('Ещё одна граница — несколько соединений. Откат одного <code>PDO</code> не очистит запись, сделанную вторым соединением, очередью или HTTP-клиентом. Если код открывает соединение сам, сначала передайте ему тестовую фабрику или выделите адаптер. Только после этого можно честно сказать, что тест контролирует следы операции.'),
|
||||
heading('Порядок запуска без случайного доступа к данным'),
|
||||
orderedList([
|
||||
'Создать отдельную схему и пользователя для тестов по правилам проекта; не копировать production DSN в команду PHPUnit.',
|
||||
'Применить к тестовой схеме заранее подготовленную миграцию и отдельно записать её версию.',
|
||||
'Перед запуском вывести только имя тестовой базы или иной безопасный идентификатор, не печатая пароль.',
|
||||
'Запустить один класс через <code>./vendor/bin/phpunit tests/Integration/CustomerRepositoryIntegrationTest.php</code> после проверки версии 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('Один вопрос этой заметки: <strong>как провести границу между unit- и integration-тестом PHP, чтобы не назвать mock настоящей проверкой?</strong> В 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 с <code>WHERE email = ?</code>, тип колонки и сопоставление строки с массивом PHP остаются за границей. Их должен покрыть отдельный integration-тест конкретного PDO-репозитория.'),
|
||||
dataTable(
|
||||
['Вопрос', 'Unit-тест', 'Integration-тест', 'Признак лишней работы'],
|
||||
[
|
||||
['Правило дубликата', 'Подставной репозиторий отвечает <code>true</code>', 'Не обязателен в каждом варианте правила', 'Поднимать БД, чтобы проверить одно условие <code>if</code>'],
|
||||
['SQL и имена столбцов', 'Не проверяет', 'Выполняет настоящий запрос в тестовой схеме', 'Сравнивать строку SQL с копией этой же строки в тесте'],
|
||||
['Тип результата PDO', 'Можно задать массив вручную', 'Показывает фактический <code>FETCH_ASSOC</code> и преобразование', 'Считать mock доказательством работы драйвера'],
|
||||
['HTTP-запрос', 'Проверяет, что клиент был вызван с нужными данными', 'Проверяет URL, код ответа и разбор ответа на локальном endpoint', 'Посылать тест в боевой API'],
|
||||
['Конфигурация', 'Передаёт строку явно в конструктор', 'Берёт test-only значение из окружения и проверяет отказ при его отсутствии', 'Использовать production значение по умолчанию'],
|
||||
],
|
||||
),
|
||||
heading('Unit-тест: правило без настоящей БД'),
|
||||
paragraph('Ниже минимальный пример на PHPUnit 7. Он не называет объект mock только ради модного слова: репозиторий подставлен, потому что тест проверяет решение <code>RegistrationService</code> до момента записи. Вход и ожидаемый отказ видны прямо в коде. Такой тест быстро падает, если автор случайно удалит проверку существующего адреса, и не требует доступной БД для каждого запуска.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'use PHPUnit\\Framework\\TestCase;',
|
||||
'',
|
||||
'interface CustomerLookup',
|
||||
'{',
|
||||
' public function existsByEmail(string $email): bool;',
|
||||
'}',
|
||||
'',
|
||||
'final class RegistrationService',
|
||||
'{',
|
||||
' private $customers;',
|
||||
'',
|
||||
' public function __construct(CustomerLookup $customers)',
|
||||
' {',
|
||||
' $this->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('Этот код сознательно не содержит <code>PDO</code>, <code>getenv()</code> и URL. Если он зелёный, мы знаем ровно одно: при ответе <code>true</code> сервис бросает ожидаемое исключение. Мы не знаем, вернёт ли реальный запрос <code>true</code>, доступна ли нужная таблица и не передал ли bootstrap в репозиторий другой DSN. Чем точнее сформулирован вывод, тем меньше соблазн считать этот тест универсальной страховкой.'),
|
||||
heading('Integration-тест: настоящий адаптер вместо предположения'),
|
||||
paragraph('Чтобы проверить репозиторий, unit-тест выше не расширяют ожиданиями на SQL. Создают второй тест для <code>PdoCustomerLookup</code>. Он передаёт адаптеру PDO, подключённый только к тестовой схеме, кладёт известную строку в пределах транзакции и делает настоящий запрос. Ожидаемое значение выводится не из настройки mock-а, а из таблицы через тот же путь, по которому пойдёт приложение.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'final class PdoCustomerLookupIntegrationTest extends TestCase',
|
||||
'{',
|
||||
' /** @var PDO */',
|
||||
' private $pdo;',
|
||||
'',
|
||||
' protected function setUp(): void',
|
||||
' {',
|
||||
' $this->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('Здесь целевое поведение всё ещё небольшое: два адреса, один настоящий запрос, одна транзакция. Если в таблице вместо <code>email</code> теперь <code>mail</code>, тест покажет реальную ошибку. Если драйвер возвращает строку в неожиданной кодировке или DSN не открывается, это уже не «красный unit-тест», а след того, что договор адаптера или окружения изменился.'),
|
||||
heading('Как появляются фальшиво-зелёные проверки'),
|
||||
paragraph('Фальшивая зелень возникает не из-за самого mock-объекта. Она появляется, когда его результат становится единственным доказательством внешней границы. Test double заранее научен вернуть <code>true</code>, поэтому он никогда не увидит отсутствие миграции, ошибочный DNS, пустой <code>TEST_CALLBACK_URL</code> или код ответа 500. Такой объект нужен для unit-вопроса, но его нельзя использовать для ответа на другой вопрос.'),
|
||||
dataTable(
|
||||
['Зелёный тест говорит', 'Чего он не видел', 'Минимальная настоящая проверка', 'Следующее действие'],
|
||||
[
|
||||
['Сервис вызвал <code>save()</code>', 'SQL, транзакцию и индекс', 'Один <code>INSERT</code> и чтение через PDO в test DB', 'Добавить integration-тест адаптера'],
|
||||
['Клиент получил URL строкой', 'DNS, cURL, статус и тело ответа', 'Локальный HTTP endpoint с ожидаемым статусом', 'Проверить код и разбор ответа'],
|
||||
['Конструктор получил DSN', 'Как bootstrap прочёл окружение', 'Запуск с <code>TEST_</code> переменными и отказ без них', 'Зафиксировать test-only конфигурацию'],
|
||||
['Mock вернул массив', 'Настоящий формат строки БД или JSON', 'Адаптер читает учебный ответ ресурса', 'Проверить преобразование на границе'],
|
||||
],
|
||||
),
|
||||
heading('Выбираю границу по риску, а не по названию папки'),
|
||||
paragraph('Папки <code>tests/Unit</code> и <code>tests/Integration</code> помогают ориентироваться, но не делают код правильным сами. Сначала называем побочный эффект: запись в БД, HTTP-вызов, файловая система, очередь или конфигурация. Затем оставляем реальным только один из них. Если тест одновременно поднимает БД, отправляет письмо и строит HTML, он слишком широкий для поиска причины. Если он заменяет все ресурсы, он не ловит ошибки склейки.'),
|
||||
orderedList([
|
||||
'Выписать один симптом, который прошёл мимо unit-тестов: SQL, HTTP, конфигурация или преобразование данных.',
|
||||
'Назвать класс, который владеет границей, например <code>PdoCustomerLookup</code> или <code>CallbackClient</code>.',
|
||||
'Оставить настоящий только этот адаптер, а остальные соседние части заменить простыми контролируемыми объектами.',
|
||||
'Подготовить test-only ресурс: отдельную схему, локальный HTTP endpoint или временный каталог; production ресурс не использовать.',
|
||||
'Проверить положительный и один отрицательный путь, который показывает понятную ошибку границы.',
|
||||
'Оставить unit-тест правила рядом с integration-тестом адаптера: они дополняют, а не дублируют друг друга.',
|
||||
]),
|
||||
heading('Версии и ограничения нельзя прятать'),
|
||||
paragraph('Пример рассчитан на синтаксис PHP 7.2 и PHPUnit 7.5. Эти версии уже не поддерживаются на дату редакционного пересмотра, поэтому в новом проекте их не стоит выбирать по этой статье. Для исторического кода важно закрепить фактическую версию в <code>composer.lock</code> и сверить методы <code>createMock()</code>, <code>setUp()</code> и конфигурацию именно с ней.'),
|
||||
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('Разберём один вопрос: <strong>как поймать фальшиво-зелёный сценарий PHP, если в нём сходятся БД, HTTP и конфигурация?</strong> Это учебная трасса, а не отчёт о чужом инциденте. Мы не будем вызывать партнёрский 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('Полевой разбор начинается с одной исходной команды и одним учебным идентификатором, например <code>registration-test-42</code>. Его передаём в запись и заголовок локального callback. Тогда можно спросить последовательно: создалась ли строка, был ли собран URL, дошёл ли HTTP-запрос до тестового обработчика, какой статус вернулся и какое исключение увидел вызывающий код. Без этого порядка фраза «форма не работает» смешивает три разные границы.'),
|
||||
paragraph('В нормальном тестовом контуре DSN и URL имеют отдельные переменные: <code>TEST_DATABASE_DSN</code> и <code>TEST_CALLBACK_URL</code>. Не подставляем боевой адрес как запасной вариант. Пустая переменная — полезный красный сигнал, потому что она показывает ошибку конфигурации до записи или сетевой попытки. Секреты не печатаем в exception и не кладём в HTML-отчёт.'),
|
||||
dataTable(
|
||||
['Точка трассы', 'Что записать безопасно', 'Что означает сбой', 'Первое действие'],
|
||||
[
|
||||
['Чтение конфигурации', 'Есть ли непустые <code>TEST_</code> имена, без значений пароля', 'Запуск не получил test-only окружение', 'Остановить тест до соединения'],
|
||||
['PDO-соединение', 'Имя тестовой схемы и тип исключения', 'DSN, драйвер или права тестового пользователя', 'Проверить отдельную конфигурацию и миграцию'],
|
||||
['INSERT / SELECT', 'Учебный request ID и факт чтения обратно', 'SQL, схема или преобразование результата', 'Сузить тест до репозитория и повторить'],
|
||||
['HTTP-вызов', 'URL без query-секретов, статус, текст cURL-ошибки', 'Локальный endpoint недоступен или ответ не соответствует договору', 'Проверить порт, маршрут и ожидаемый статус'],
|
||||
['Ответ сервиса', 'Тип исключения и request ID', 'Код скрыл ошибку или смешал границы', 'Вернуть понятную ошибку вызывающему уровню'],
|
||||
],
|
||||
),
|
||||
heading('Локальный callback вместо внешнего партнёра'),
|
||||
paragraph('Для integration-теста HTTP-граница должна быть настоящей, но управляемой. В отдельном терминале проекта можно запустить встроенный PHP-сервер и направить <code>TEST_CALLBACK_URL</code> на <code>127.0.0.1</code>. Такой маршрут не доказывает доступность партнёра и не должен это обещать. Зато он показывает, что наш cURL-код собрал URL, отправил тело и корректно обработал статус, не передавая данные за пределы машины.'),
|
||||
paragraph('Обработчик ниже принимает только учебный запрос, сохраняет тело в системную временную папку и возвращает <code>202</code>. Имя файла включает заранее выбранный ID из заголовка. Перед повторным запуском файл нужно удалить вручную в тестовой директории или в <code>tearDown()</code>; пример не советует чистить широкие каталоги и не требует прав администратора. Команда сервера приведена как способ воспроизведения, а не как выполненный здесь прогон.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// tests/fixtures/callback.php',
|
||||
"$requestId = isset($_SERVER['HTTP_X_TEST_REQUEST_ID']) ? $_SERVER['HTTP_X_TEST_REQUEST_ID'] : 'missing';",
|
||||
"if (!preg_match('/^[a-z0-9-]{1,40}$/', $requestId)) {",
|
||||
' http_response_code(400);',
|
||||
" echo 'bad request id';",
|
||||
' return;',
|
||||
'}',
|
||||
'',
|
||||
"$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'callback-' . $requestId . '.json';",
|
||||
'file_put_contents($path, file_get_contents(\'php://input\'));',
|
||||
"header('Content-Type: application/json');",
|
||||
'http_response_code(202);',
|
||||
"echo '{\"accepted\":true}';",
|
||||
'',
|
||||
'# отдельный терминал, если порт 8088 свободен:',
|
||||
'# php -S 127.0.0.1:8088 -t tests/fixtures',
|
||||
].join('\n')),
|
||||
paragraph('В тестовой конфигурации URL должен быть явным: <code>http://127.0.0.1:8088/callback.php</code>. Если проект использует контейнер для тестовой БД, адрес может отличаться внутри контейнера; тогда сначала проверяем, из какого процесса запускается PHPUnit. Не угадываем <code>localhost</code>: у процесса PHP и у браузера это могут быть разные сетевые пространства. Это ещё одна причина хранить URL в test-only переменной и называть его в ошибке без токенов.'),
|
||||
heading('Показываю, почему unit-тест здесь недостаточен'),
|
||||
paragraph('Локальное правило регистрации всё ещё стоит покрыть unit-тестом. Но в следующем фрагменте оба побочных эффекта заменены объектами в памяти. Он подтвердит порядок вызовов и реакцию сервиса, однако всегда останется зелёным при пустом DSN, отсутствующем драйвере PDO или неверном URL. В этом и состоит его ограничение, а не дефект самого теста.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'final class MemoryRegistrationRepository implements RegistrationRepository',
|
||||
'{',
|
||||
' public function create(string $requestId, string $email): int',
|
||||
' {',
|
||||
' return 42;',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'final class SpyCallbackClient implements CallbackClient',
|
||||
'{',
|
||||
' public $messages = array();',
|
||||
'',
|
||||
' public function send(string $requestId, int $registrationId): void',
|
||||
' {',
|
||||
' $this->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-тест остаётся полезным: он быстро защищает правило, что уведомление отправляется после успешного создания. Но его вывод надо читать буквально. Он не делал <code>INSERT</code>, не открывал cURL и не читал <code>getenv()</code>. Поэтому рядом появляется integration-тест с реальным <code>PdoRegistrationRepository</code> и <code>CurlCallbackClient</code>, направленным только на локальный endpoint.'),
|
||||
heading('Делаю настоящий HTTP-переход проверяемым'),
|
||||
paragraph('cURL-адаптер обязан отличать ошибку транспорта от ответа сервера. <code>curl_exec()</code> возвращает данные или <code>false</code>; статус читаем через <code>curl_getinfo()</code>. Не считаем любой непустой ответ успехом. Для учебного callback договор простой: ожидаем <code>202</code> и JSON с признаком <code>accepted</code>. Таймаут и заголовок задаются в коде явно, чтобы тест не зависел от неявных ini-настроек.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'final class CurlCallbackClient implements CallbackClient',
|
||||
'{',
|
||||
' private $url;',
|
||||
'',
|
||||
' public function __construct(string $url)',
|
||||
' {',
|
||||
' $host = parse_url($url, PHP_URL_HOST);',
|
||||
" if ($host !== '127.0.0.1') {",
|
||||
" throw new InvalidArgumentException('TEST_CALLBACK_URL must point to the local test endpoint');",
|
||||
' }',
|
||||
' $this->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('Проверка <code>127.0.0.1</code> выше намеренно учебная и не подходит как общая политика URL. Её задача — не дать этому конкретному тесту случайно послать данные за пределы локальной машины. В проекте с отдельной тестовой сетью правило будет другим: allowlist test-хоста, отдельные credentials и запрещённый production DNS. Важно, что ограничение находится до вызова cURL, а не в надежде на внимательность запускающего.'),
|
||||
heading('Integration-тест связывает только три нужные части'),
|
||||
paragraph('Тест ниже предполагает, что тестовая схема уже подготовлена, а локальный callback поднят отдельно. Он не создаёт таблицы на лету и не обращается к production. Транзакция очистит запись в БД, но HTTP-вызов не откатится вместе с ней, поэтому обработчик пишет учебное тело в файл с request ID, который можно проверить и удалить после теста. Это явная граница: БД и сеть имеют разный способ уборки.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'final class RegistrationIntegrationTest extends TestCase',
|
||||
'{',
|
||||
' /** @var PDO */',
|
||||
' private $pdo;',
|
||||
'',
|
||||
' protected function setUp(): void',
|
||||
' {',
|
||||
' $this->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 одновременно.',
|
||||
'Проверить наличие <code>TEST_DATABASE_DSN</code> и <code>TEST_CALLBACK_URL</code> без вывода паролей и токенов.',
|
||||
'Запустить отдельно интеграционный тест репозитория: запись и чтение через PDO должны быть видны до HTTP-шага.',
|
||||
'Поднять или проверить только локальный callback, затем убедиться, что URL теста не совпадает с внешним адресом.',
|
||||
'Добавить настоящий cURL-адаптер в тест и различить transport error, HTTP status и неверное тело ответа.',
|
||||
'После причины вернуть unit-тесту его узкую роль, а integration-тест оставить возле адаптеров как защиту от повторной склейки.',
|
||||
]),
|
||||
heading('Что этот маршрут не обещает'),
|
||||
paragraph('Локальная связка не проверяет реальную сеть партнёра, его авторизацию, лимиты, очередь, браузерную форму или поведение production БД под нагрузкой. Она также не делает распределённую транзакцию: если БД уже записала строку, а callback ответил ошибкой, политика повтора и компенсации должна быть спроектирована отдельно. Не надо прятать эту проблему в <code>catch</code> и объявлять сценарий атомарным.'),
|
||||
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');
|
||||
}
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const paragraph = (content) => '<p>' + content + '</p>';
|
||||
const heading = (content) => '<h2>' + content + '</h2>';
|
||||
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return [
|
||||
'<figure>',
|
||||
'<img src="' + src + '" alt="' + alt + '" />',
|
||||
'<figcaption>' + caption + '</figcaption>',
|
||||
'</figure>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||||
const body = rows.map((row) => (
|
||||
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
|
||||
)).join('');
|
||||
|
||||
return '<div class="table-scroll"><table><thead><tr>' + head
|
||||
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url }) => (
|
||||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a></li>'
|
||||
)).join('') + '</ul>';
|
||||
}
|
||||
|
||||
const sources = {
|
||||
includeModule: {
|
||||
label: '1С-Битрикс — CModule::IncludeModule',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php',
|
||||
},
|
||||
update: {
|
||||
label: '1С-Битрикс — CIBlockElement::Update',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
|
||||
},
|
||||
getList: {
|
||||
label: '1С-Битрикс — CIBlockElement::GetList',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php',
|
||||
},
|
||||
beforeUpdate: {
|
||||
label: '1С-Битрикс — событие OnBeforeIBlockElementUpdate',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/events/onbeforeiblockelementupdate.php',
|
||||
},
|
||||
translit: {
|
||||
label: '1С-Битрикс — CUtil::translit',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php',
|
||||
},
|
||||
phpExceptions: {
|
||||
label: 'PHP Manual — Exceptions',
|
||||
url: 'https://www.php.net/exceptions',
|
||||
},
|
||||
};
|
||||
|
||||
const practiceArticle = {
|
||||
slug: 'editorial-2018-12-practice-legacy-refactoring',
|
||||
title: 'Bitrix API. Как заменить один вызов Update, не переписывая модуль',
|
||||
categories: ['Bitrix', 'PHP'],
|
||||
cover: '/assets/editorial/2018/bitrix-legacy-safe-seam-2018.svg',
|
||||
excerpt: 'Небольшой защищённый шов вокруг CIBlockElement::Update: ограничиваем вход, меняем одно поле CODE, читаем результат обратно и не обещаем переписать весь legacy-модуль.',
|
||||
readingMinutes: 13,
|
||||
contentHtml: [
|
||||
paragraph('После точечной правки карточки товар иногда исчезает по привычному URL: поле <code>CODE</code> пришло пустым или ушло не в тот инфоблок. Цена ошибки — не только 404 для посетителя. Следующая выгрузка или шаблон может прочитать уже другой адрес, и разбирать придётся не одну строку в обработчике, а весь след изменения.'),
|
||||
paragraph('Разберём один вопрос: <strong>как вынести маленький защищённый шов вокруг <code>CIBlockElement::Update</code>, когда нужно менять только символьный код элемента?</strong> Это учебный пример для старого Bitrix и PHP 7.2. Он не предполагает, что у нас есть доступ к вашему модулю, тестовой базе или журналу изменений. Сначала ограничиваем вход, затем меняем одно поле и читаем его обратно.'),
|
||||
heading('Симптом показывает место, а не весь объём переделки'),
|
||||
paragraph('В legacy-файле изменение часто прячется между разбором <code>$_POST</code>, подключением шаблона, проверкой прав и отправкой письма. В таком месте легко решить, что нужен новый модуль каталога. Пока доказан только другой факт: один переход <code>вход формы → CODE элемента</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>CODE</code>. До вызова API проверяем, что элемент существует и относится к этому инфоблоку. После вызова читаем тот же элемент и сравниваем фактическое значение. Если код уже совпал, писать в БД второй раз не нужно: это отдельный наблюдаемый результат, а не ошибка.'),
|
||||
dataTable(
|
||||
['Часть шва', 'Проверка до записи', 'Действие', 'Признак готовности'],
|
||||
[
|
||||
['Модуль', '<code>CModule::IncludeModule("iblock")</code> вернул <code>true</code>', 'Разрешить работу с API инфоблоков', 'Нет скрытого подключения класса из другого файла'],
|
||||
['Элемент', 'Выборка по ID вернула строку', 'Сравнить <code>IBLOCK_ID</code> и текущий <code>CODE</code>', 'Не меняем чужой инфоблок и не создаём элемент по ошибке'],
|
||||
['Вход', 'ID положительный, код после <code>trim()</code> не пустой', 'Передать ровно одно поле в <code>Update</code>', 'Форма не превращает пустое значение в случайное обновление'],
|
||||
['Запись', '<code>Update()</code> вернул <code>true</code>', 'Прочитать элемент той же выборкой', 'Возвращённый <code>CODE</code> совпадает с ожидаемым'],
|
||||
['Отказ', 'API вернул <code>false</code> или проверка не прошла', 'Остановить текущий путь с понятным сообщением', 'Нет продолжения к шаблону как после успешной записи'],
|
||||
],
|
||||
),
|
||||
paragraph('Такой контракт не гарантирует уникальность кода на любом сайте. В одном проекте код формирует компонент, в другом — импорт, в третьем на него влияют обработчики события. Задача этого шага скромнее: не дать конкретному вызову <code>Update</code> изменить неизвестный элемент или умолчать о неуспехе. Правило уникальности, транслитерация и маршруты каталога добавляются отдельными проверками, если они действительно принадлежат вашему случаю.'),
|
||||
heading('Выделяем CodeWriter, а не новый «слой приложения»'),
|
||||
paragraph('Ниже обычный PHP-класс. Он использует старый API Bitrix потому, что именно он уже находится в рассматриваемом коде. Перед работой он подключает модуль <code>iblock</code>, читает элемент через <code>CIBlockElement::GetList</code> и передаёт в <code>Update</code> только <code>CODE</code>. В примере нет автозагрузчика, контейнера зависимостей или обещания миграции на другой API: эти вещи не нужны, чтобы сделать один вызов понятнее.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// local/php_interface/lib/ProductCodeWriter.php — PHP 7.2',
|
||||
'final class ProductCodeWriter',
|
||||
'{',
|
||||
' private $expectedIblockId;',
|
||||
'',
|
||||
' public function __construct($expectedIblockId)',
|
||||
' {',
|
||||
' $this->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 в транзакцию: документация <code>CIBlockElement::Update</code> указывает, что до и после записи работают события. Поэтому ответ <code>true</code> нужен, но сам по себе не равен доказательству, что шаблон, индекс или внешняя система уже увидели нужный адрес. Здесь мы доказываем только то, что выбрали правильный элемент и прочитали обратно его <code>CODE</code>.'),
|
||||
heading('Подключаем шов из старого обработчика одной строкой'),
|
||||
paragraph('Старый файл может по-прежнему собирать <code>$_POST</code>, проверять сессию и решать, какой шаблон показать. Замена касается только места, где раньше напрямую вызывался <code>CIBlockElement::Update</code>. Обработчик получает отчёт и сам выбирает, как показать ошибку. Это важно: класс не должен делать <code>echo</code>, редирект или запись в глобальный <code>$APPLICATION</code>, иначе ответственность снова смешается.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// fragment from a legacy save handler',
|
||||
'try {',
|
||||
' $writer = new ProductCodeWriter(7); // 7 — ID инфоблока этого сценария',
|
||||
" $report = $writer->write($_POST['ID'], $_POST['CODE']);",
|
||||
'',
|
||||
" $message = $report['changed']",
|
||||
" ? 'Символьный код обновлён.'",
|
||||
" : 'Символьный код уже совпадает.';",
|
||||
'} catch (InvalidArgumentException $error) {',
|
||||
' $message = $error->getMessage();',
|
||||
'} catch (RuntimeException $error) {',
|
||||
' // В конкретном проекте здесь выбирают локальный журнал и вывод формы.',
|
||||
' $message = $error->getMessage();',
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('В таком виде изменение можно снять отдельно в истории: одна правка добавляет <code>ProductCodeWriter</code> и переключает один вызов. Не стоит в том же коммите переименовывать шаблоны, менять поля инфоблока и переписывать импорт. Если поведение расходится, разница будет лежать либо на входе, либо в шве, либо после него. Чем меньше одновременно изменённых мест, тем легче вернуть старую строку без отката соседней работы.'),
|
||||
heading('Проверяем один след, затем расширяем покрытие'),
|
||||
orderedList([
|
||||
'Найти прямой вызов <code>CIBlockElement::Update</code> и записать его входы: ID, инфоблок, поле и источник кода.',
|
||||
'Выбрать изолированный элемент на разрешённом тестовом контуре; не брать рабочую карточку ради быстрой проверки.',
|
||||
'Сохранить исходный <code>CODE</code> и ожидаемый новый код рядом с проверкой, не в комментарии памяти.',
|
||||
'Вызвать шов только для этого элемента и проверить отчёт <code>changed</code>.',
|
||||
'Снова выбрать элемент API-запросом и сравнить фактический <code>CODE</code> с ожидаемым.',
|
||||
'Если появилось расхождение, вернуть вызов на прежний путь или восстановить сохранённое значение по процедуре проекта; не добавлять второй <code>Update</code> «на всякий случай».',
|
||||
'Лишь после понятного результата подключать следующий вызов и отдельно разбирать его предусловия.',
|
||||
]),
|
||||
heading('Границы защищённого шва'),
|
||||
paragraph('Этот приём не устраняет все риски старого Bitrix-проекта. Он не говорит, какой <code>CODE</code> нужен для SEO, как синхронизировать его с торговыми предложениями и можно ли менять его у опубликованного элемента. Если на инфоблоке есть обработчик <code>OnBeforeIBlockElementUpdate</code>, он вправе изменить поля или отменить обновление. Значит, перед включением на конкретном сайте нужно посмотреть зарегистрированные обработчики и проверить их на разрешённом сценарии.'),
|
||||
paragraph('Также не следует превращать каждую строку PHP в класс. Если в файле один вызов и он уже имеет ясный вход, достаточно функции с тем же контрактом. Шов оправдан, когда повторяемая операция сейчас смешана с формой или когда нужно зафиксировать её проверку. Цель не в количестве файлов. Цель — увидеть, что именно меняется, и получить точку, куда можно поставить следующий локальный тест.'),
|
||||
heading('Итог: маленькая замена оставляет понятный след'),
|
||||
paragraph('Для первого рефакторинга достаточно вынести один вызов <code>Update</code>, ограничить инфоблок и поле, а затем прочитать результат обратно. Это не переписывает модуль и не обещает, что все связи каталога стали безопасными. Зато следующий разработчик получает конкретный маршрут: вход, проверка, запись, повторная выборка и ясная точка отказа. Когда этот маршрут устойчив, рядом можно вынести следующий — но только после отдельной проверки.'),
|
||||
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('Один вопрос этой заметки: <strong>почему смешанная ответственность в старом <code>save.php</code> делает даже локальный рефакторинг рискованным?</strong> В 2018 году для ответа не нужен большой набор паттернов. Достаточно показать порядок действий: форма передала данные, Bitrix изменил элемент, затем код решил, что показать или вызвать дальше. Если эти шаги не разделены, по одному сообщению в браузере нельзя понять, какой из них уже произошёл.'),
|
||||
heading('Один HTTP-запрос может оставить несколько разных следов'),
|
||||
paragraph('Старый обработчик обычно вырос постепенно. Сначала он сохранял название товара. Потом в него добавили проверку картинки, затем письмо менеджеру, затем очистку кеша и кусок шаблона. Все строки исполняются в одном PHP-процессе, но владеют разными состояниями. Поле инфоблока живёт в Битрикс, сообщение — в форме, а уведомление — в другом канале. Ошибка после первого действия не отменяет автоматически уже сделанное изменение.'),
|
||||
paragraph('Опасность не в длине файла. Маленький файл тоже смешивает ответственность, если функция одновременно читает <code>$_POST</code>, меняет элемент, печатает HTML и решает, что делать с ошибкой. В нём невозможно выбрать простую проверку: мы не знаем, считать ли «успехом» ответ браузеру, результат <code>Update()</code> или факт, что следующее действие не было вызвано. Поэтому сначала даём каждому следу имя.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/bitrix-mixed-responsibility-2018.svg',
|
||||
'Схема смешанной ответственности в legacy save.php: один файл одновременно читает POST, изменяет CODE в инфоблоке, формирует HTML и запускает внешнее действие; ошибка на позднем шаге не сообщает, что уже сохранилось.',
|
||||
'Красные стрелки показывают независимые побочные эффекты. Их порядок нельзя восстановить только по одному сообщению формы.',
|
||||
),
|
||||
heading('Сначала фиксируем наблюдаемые границы'),
|
||||
paragraph('Для локальной переделки достаточно трёх ролей. Обработчик формы принимает и проверяет вход. Операция записи меняет один элемент инфоблока и возвращает результат или ошибку. Представление решает, какой текст показать. Внешняя отправка, кеш или импорт остаются отдельными соседями: их не надо прятать в новую функцию только потому, что они находятся рядом. Если они важны для сценария, порядок и отдельный признак их выполнения описываются позже.'),
|
||||
dataTable(
|
||||
['Что делает старый файл', 'Какой след остаётся', 'Почему это опасно при смешении', 'Самый маленький шов'],
|
||||
[
|
||||
['Читает <code>$_POST</code>', 'Непроверенные строки формы', 'Пустой ID может дойти до записи под видом обычной ошибки', 'Преобразовать вход в массив с ID и названием до работы с API'],
|
||||
['Вызывает <code>CIBlockElement::Update</code>', 'Изменение в инфоблоке или <code>LAST_ERROR</code>', 'HTML ниже по файлу не доказывает результат записи', 'Вернуть из функции отчёт или исключение'],
|
||||
['Выводит HTML', 'Текст в браузере', 'Сообщение «готово» может появиться не на том пути', 'Показывать текст после известного результата операции'],
|
||||
['Запускает интеграцию', 'Отдельный сетевой или файловый эффект', 'Повтор формы способен повторить уже выполненное действие', 'Оставить вызов рядом с явным условием успеха'],
|
||||
['Меняет глобальное состояние', 'Сессия, кеш, глобальные переменные', 'Тест и диагностика зависят от порядка строк', 'Передавать нужное значение аргументом в малую функцию'],
|
||||
],
|
||||
),
|
||||
paragraph('Таблица не предлагает разнести старый сайт по слоям за один день. Она нужна для более короткого решения: выбрать единственный след, который сейчас нужен задаче, и не потерять его среди остальных. Например, если исправляем пустой символьный код, первым швом будет сохранение <code>CODE</code>. Письмо менеджеру и кеш можно временно оставить в старом файле, но не использовать их как доказательство того, что код элемента записан.'),
|
||||
heading('Как выглядит смешение в коде'),
|
||||
paragraph('Этот фрагмент намеренно похож на обычный legacy-обработчик. Он не взят из конкретного проекта и не должен быть скопирован в production. Его задача — показать, почему ошибка в конце не отвечает на вопрос о середине. Вызов <code>sendPartnerNotice()</code> обозначает уже существующую соседнюю операцию; статья не утверждает, что она выполнялась или что любой сайт должен её иметь.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// save.php — упрощённый пример смешанной ответственности',
|
||||
"if ($_SERVER['REQUEST_METHOD'] === 'POST') {",
|
||||
" $elementId = (int) $_POST['ID'];",
|
||||
" $name = trim((string) $_POST['NAME']);",
|
||||
'',
|
||||
" if ($elementId <= 0 || $name === '') {",
|
||||
" echo 'Заполните ID и название.';",
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
" CModule::IncludeModule('iblock');",
|
||||
' $element = new CIBlockElement();',
|
||||
" if (!$element->Update($elementId, array('NAME' => $name))) {",
|
||||
' echo $element->LAST_ERROR;',
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
' // Детали этой интеграции здесь неизвестны.',
|
||||
" sendPartnerNotice($elementId, $name);",
|
||||
" echo 'Сохранено';",
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('В примере можно увидеть минимум три исхода: вход не прошёл, Bitrix отказал в обновлении, запись прошла, но следующий шаг вернул ошибку. Последний исход особенно неприятен. Если вокруг <code>sendPartnerNotice()</code> появится исключение, браузер может показать общую ошибку, хотя <code>NAME</code> уже записан. Повторить POST после этого — не нейтральная проверка. Поэтому не маскируем все пути одним текстом и не добавляем «повторить Update» после любого сбоя.'),
|
||||
heading('Выносим запись в функцию с одним ответом'),
|
||||
paragraph('Первое извлечение можно сделать обычной функцией. Вход ей передают явно: ID и нормализованное название. Она не печатает HTML, не читает глобальный <code>$_POST</code> и не вызывает интеграцию. Она либо возвращает ID обновлённого элемента, либо останавливает текущий путь исключением. PHP исключения здесь используются не как модная абстракция, а чтобы код формы не продолжился как после успешной записи.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// ProductNameWriter.php — PHP 7.2',
|
||||
'function saveProductName($elementId, $name)',
|
||||
'{',
|
||||
" if (!CModule::IncludeModule('iblock')) {",
|
||||
" throw new RuntimeException('Module iblock is unavailable');",
|
||||
' }',
|
||||
'',
|
||||
' $elementId = (int) $elementId;',
|
||||
' $name = trim((string) $name);',
|
||||
" if ($elementId <= 0 || $name === '') {",
|
||||
" throw new InvalidArgumentException('ID and NAME are required');",
|
||||
' }',
|
||||
'',
|
||||
' $element = new CIBlockElement();',
|
||||
" if (!$element->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('После этого можно решить, что делать с интеграцией, но не смешивать решение с первым швом. Если уведомление допустимо только после успешной записи, его вызывают после <code>saveProductName()</code> и фиксируют отдельно, что именно считается успехом интеграции. Если интеграция упала, обработчик честно показывает её отдельную ошибку и не делает вид, что карточка не менялась. Восстановление поля, повтор сети и очередь — следующие задачи с собственными условиями, а не одна строка в <code>catch</code>.'),
|
||||
heading('Почему обработчики Bitrix усиливают путаницу'),
|
||||
paragraph('Метод <code>CIBlockElement::Update</code> не одинок: до изменения могут выполниться обработчики <code>OnBeforeIBlockElementUpdate</code>, которые могут изменить входные поля или отменить действие. После записи также есть события. Поэтому функция записи должна сохранить первоначальный смысл: она возвращает только результат вызова API и его сообщение. Ей не нужно обещать, что все слушатели, поиск, кеш или внешний каталог уже находятся в согласованном состоянии.'),
|
||||
paragraph('Эта оговорка особенно полезна при разборе старого кода. Если новая функция внезапно меняет больше, чем старая строка, сначала смотрим обработчики и фактический набор полей. Не добавляем <code>PROPERTY_VALUES</code> «для полноты»: документация события отдельно предупреждает, что неосторожная работа с этим массивом может очистить остальные свойства, когда Update был вызван без них. Узкий массив полей — защита от лишнего изменения, а не неполнота примера.'),
|
||||
heading('Порядок локальной переделки'),
|
||||
orderedList([
|
||||
'Назвать один симптом: например, после формы неизвестно, записано ли название или ошибка случилась после записи.',
|
||||
'Выписать из файла все побочные эффекты в их фактическом порядке: изменение элемента, HTML, кеш, письмо, интеграция.',
|
||||
'Выбрать только один эффект для первой замены и описать вход, выход и отказ.',
|
||||
'Вынести его в функцию или небольшой класс без <code>echo</code>, <code>$_POST</code> и сторонней отправки.',
|
||||
'Подключить шов одним вызовом из старого файла и сохранить прежний порядок для действий, которые ещё не разбирались.',
|
||||
'На разрешённом тестовом контуре проверить положительный и отрицательный вход отдельно от шаблона.',
|
||||
'Если нужно менять следующий эффект, начать новый короткий разбор, а не расширять первый шов до всего файла.',
|
||||
]),
|
||||
heading('Где такое разделение не решает проблему'),
|
||||
paragraph('Функция записи не создаёт транзакцию между инфоблоком и внешним API. Она не отменяет уже сделанное уведомление и не знает, можно ли повторить сетевой запрос. Если сценарий требует атомарности нескольких систем, это отдельная задача: сначала нужно описать данные, порядок и допустимый повтор. Называть такую задачу «добавим try/catch» было бы опаснее, чем оставить честную границу.'),
|
||||
paragraph('Также не надо принудительно выносить весь шаблон из PHP-файла, если ошибка живёт в одной операции записи. Старый формат может остаться старым. Результат локального рефакторинга измеряется проще: у операции есть собственный вход, собственный результат, понятная ошибка и короткий способ проверить, где оборвался сценарий. Это уже делает следующую правку меньше.'),
|
||||
heading('Итог: порядок важнее размера файла'),
|
||||
paragraph('Маленькая правка опасна, когда один файл выдаёт несколько несвязанных эффектов за один успех. Разделив форму, изменение инфоблока и последующее действие хотя бы на уровне функций, мы не строим новую архитектуру. Мы возвращаем причинность: сообщение формы не подменяет результат <code>Update</code>, а ошибка интеграции не стирает факт сохранения. С этой точки можно выбрать следующий шов без большого переписывания.'),
|
||||
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>CODE</code>, но его вызовы разбросаны между импортом и формой редактирования. Цена ошибки — не «некрасивый рефакторинг»: один новый код может сломать URL элемента, а поспешный откат поверх неизвестного значения способен затереть изменение другого человека.'),
|
||||
paragraph('Это учебный полевой разбор одного вопроса: <strong>как заменить один legacy-обработчик Bitrix на проверяемый путь и оставить возможность отката?</strong> Он не описывает реальный проект, запуск или результат релиза. Возьмём один элемент на разрешённом тестовом контуре, снимем его исходное значение, направим только этот вызов через новый код и после записи снова прочитаем элемент. Так можно увидеть расхождение до того, как расширять замену на импорт.'),
|
||||
heading('Выбираем один участок, а не «весь импорт»'),
|
||||
paragraph('Представим старую функцию <code>legacyUpdateCode()</code>. Она получает название, сама делает транслитерацию и сразу вызывает <code>CIBlockElement::Update</code>. Задача не в том, чтобы объявить её плохой. Она уже может обслуживать десятки строк импорта. В первом проходе меняем только один контролируемый вызов: выбранный 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>CODE</code>, новый расчётный код и время проверки. Эти значения можно положить в тестовый сценарий, временный защищённый журнал или запись задачи — способ зависит от правил проекта. Не стоит печатать в общий лог весь массив элемента: в нём могут оказаться поля, которые не нужны для данной операции. Нам достаточно того, что позволит сравнить один переход.'),
|
||||
dataTable(
|
||||
['Шаг', 'Что фиксируем', 'Что считаем успехом', 'Что делаем при расхождении'],
|
||||
[
|
||||
['До переключения', '<code>ID</code>, <code>IBLOCK_ID</code>, старый <code>CODE</code>', 'Элемент существует и принадлежит ожидаемому инфоблоку', 'Не запускать новый путь для этого ID'],
|
||||
['Расчёт', 'Название и параметры транслитерации', 'Новый код не пустой и понятен человеку', 'Остановить сценарий, не писать заглушку'],
|
||||
['Запись', 'Выбранный writer и ответ <code>Update</code>', 'API сообщил успех без скрытой повторной записи', 'Вернуть управление старому маршруту для следующих попыток'],
|
||||
['Повторная выборка', 'Фактический <code>CODE</code> по тому же ID', 'Совпадает с расчётным значением', 'Сначала отключить новый маршрут, затем расследовать события и вход'],
|
||||
['Откат поля', 'Сохранённый старый код и текущий код', 'Текущий код всё ещё тот, который поставил опыт', 'Не перезаписывать элемент; согласовать восстановление вручную'],
|
||||
],
|
||||
),
|
||||
paragraph('Последняя строка важнее всего. Откат маршрута и откат данных — разные действия. Переключатель может вернуть последующие вызовы на старую функцию. Но если новый путь уже изменил поле, автоматическое восстановление безопасно только при проверке, что между снимком и откатом значение не менял импорт, редактор или обработчик. Если такой гарантии нет, честнее остановиться и сравнить состояние, чем вернуть «старый» код поверх чужой работы.'),
|
||||
heading('Новый writer отвечает только за расчёт и одно поле'),
|
||||
paragraph('В примере ниже старый и новый writers существуют рядом. Это не постоянная архитектура и не рекомендация держать две реализации вечно. Две функции нужны на время локальной проверки, чтобы маршрут можно было вернуть без массового удаления кода. Новый вариант перед записью проверяет инфоблок, формирует символьный код стандартной функцией Bitrix и передаёт в <code>Update</code> только поле <code>CODE</code>.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// CodeWriter.php — учебный пример для PHP 7.2 и legacy Bitrix API',
|
||||
'final class CheckedCodeWriter',
|
||||
'{',
|
||||
' private $iblockId;',
|
||||
'',
|
||||
' public function __construct($iblockId)',
|
||||
' {',
|
||||
' $this->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('Параметры <code>CUtil::translit</code> здесь выбраны для примера: нижний регистр, дефисы вместо пробелов и ограничение длины. Они не доказывают, что именно такой код нужен вашему каталогу. Например, правила SEO могут требовать другой язык, сохранение старых URL или дополнительную проверку уникальности. До подключения нового writer эти условия следует назвать отдельно. Нельзя делать вывод о совпадении поведения только по тому, что обе функции вернули непустую строку.'),
|
||||
heading('Маршрут выбираем явно и на короткое время'),
|
||||
paragraph('Для контролируемого опыта достаточно простого переключателя в локальной конфигурации. Он не должен быть скрыт в шаблоне или зависеть от случайного параметра URL. В примере константу задаёт окружение, которое уже контролирует проект. По умолчанию остаётся старый путь. Новый маршрут включают только для заранее выбранной проверки, а не для всех вызовов импорта.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'// config.php: по умолчанию старый маршрут',
|
||||
"defined('USE_CHECKED_CODE_WRITER') || define('USE_CHECKED_CODE_WRITER', false);",
|
||||
'',
|
||||
'function updateCodeForOneScenario($elementId, $name)',
|
||||
'{',
|
||||
" if (USE_CHECKED_CODE_WRITER !== true) {",
|
||||
' // Существующая функция остаётся точкой возврата.',
|
||||
' return legacyUpdateCode($elementId, $name);',
|
||||
' }',
|
||||
'',
|
||||
' $writer = new CheckedCodeWriter(7);',
|
||||
' return $writer->writeFromName($elementId, $name);',
|
||||
'}',
|
||||
'',
|
||||
'// Перед опытом сравниваем ID с заранее выбранным значением.',
|
||||
"if ((int) $elementId !== 451) {",
|
||||
" throw new RuntimeException('The checked route is not enabled for this element');",
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('Числа <code>7</code> и <code>451</code> в примере не являются настройкой для копирования. Они показывают, что контур должен назвать свой инфоблок и тестовый элемент явно. В рабочем коде значения берут из согласованной конфигурации, а не из формы. Если такого контура нет, не следует подменять его production-карточкой. Сначала подготовьте разрешённый элемент и способ увидеть его до и после вызова.'),
|
||||
heading('Проверяем новую ветку и готовим откат до запуска'),
|
||||
paragraph('В Bitrix <code>Update</code> вызывает события, поэтому сравнение не заканчивается на его булевом результате. После вызова снова выбираем элемент и проверяем <code>CODE</code>. Если новое значение не совпало с расчётным, первым действием будет выключить новый маршрут для следующих запросов. Затем смотрим вход, обработчики и фактическое значение. Откат поля не следует запускать автоматически из <code>catch</code>: в нём недостаточно информации о чужих изменениях.'),
|
||||
orderedList([
|
||||
'Составить карту старых вызовов и выбрать один разрешённый сценарий, не заявляя, что карта уже полна.',
|
||||
'Снять перед опытом ID, инфоблок и прежний <code>CODE</code>; отдельно записать ожидаемую строку после транслитерации.',
|
||||
'Оставить переключатель нового writer выключенным по умолчанию и подготовить понятный способ вернуть его в <code>false</code>.',
|
||||
'Включить новый путь только для выбранного ID на тестовом контуре.',
|
||||
'После вызова прочитать элемент заново через API и сравнить фактическое поле с ожидаемым.',
|
||||
'При расхождении сразу отключить новый маршрут. Восстанавливать старый <code>CODE</code> можно только после проверки, что текущая строка принадлежит этому опыту.',
|
||||
'Сохранить итог проверки рядом с задачей и лишь затем решать, нужен ли второй сценарий или доработка правил транслитерации.',
|
||||
]),
|
||||
heading('Чего не доказывает один удачный элемент'),
|
||||
paragraph('Один элемент не проверяет все алфавиты, дубликаты, права редакторов, торговые предложения, SEO-шаблоны и работу импорта по расписанию. Он также не показывает, что в проекте нет обработчика, меняющего <code>CODE</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');
|
||||
}
|
||||
}
|
||||