Foundry: тесты публикации, замены и деактивации
В предыдущем уроке мы разобрали создание реестра. Теперь рассмотрим курс не как отдельную запись после одного вызова, а как изменяющийся объект. Учебный выпуск публикуется, заменяется, деактивируется и при необходимости возвращается.
Подготовим последовательный сценарий для номера 17. На каждом шаге будем сравнивать отпечаток и активность. Исходник пока не исполнялся: таблица ниже описывает требуемые результаты, которые будущий запуск должен подтвердить или опровергнуть.
Текущее состояние не хранит всю историю
Базовый mapping содержит только два поля курса: contentHash и active. В нём нет номера редакции, списка прошлых отпечатков или времени последней публикации. Поэтому замена записывает новый хеш поверх старого, а деактивация изменяет только флаг.
Исторические выпуски можно связывать с событиями и внешним архивом, но такая система изучается отдельно. У нашей проверки более точный предмет: поведение текущей записи при разрешённых переходах. Мы не добавляем методы версии или удаления во время объяснения существующего контракта.
| Шаг | Действие издателя | Ожидаемая запись курса 17 |
|---|---|---|
| Начало | Нет вызовов | (0, false) |
| Выпуск | publish(17, first) |
(first, true) |
| Замена | publish(17, second) |
(second, true) |
| Отключение | deactivate(17) |
(second, false) |
| Возвращение | publish(17, third) |
(third, true) |
Само повторение номера допустимо. Это важное отличие от контракта, который разрешает только однократную регистрацию. Если будущая реализация добавит запрет повторной публикации, она изменит договор и должна сопровождаться отдельным решением о совместимости.
Готовим читаемое сравнение
Для нескольких чтений добавим внутреннюю вспомогательную функцию в CourseCatalogTest. Она не меняет состояние и не создаёт зависимость между сценариями; только сокращает повторяемое чтение двух полей.
function assertCourse(
uint256 id,
bytes32 expectedDigest,
bool expectedActive
) internal view {
(bytes32 digest, bool active) = catalog.courses(id);
assertEq(digest, expectedDigest);
assertEq(active, expectedActive);
}
Слово expected здесь существенно. Мы передаём ожидаемые значения из договора, а не сначала читаем их из реестра и затем сравниваем с тем же реестром. Иначе утверждение могло бы стать тождеством, выполняющимся независимо от ошибки приложения.
Теперь последовательность помещается в одной функции. Перед каждым изменяющим вызовом явно задаём издателя; чтения не потребляют следующее одноразовое полномочие.
function testEditionLifecycle() public {
bytes32 first = keccak256("edition-one");
bytes32 second = keccak256("edition-two");
bytes32 third = keccak256("edition-three");
assertCourse(17, bytes32(0), false);
vm.prank(publisher);
catalog.publish(17, first);
assertCourse(17, first, true);
vm.prank(publisher);
catalog.publish(17, second);
assertCourse(17, second, true);
vm.prank(publisher);
catalog.deactivate(17);
assertCourse(17, second, false);
vm.prank(publisher);
catalog.publish(17, third);
assertCourse(17, third, true);
}
Ожидаемое чтение после отключения сохраняет second. Поэтому сценарий должен обнаружить ошибочный delete courses[id], который обнуляет весь struct. Проверка только флага такую потерю отпечатка пропустила бы. Восстановление через publish одновременно задаёт новый выпуск и вновь включает курс.
Отдельно разбираем повторное отключение
Метод deactivate требует активной записи. Пустой курс и уже отключённый курс возвращают NotActive(). Это не ошибка валидации номера: текущая модель проверяет состояние, поэтому даже произвольный ненулевой номер может быть неактивным.
Добавим короткий сценарий, который публикует выпуск, отключает его и пытается отключить снова. Для второго действия требуется ожидаемый отказ, а сохранённый отпечаток должен остаться прежним.
function testSecondDeactivationIsRejected() public {
bytes32 digest = keccak256("edition-one");
vm.startPrank(publisher);
catalog.publish(17, digest);
catalog.deactivate(17);
vm.expectRevert(CourseCatalog.NotActive.selector);
catalog.deactivate(17);
vm.stopPrank();
assertCourse(17, digest, false);
}
Здесь повторяющееся полномочие ограничено одной явной последовательностью. Внутренние вызовы инструмента vm не являются вызовами исследуемого реестра. После stopPrank чтение происходит с обычным отправителем проверяющего контракта; getter не ограничен ролью издателя.
Проверяем независимость номеров
Изменим пример: до жизненного цикла курса 17 опубликуем курс 18 с собственным отпечатком. После замены и отключения первого курса второй должен оставаться активным и хранить свой выпуск. Такая вариация обнаруживает ошибку индексирования mapping или случайное использование постоянного номера.
Один сценарий со всеми возможными действиями быстро становится трудным для диагностики. Поэтому независимость записей лучше вынести в отдельную функцию с двумя номерами, а основную историю оставить сосредоточенной на переходах 17. Каждая функция тогда имеет понятную причину существования и небольшой набор ожиданий.
Проверки событий можно добавить к конкретным шагам, используя приём седьмого урока. Например, замена должна испустить Published(17, second), отключение — Deactivated(17). Однако не надо описывать события как способ чтения старого значения из текущего mapping: это разные источники информации.
Состояние после запрещённой замены
Рассмотрим ещё одну вариацию без изменения базового контракта. Издатель публикует first, затем читатель пытается записать second. Здесь ожидаем Unauthorized() и после отказа читаем именно (first, true). Если вместо этого проверять только ненулевой хеш, незаметной останется ошибочная запись чужого выпуска.
Такое требование отличается от проверки пустого реестра в шестом уроке. Отрицательный вызов не обязан обнулять запись: он должен сохранять предыдущее состояние. Подготовка начального выпуска выполняется явно в функции, благодаря чему читателю видны обе границы перехода. Сценарий можно назвать testReaderCannotReplaceEdition, чтобы название передавало смысл, а не только используемый инструмент.
Для запрещённого отключения логика аналогична. После публикации читатель вызывает deactivate, получает ошибку доступа, а запись остаётся активной. Эта вариация показывает, что полномочие распространяется на оба изменяющих метода. Проверка запрета одного метода не позволяет автоматически приписать такое же поведение другому, даже когда они используют похожие условия.
Что увидит приложение
Для интерфейса флаг false означает, что публикация не предлагается как действующая. Ненулевой отпечаток при этом позволяет показать последний известный выпуск или связать запись с архивом. Решение о таком представлении принадлежит продукту, а контракт предоставляет необходимые различимые значения.
Если интерфейс считает любой ненулевой хеш доказательством активности, он ошибается после деактивации. Если считает false признаком отсутствия всякой истории, он тоже теряет смысл сохранённого хеша. Наша таблица переходов помогает сформулировать эти отличия без догадок по одному скриншоту.
Текущая последовательность ограничена выбранными тремя отпечатками и одним номером. Она не перебирает весь диапазон входов и все возможные истории. Далее мы будем постепенно расширять область рассмотрения, сохраняя эту функцию как понятный пример и основу для диагностики.
В следующем уроке рассмотрим ноль, минимальные и максимальные значения. Семантика mapping и значений по умолчанию описана в Solidity 0.8.30. Оглавление курса.