Легаси: Босс, которого нельзя победить с первого раза

Рефакторинг модуля editDocs для EvolutionCMS CE: как перестать бояться легаси, добавить тесты с PHPUnit и улучшить поддерживаемость кода без боли.

Посмотреть результат на момент написания статьи и забрать в docker можно тут

Ссылка на модуль на момент написания статьи в коде тут

Цель и результат

💡 Цель рефакторинга: не переписать всё с нуля, а убрать явные проблемы и оставить код в состоянии, «с которым хочется продолжить работать».

Это не стремление к идеальной архитектуре с первого раза. Это маленькие, безопасные шаги, каждый из которых делал код чуть лучше, чуть понятнее, чуть надёжнее.

Что получилось в итоге:

Задача Решение Эффект
Дыры в безопасности Валидация входных данных через DTO + Value Objects, экранирование в репозитории Защита от SQL-инъекций, невалидных данных, человеческих ошибок
Проблемы в бизнес-правилах Написание тестов выявило: нельзя перемещать в себя, нельзя в потомка, нужно проверять наличие детей Модуль стал отзывчивее: понятные ошибки, защита от логических багов, предсказуемое поведение
Архитектурный хаос Разделение на слои: DTO → Validator → Service → Repository, внедрение зависимостей Код стал чище: каждый слой отвечает за своё, легче тестировать, проще расширять

Вступление: зачем вообще это нужно

«Легаси, легаси никогда не меняется. Когда-то мой код станет чьим-то легаси, и мне нужно постараться, чтобы следующему разработчику (или мне в будущем) не было мучительно больно его поддерживать.»

Знакомо? Если нет — поздравляю, вы либо:

  • 🎮 только что начали играть в «Симулятор программиста» и ещё не дошли до уровня «Наследие»;
  • 🎬 смотрите только комедии, где код работает с первого раза;
  • 🧙‍♂️ маг, и ваши if-elseif сами превращаются в чистую архитектуру.

Для всех остальных — добро пожаловать в квест «Рефакторинг без паники».

Почему мы здесь?

Представьте: код — это как старый автомобиль. Сначала он едет, потом начинает скрипеть, потом дымить, а потом вы стоите на обочине с гаечным ключом и думаете: «А почему я не поменял масло, когда был шанс?»

Так и с кодом. Когда он остаётся в зачаточном состоянии и начинает обрастать фичами, как снежный ком, наступает момент, когда его перестают поддерживать. Причины классические:

Причина Перевод на человеческий
⏰ Нехватка времени «Срочно нужно фичу, потом допилю»
😴 Нет желания «Работает — и ладно, потом перепишу»
🤷 Нет знаний «А как это вообще тестируется?»

Всё это ведёт к закисанию, стагнации и тому моменту, когда даже git blame показывает на вас, но вы уже не помните, что там написали.

💡 Мораль: бояться легаси — нормально. Не бояться его рефакторить — профессионально.

Наш «босс»: модуль editDocs

Для примера возьмём модуль, который уже стал легендой в экосистеме EvolutionCMS CEeditDocs.

Почему он?

Критерий Почему это важно
Польза Массовый импорт/экспорт, редактирование полей и TV любой вложенности — то, за что любят модуль
Популярность У модуля есть пользователи, а значит, изменения должны быть безопасными
Документация Красивая документация с примерами — редкость для легаси
Видео-урок Наглядный гайд по использованию — чтобы даже новичок понял

Но есть и «но»:

  • ajax.php на 100+ строк с if-elseif, который читается как древний свиток;
  • Гуляющий $_POST без проверок;
  • Тестов нет — а значит, любой рефакторинг — это игра в «русскую рулетку».

Начнём: ajax.php — свиток, который нужно уметь читать

«Код, который работает — это хорошо. Код, который работает и понятен — это искусство.»

Если ajax.php — это свиток, то читать его нужно с факелом в одной руке и огнетушителем в другой. 🔥🧯

Давайте разберём этот «древний манускрипт» и найдём точки, где рефакторинг принесёт максимальную пользу с минимальными рисками.

🔍 Что не так с этим кодом?

Взглянем на ajax.php как на детективную историю. У нас есть:

Герой Проблема Последствия
$_POST Гуляет без валидации 🔓 Уязвимости, баги, «магия»
$modx->db->query() Прямая подстановка данных 💉 SQL-инъекции
$obj->massMove() Нет проверки результата 🎭 «Успех», даже когда всё сломалось

🚨 Проблема 1: $_POST без валидации

if (!empty($_POST['parent1']) && !empty($_POST['parent2'])) {
        echo $obj->massMove();
    }

Почему это опасно, даже в админке?

  1. Админка ≠ безопасная зона. Если злоумышленник получит доступ к сессии администратора (через XSS, утечку куки, MITM), он сможет отправлять любые $_POST-данные.
  2. Человеческий фактор. Даже честный админ может случайно отправить parent1=abc или parent2=-1. Код должен быть устойчив к ошибкам.
  3. Отладка и логирование. Без валидации сложно понять, почему что-то сломалось. «Пришло что-то не то» — плохая диагностика.
💡 Правило: Валидируй входные данные как можно ближе к точке входа. Это как фильтр на входе в дом: лучше отсеять мусор сразу, чем разгребать его в гостиной.

«Золотой слиток» — как застраховать рефакторинг легаси

«Прежде чем менять код, нужно понять, что он делает. Не то, что он должен делать. А то, что он фактически делает.»

Если рефакторинг — это ремонт старого дома, то характеризационные тесты (Characterization Tests) — это фотофиксация каждой трещинки в стене до начала работ. Не для того чтобы сохранить трещины, а чтобы потом не гадать: «Это я так задумал или случайно сломал?».

Что такое «Золотой слиток» (Golden Master)?

Представьте: у вас есть чёрный ящик. Вы кладёте внутрь $_POST['parent1'] = 10, $_POST['parent2'] = 20 — и ящик выдаёт строку 'Успешное массовое перемещение'.

Вы не знаете, как он это делает. Но вы фиксируете: «При таких входных данных — такой вывод». Это и есть Золотой слиток — эталон текущего поведения системы.

📦 Чёрный ящик (легаси)
       │
       ▼
    🔢 Вход: $_POST = ['parent1' => '10', 'parent2' => '20']
       │
       ▼
    🎁 Выход: 'Успешное массовое перемещение'
       │
       ▼
    📝 Фиксируем: "10+20 → Успех" ← Это наш Золотой слиток
Без Золотого слитка С Золотым слитком
❌ Рефакторинг = гадание на кофейной гуще ✅ Рефакторинг = контролируемый эксперимент
❌ «Работало — перестало. Почему?» ✅ «Тест упал — значит, поведение изменилось. Это задумано?»
❌ Страх менять код ✅ Уверенность: если тесты зелёные — всё ок
💡 Правило: Характеризационный тест не проверяет «правильность». Он документирует «фактичность».

Установка инструментов

В assets/modules/editdocs/libs/composer.json добавляем PHPUnit:

{
      "require": {
        "phpoffice/phpspreadsheet": "1.30.0"
      },
      "require-dev": {
        "phpunit/phpunit": "^9.0"
      },
      "autoload": {
        "psr-4": {
          "EditDocs\\": "../src/"
        }}
      }
    }

Затем в терминале:

cd assets/modules/editdocs/libs/
    composer update --dev

✅ Готово: у нас есть vendor/bin/phpunit и автозагрузка для наших классов.

Пишем характеризационный тест

Файл: tests/Legacy/EditDocsMassMoveBaseCharacterizationTest.php

<?php declare(strict_types=1);

    use PHPUnit\Framework\TestCase;

    class MassMoveObjStub
    {
        public array $lang = ['notall' => 'Заполните оба поля'];

        public function massMove(): string
        {
            return 'Успешное массовое перемещение';
        }
    }

    class EditDocsMassMoveBaseCharacterizationTest extends TestCase
    {
        private array $originalPost = [];

        protected function setUp(): void
        {
            $this->originalPost = $_POST;
        }

        protected function tearDown(): void
        {
            $_POST = $this->originalPost;
        }

        /**
         * @dataProvider postCasesProvider
         */
        public function testCharacterizeMassMoveBehavior(array $postData, string $expectedOutput): void
        {
            $_POST = $postData;
            $obj = new MassMoveObjStub();

            ob_start();

            // ИССЛЕДУЕМЫЙ КОД
            if (!empty($_POST['parent1']) && !empty($_POST['parent2'])) {
                echo $obj->massMove();
            } else if (isset($_POST['parent1']) || isset($_POST['parent2'])) {
                echo '<div class="alert alert-danger">' . $obj->lang['notall'] . '</div>';
            }

            $actualOutput = ob_get_clean();

            // Фиксируем текущее поведение
            $this->assertSame($expectedOutput, $actualOutput);
        }

        public static function postCasesProvider(): array
        {
            return [
                'Оба параметра заполнены' => [
                    ['parent1' => '10', 'parent2' => '20'],
                    'Успешное массовое перемещение'
                ],
                'Только parent1' => [
                    ['parent1' => '10'],
                    '<div class="alert alert-danger">Заполните оба поля</div>'
                ],
                'Только parent2' => [
                    ['parent2' => '20'],
                    '<div class="alert alert-danger">Заполните оба поля</div>'
                ],
                'Оба пустые строки' => [
                    ['parent1' => '', 'parent2' => ''],
                    '<div class="alert alert-danger">Заполните оба поля</div>'
                ],
                'Оба отсутствуют' => [
                    [],
                    ''
                ],
                'Значение "0"' => [
                    ['parent1' => '0', 'parent2' => '0'],
                    '<div class="alert alert-danger">Заполните оба поля</div>'
                ],
            ];
        }
    }
💡 Совет: Характеризационный тест — это не «идеальный тест». Это «честный тест». Он говорит: «Вот что код делает сейчас». Дальше — ваш выбор: оставить как есть или улучшить.

Структура проекта — от свитка к модулям

После того, как Золотой слиток зафиксирован, мы можем начинать рефакторинг. Но сначала — организуем код, чтобы он не превратился в новый «свиток».

Было: ajax.php — всё в одном файле

📄 ajax.php (100+ строк)
       ├── Проверки окружения
       ├── Подключение классов
       ├── 10+ обработчиков действий
       └── Логика массового перемещения (в editdocs.class.php)

Стало: модульная структура

📁 assets/modules/editdocs/
       ├── ajax.php                          # ← Точка входа (тонкий слой)
       ├── editdocs.class.php                # ← Легаси (не трогаем пока)
       ├── libs/
       │   ├── composer.json                 # ← Зависимости (PHPUnit, PhpSpreadsheet)
       │   └── vendor/                       # ← Установленные пакеты
       ├── src/                              # ← Наш новый код
       │   ├── Http/
       │   │   └── EditDocsAjaxHandler.php   # ← Контроллер (маршрутизация)
       │   ├── Dto/
       │   │   └── EditDocsMassMoveInput.php # ← DTO для валидации входных данных
       │   ├── Repository/
       │   │   └── EditDocsDocumentRepository.php # ← Работа с БД
       │   └── Service/
       │       └── EditDocsMassMoveService.php    # ← Бизнес-логика
       └── tests/
           ├── Legacy/
           │   └── EditDocsMassMoveBaseCharacterizationTest.php # ← Золотой слиток
           └── Service/
               └── EditDocsMassMoveServiceTest.php              # ← Тесты нового кода
Было Стало Выгода
ajax.php на 100 строк ajax.php на 30 строк Читаемость
Логика размазана по файлам Логика в Service/Repository Ответственность
Тесты невозможны Тесты через PHPUnit Безопасность изменений
new editDocs($modx) везде Внедрение зависимостей Тестируемость

Тонкий ajax.php — входная дверь

<?php
    /**
     * AJAX контроллер для модуля editDocs
     */

    define('MODX_API_MODE', true);
    define('IN_MANAGER_MODE', true);
    define('NO_TRACY', true);

    include_once(__DIR__ . "/../../../index.php");

    global $modx;
    $modx->db->connect();
    if (empty($modx->config)) {
        $modx->getSettings();
    }

    spl_autoload_register(function ($class) {
        $prefix = 'EditDocs\\';
        $baseDir = __DIR__ . '/src/';

        $len = strlen($prefix);
        if (strncmp($prefix, $class, $len) !== 0) {
            return;
        }

        $relativeClass = substr($class, $len);
        $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';

        if (file_exists($file)) {
            require $file;
        }
    });

    require_once(MODX_BASE_PATH . "assets/modules/editdocs/editdocs.class.php");

    use EditDocs\Http\EditDocsAjaxHandler;

    function validateEnvironment($modx): void { /* ... */ }

    validateEnvironment($modx);
    $legacyEditDocs = new editDocs($modx);
    $handler = new EditDocsAjaxHandler($modx, $legacyEditDocs);
    $handler->handleRequest();

Что здесь происходит и зачем?

Блок кода Зачем это нужно Почему так, а не иначе
define('MODX_API_MODE', true) Сообщает MODX, что мы в режиме API Требуется ядром для корректной инициализации
include_once(.../index.php) Загружает ядро MODX Без $modx модуль не работает
spl_autoload_register(...) Подключает наши классы EditDocs\... Composer не всегда доступен в хостингах, делаем свой простой PSR-4
require_once(...editdocs.class.php) Подключает легаси-класс Он ещё нужен, но мы постепенно «оборачиваем» его
validateEnvironment($modx) Проверяет права, сессию, AJAX Безопасность — на входе, а не внутри бизнес-логики
new EditDocsAjaxHandler(...) Создаёт контроллер с зависимостями Внедрение зависимостей = тестируемость

Почему автозагрузчик свой, а не Composer?

spl_autoload_register(function ($class) {
        $prefix = 'EditDocs\\';
        $baseDir = __DIR__ . '/src/';
        // ... PSR-4 логика ...
    });

Причины:

  1. Совместимость: Не все хостинги позволяют запускать composer install.
  2. Изоляция: Мы не хотим, чтобы наш автозагрузчик конфликтовал с другими модулями.
  3. Простота: 15 строк кода, которые делают то, что нужно — и ничего лишнего.
💡 Правило: Используй стандартные инструменты, когда можешь. Создавай свои, когда стандартные не подходят.

EditDocsAjaxHandler — диспетчер запросов

class EditDocsAjaxHandler
    {
        protected DocumentParser $modx;
        private editDocs $editDocs;
        private ResponseSenderInterface $responseSender;

        public function __construct(
            DocumentParser $modx,
            editDocs $editDocs,
            ResponseSenderInterface $responseSender = null
        ) {
            $this->modx = $modx;
            $this->editDocs = $editDocs;
            $this->responseSender = $responseSender ?? new HttpHeaderResponseSender();
        }

        private function handleMassMove(): void
        {
            $input = EditDocsMassMoveInput::fromPost($_POST);
            $service = $this->createMassMoveService();
            echo $service->execute($input);
        }

        protected function createMassMoveService(): EditDocsMassMoveServiceInterface
        {
            $repository = new EditDocsDocumentRepository($this->modx);
            $validator = new EditDocsMassMoveValidator($repository, $this->editDocs);
            return new EditDocsMassMoveService($repository, $this->editDocs, $validator);
        }
    }

Паттерны и решения

1. Dependency Injection (Внедрение зависимостей)

public function __construct(
        DocumentParser $modx,
        editDocs $editDocs,
        ResponseSenderInterface $responseSender = null
    )

Зачем?

  • Тестируемость: В тестах можно передать мок $modx или $responseSender.
  • Гибкость: Если завтра появится новый способ отправки ответов — меняем реализацию, не трогая контроллер.
  • Ясность: Сразу видно, от чего зависит класс.

2. Фабричный метод createMassMoveService()

protected function createMassMoveService(): EditDocsMassMoveServiceInterface
    {
        $repository = new EditDocsDocumentRepository($this->modx);
        $validator = new EditDocsMassMoveValidator($repository, $this->editDocs);
        return new EditDocsMassMoveService($repository, $this->editDocs, $validator);
    }

Зачем выносить создание сервиса в отдельный метод?

Без фабрики С фабрикой
new EditDocsMassMoveService(...) прямо в handleMassMove() Можно переопределить в тесте через наследование
Сложно замокать зависимости Легко подменить сервис на мок
Нарушение принципа единой ответственности Контроллер только использует сервис, не создаёт его
💡 Совет: Если метод создаёт объект с зависимостями — вынеси его в отдельный protected-метод. Это «бэкдор» для тестов.

DTO — EditDocsMassMoveInput и магия Value Objects

«Данные — как гости: если не проверить на входе, потом придётся убирать беспорядок во всей квартире.»

Если раньше $_POST['parent1'] мог быть чем угодно — строкой, массивом, скриптом для инъекции — то теперь он проходит через двойной фильтр: сначала Value Object, потом DTO. И только после этого попадает в бизнес-логику.

Сначала — ParentId: Value Object, который не врет

// src/ValueObject/ParentId.php
    final class ParentId
    {
        private int $value;

        private function __construct(int $value)
        {
            if ($value < 1) {
                throw new InvalidArgumentException(
                    sprintf('ParentId must be a positive integer, %d given', $value)
                );
            }
            $this->value = $value;
        }

        public static function fromRaw(mixed $value): self
        {
            $int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
            if ($int === false) {
                throw new InvalidArgumentException(
                    sprintf('Invalid ParentId value: %s', var_export($value, true))
                );
            }
            return new self($int);
        }

        public static function tryFromRaw(mixed $value): array
        {
            try {
                return ['valid' => true, 'vo' => self::fromRaw($value), 'error' => null];
            } catch (InvalidArgumentException $e) {
                return ['valid' => false, 'vo' => null, 'error' => $e->getMessage()];
            }
        }

        public function toInt(): int { return $this->value; }
        public function equals(self $other): bool { return $this->value === $other->value; }
        public function __toString(): string { return (string)$this->value; }
    }
Метод Зачем Почему так
private __construct() Запретить создание «как попало» Гарантия: ParentId нельзя создать с невалидным значением
fromRaw() Создать VO с исключением Для случаев, когда ошибка = крах (например, внутри сервиса)
tryFromRaw() Создать VO с возвратом ошибки Для случаев, когда ошибка = валидация (например, в DTO из $_POST)
toInt() Получить «сырое» значение для БД Интерфейс с внешним миром: SQL, API, логирование
equals() Сравнить два ParentId Бизнес-логика: «источник и цель не должны совпадать»
__toString() Удобный вывод в логах/отладке Человеко-читаемое представление
💡 Правило: Value Object — это не просто обёртка. Это гарантия инварианта: «если объект существует, он валиден».

Затем — EditDocsMassMoveInput: DTO, который фильтрует вход

// src/Dto/EditDocsMassMoveInput.php
    final class EditDocsMassMoveInput
    {
        public ParentId $sourceParent;
        public ParentId $targetParent;
        public ?string $error;

        private function __construct() {}

        public static function fromPost(array $post): self
        {
            $dto = new self();

            $sourceResult = ParentId::tryFromRaw($post['parent1'] ?? null);
            $targetResult = ParentId::tryFromRaw($post['parent2'] ?? null);

            if (!$sourceResult['valid']) {
                $dto->error = 'Source parent ID: ' . $sourceResult['error'];
                return $dto;
            }

            if (!$targetResult['valid']) {
                $dto->error = 'Target parent ID: ' . $targetResult['error'];
                return $dto;
            }

            $dto->sourceParent = $sourceResult['vo'];
            $dto->targetParent = $targetResult['vo'];
            $dto->error = null;

            return $dto;
        }

        public function isValid(): bool { return $this->error === null; }
    }

Почему именно так?

  1. Приватный конструктор: Невозможно создать DTO «мимо» fromPost. Это гарантирует, что валидация всегда происходит.
  2. tryFromRaw вместо fromRaw: Мы ожидаем, что входные данные могут быть невалидными. Вместо исключения — мягкий возврат ошибки.
  3. ?string $error: Единое место для сообщения об ошибке. Контроллер проверяет isValid() и решает, что делать дальше.
  4. ParentId внутри: Как только данные прошли валидацию, они уже не могут стать невалидными. Бизнес-логика работает с гарантированно корректными значениями.

Поток данных

📥 $_POST
       │
       ▼
    [DTO] EditDocsMassMoveInput::fromPost($_POST)
       │  • tryFromRaw для parent1 и parent2
       │  • Если ошибка → $dto->error !== null
       │  • Если ок → $dto->sourceParent/targetParent — гарантированно валидные ParentId
       ▼
    [Validator] EditDocsMassMoveValidator::validate($dto)
       │  • Бизнес-правила: нельзя в себя, нельзя в потомка, и т.д.
       │  • Если ошибка → исключение (SameParentError, TargetIsChildError...)
       ▼
    [Service] EditDocsMassMoveService::execute($dto)
       │  • Работает только с валидными ParentId через ->toInt()
       │  • Не проверяет формат — только бизнес-логику
       ▼
    📤 Готовый HTML-ответ
💡 Принцип: DTO отвечает за «формат данных». Validator отвечает за «бизнес-правила». Service отвечает за «выполнение операции». Каждый слой знает только о слое ниже.

Сервис — EditDocsMassMoveService

class EditDocsMassMoveService implements EditDocsMassMoveServiceInterface
    {
        private EditDocsDocumentRepositoryInterface $repository;
        private editDocs $module;
        private EditDocsMassMoveValidatorInterface $validator;

        public function __construct(
            EditDocsDocumentRepositoryInterface $repository,
            editDocs $module,
            EditDocsMassMoveValidatorInterface $validator
        ) {
            $this->repository = $repository;
            $this->module = $module;
            $this->validator = $validator;
        }

    public function execute(EditDocsMassMoveInput $input): string
        {
            try {
                $this->validator->validate($input);
            } catch (SameParentError|TargetIsChildError|NoChildrenError|TargetDoesNotExistError|ValidationError $e) {
                return '
' . htmlspecialchars($e->getMessage()) . '
'; } $sourceId = $input->sourceParent->toInt(); $targetId = $input->targetParent->toInt(); try { // Бизнес-операция $moved = $this->repository->moveChildren($sourceId, $targetId); if (!$moved) { return '
' . $this->module->lang['error_tree'] . '
'; } // Обновление флагов if ($this->repository->updateFolderFlag($targetId, true) && $this->repository->updateFolderFlag($sourceId, false)) { $this->module->clearCache(); return '
' . $this->module->lang['ok_move'] . '
'; } return '
' . $this->module->lang['error_mass_move'] . '
'; } catch (PDOException $e) { return '
' . $this->module->lang['error_database'] . '
'; } catch (Throwable $e) { return '
' . $this->module->lang['error_unknown'] . '
'; } } }

Бизнес-правила в EditDocsMassMoveValidator

class EditDocsMassMoveValidator implements EditDocsMassMoveValidatorInterface
    {
        public function validate(EditDocsMassMoveInput $input): void
        {
            if (!$input->isValid()) {
                throw new ValidationError($input->error ?? 'Input validation failed');
            }

            $sourceId = $input->sourceParent->toInt();
            $targetId = $input->targetParent->toInt();

            // БИЗНЕС-ПРАВИЛО: источник и цель должны быть разными
            if ($input->sourceParent->equals($input->targetParent)) {
                throw new SameParentError('Source and target cannot be the same');
            }

            // БИЗНЕС-ПРАВИЛО: цель не должна быть потомком источника
            if ($this->repository->isChildOf($targetId, $sourceId)) {
                throw new TargetIsChildError('Target cannot be a child of source');
            }

            // БИЗНЕС-ПРАВИЛО: у источника должны быть дочерние ресурсы
            if (!$this->repository->hasChildren($sourceId)) {
                throw new NoChildrenError('Source has no children to move');
            }

            // Проверка существования целевого документа
            if (!$this->repository->documentExists($targetId)) {
                throw new TargetDoesNotExistError('Target document not found');
            }
        }
    }
Без валидатора С валидатором
Бизнес-правила размазаны по сервису Все правила в одном месте
Сложно тестировать правила отдельно Можно тестировать validate() изолированно
Новые правила = правка сервиса Новые правила = новый catch в сервисе
💡 Правило: Валидация формата — в DTO. Валидация бизнес-правил — в Validator. Это разные уровни ответственности.

Repository — два варианта, один интерфейс

interface EditDocsDocumentRepositoryInterface
    {
        public function moveChildren(int $fromParent, int $toParent): bool;
        public function updateFolderFlag(int $docId, bool $isFolder): bool;
        public function documentExists(int $docId): bool;
        public function isChildOf(int $child, int $parent): bool;
        public function hasChildren(int $parentId): bool;
    }

Вариант 1: EditDocsDocumentLegacyRepository (условно для 1.4)

class EditDocsDocumentLegacyRepository implements EditDocsDocumentRepositoryInterface
    {
        private DocumentParser $modx;

        public function __construct(DocumentParser $modx)
        {
            $this->modx = $modx;
        }

        public function moveChildren(int $fromParent, int $toParent): bool
        {
            $table = $this->modx->getFullTableName('site_content');
            return $this->modx->db->query(
                "UPDATE {$table} SET parent = {$toParent} WHERE parent = {$fromParent}"
            ) !== false;
        }
        // ... остальные методы
    }

Вариант 2: EditDocsDocumentRepository (современный, с плейсхолдерами)

class EditDocsDocumentRepository implements EditDocsDocumentRepositoryInterface
    {
        private DocumentParser $modx;

        public function __construct(DocumentParser $modx)
        {
            $this->modx = $modx;
        }

        public function moveChildren(int $from, int $to): bool
        {
            $table = $this->getTable();
            $pdo = $this->getPdo();

            $sql = "UPDATE {$table}
                    SET parent = :to
                    WHERE parent = :from";

            $stmt = $pdo->prepare($sql);

            if (!$stmt->execute([':to' => $to, ':from' => $from])) {
                return false;
            }

            return $stmt->rowCount() > 0;
        }
        // ... остальные методы с плейсхолдерами
    }
Причина Почему это важно
Обратная совместимость Старые проекты на 1.4 могут использовать LegacyRepository
Постепенная миграция Новые проекты могут сразу использовать современный Repository
Тестируемость Интерфейс позволяет подменить реализацию на мок в тестах
Безопасность Современный вариант использует подготовленные выражения
💡 Правило: Интерфейс — это контракт. Реализация — это деталь. Меняй детали, не меняя контракт.

Тесты после рефакторинга — не «надо бы», а «страховка»

«Код без тестов — как машина без тормозов. Едет? Да. Но останавливать страшно.»

После того как мы вынесли логику massMove() из легаси-спагетти в чистые слои (DTO → Validator → Service → Repository), пришло время зафиксировать результат. Не для галочки. А чтобы завтра, когда кто-то (возможно, вы) захочет добавить фичу, можно было нажать «запустить тесты» и быть уверенным: «Ничего не сломал».

Что мы тестируем и зачем?

Файл: tests/Legacy/EditDocsMassMoveFinalTest.php

Цель теста: убедиться, что после рефакторинга EditDocsAjaxHandler::handleRequest() корректно:

  1. Распознаёт запрос на массовое перемещение (parent1 + parent2).
  2. Передаёт данные в EditDocsMassMoveService.
  3. Возвращает правильный ответ (успех или ошибку).
  4. Обрабатывает исключения без падения.
💡 Ключевая мысль: Мы не тестируем «всё». Мы тестируем «границы» — точки, где наш новый код встречается со старым миром ($_POST, echo, $modx).

Моки: почему мы «притворяемся», а не используем реальные классы

$this->mockModx = $this->getMockBuilder(\DocumentParser::class)
        ->disableOriginalConstructor()
        ->onlyMethods(['logEvent', 'clearCache', 'getFullTableName'])
        ->getMock();
Реальный $modx Мок $modx
❌ Требует инициализации всего MODX ✅ Создаётся за миллисекунды
❌ Зависит от БД, конфига, сессии ✅ Изолирован, предсказуем
❌ Тест = интеграционный, медленный ✅ Тест = юнит-тест, быстрый
❌ Сложно проверить, что вызван logEvent ✅ Легко: $this->mockModx->expects($this->once())->method('logEvent')

Тест успешного сценария: testHandleRequest_MassMove_Success

public function testHandleRequest_MassMove_Success(array $postData, string $expectedOutput): void
    {
        $_POST = $postData;

        $responseSender = new TestResponseSender();

        $handler = $this->getMockBuilder(EditDocsAjaxHandler::class)
            ->setConstructorArgs([$this->mockModx, $this->mockEditDocs, $responseSender])
            ->onlyMethods(['createMassMoveService'])
            ->getMock();

        $mockService = $this->createMock(EditDocsMassMoveService::class);
        $mockService
            ->method('execute')
            ->with($this->isInstanceOf(EditDocsMassMoveInput::class))
            ->willReturn($expectedOutput);

        $handler
            ->method('createMassMoveService')
            ->willReturn($mockService);

        ob_start();
        $handler->handleRequest();
        $actualOutput = ob_get_clean();

        $this->assertSame($expectedOutput, $actualOutput);
    }

Почему createMassMoveService вынесен в protected-метод?

Без выноса С выносом
new EditDocsMassMoveService(...) прямо в handleMassMove() Можно переопределить в тесте через ->onlyMethods(['createMassMoveService'])
Сложно подменить сервис на мок Легко: $handler->method('createMassMoveService')->willReturn($mockService)
Тест зависит от реальных зависимостей Тест изолирован, быстрый, предсказуемый
💡 Совет: Если метод создаёт объект с зависимостями — сделай его protected. Это «бэкдор» для тестов, который не ломает инкапсуляцию в продакшене.

Почему TestResponseSender?

// tests/Stubs/TestResponseSender.php
    class TestResponseSender implements ResponseSenderInterface
    {
        public ?string $lastContent = null;
        public ?int $lastStatusCode = null;
        public ?array $lastHeaders = null;

        public function send(string $body, int $code, array $headers): void
        {
            $this->lastContent = $body;
            $this->lastStatusCode = $code;
            $this->lastHeaders = $headers;
            // Никакого header(), echo, exit — только запомнить
        }
    }

Зачем это нужно?

  1. Изоляция: Тест не зависит от реального HTTP-стека.
  2. Проверяемость: Можно легко проверить, что и с каким кодом должно было быть отправлено.
  3. Гибкость: Если завтра мы решим отправлять ответы через WebSocket — поменяем реализацию ResponseSenderInterface, а тесты останутся зелёными.

Выводы: что мы узнали

  1. Легаси — это не приговор. Это просто код, который можно улучшить маленькими шагами.
  2. Характеризационные тесты — это ваша страховка. Они не проверяют «правильность», но фиксируют «фактичность».
  3. Разделение ответственности (DTO/Service/Repository) — это не перемудрили, а подготовились к будущему.
  4. Интерфейсы — это контракт. Реализация — это деталь. Меняй детали, не меняя контракт.
  5. Внедрение зависимостей — это не модно, а тестируемо.
  6. PHPUnit — это не сложно. Это стандарт индустрии, который экономит нервы.
💡 Главный вывод: «Рефакторинг — это не про то, чтобы сделать код идеальным. Это про то, чтобы сделать его достаточно хорошим, чтобы с ним хотелось работать завтра.»

Челлендж для читателя

  1. Открой свой легаси-проект.
  2. Найди одну функцию, которую страшно менять.
  3. Напиши для неё характеризационный тест.
  4. Сделай один маленький рефакторинг.
  5. Запушь. Почувствуй, как страх превращается в уверенность.
«Лучше маленький шаг вперёд, чем большой страх остаться на месте».

Что изменилось в ощущениях:

  • Было: «Страшно трогать — вдруг сломается» → Стало: «Есть тесты — можно менять»
  • Было: «Где тут логика перемещения?» → Стало: «Открываю EditDocsMassMoveService — всё на месте»
  • Было: «Как добавить новое правило?» → Стало: «Добавляю метод в Validator — готово»
  • Было: «А если придёт parent1=abc?» → Стало: «DTO отклонит на входе, пользователь увидит понятную ошибку»
Главный результат: код перестал быть «чёрным ящиком». Теперь это система, которую можно понимать, тестировать и улучшать — по одному маленькому шагу за раз.

Что это дало для будущего:

  1. Масштабируемость: новые фичи добавляются в соответствующий слой, не ломая существующие.
  2. Тестируемость: юнит-тесты для сервисов, интеграционные — для контроллеров, характеризационные — для легаси.
  3. Поддерживаемость: новый разработчик может открыть src/ и быстро понять, где что лежит.
  4. Безопасность: валидация на входе + подготовленные выражения = защита от распространённых уязвимостей.

P.S. Если после прочтения тебе захотелось открыть свой проект и начать рефакторинг — миссия выполнена.

Если захотелось сказать «да ладно, у меня и так работает» — перечитай пункт про страх и попробуй ещё раз. 😊

Обсуждение

💬 Есть вопросы? Пишите в Telegram-канал или на ping@ambrion.dev