tests: ModelStoreTest — закрыты дыры покрытия Range и Content-Range

resumesPartialDownload: счётчики Range-запросов и байт; сервер отдаёт 400
без Range для .part — запасной путь «начать заново» закрыт.

failsOnSizeMismatch разделена на два:
  - rejectsWrongTotalFromContentRange — сервер соврал в Content-Range,
    бьёт в проверку actualTotal != expectedTotal (вторая, достижимая);
  - truncatedResponseDoesNotProduceFile — оборванный ответ, по факту ловит
    ветку IOException → «не удалось скачать» (а не first size check).

В mutations.tsv убрана строка про if (declaredBody >= 0 && written !=
declaredBody) — через java.net/http недостижимо (оборванный ответ даёт
IOException, не EOF). Добавлена строка про if (expectedTotal >= 0 &&
actualTotal != expectedTotal) — теперь убивает rejectsWrongTotalFromContentRange.

Все 18 мутаций (4 из ModelStore) убиты прогоном mutation_check.py.
This commit is contained in:
2026-10-02 04:48:35 +03:00
parent 3054705176
commit 69a0ebe0fa
4 changed files with 579 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: закрыть ДВЕ дыры в покрытии `ModelStoreTest`,
доказанные мутационной проверкой. Правки только в тестовом файле, боевой код НЕ трогать.
Прогон, который их нашёл:
python3 e2e/mutation_check.py --gradle ./gradlew
Выжившие мутации:
1. `ModelStore.kt`: `if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")`
→ `if (false) ...` | ожидался провал `resumesPartialDownload` — НЕ упал.
2. `ModelStore.kt`: `if (declaredBody >= 0 && written != declaredBody) {`
→ `if (false) {` | ожидался провал `failsOnSizeMismatch` — НЕ упал.
## Почему они выжили (разобрано, не догадка)
**1. `resumesPartialDownload`.** Тестовый сервер (`serveBytes`) отдаёт `206` с хвостом только если
в запросе есть заголовок `Range`; без него он отдаёт `200` и **полное содержимое**. Поэтому при
мутации (Range не отправляется) срабатывает штатный запасной путь «начать заново», итоговый файл
получается правильным — и тест, проверяющий только «файл в итоге верный», проходит.
Тест не проверяет того, ради чего написан: что докачка **действительно** шла хвостом.
**2. `failsOnSizeMismatch`.** Сервер объявляет `Content-Length` больше, чем пишет, соединение
обрывается — у клиента вылетает `IOException`, который `download` превращает в
`IllegalStateException("не удалось скачать ...")`. Тест принимает `msg.contains("не удалось скачать")`,
поэтому проверка размера не проверяется вообще: исключение приходит из другого места.
**Важное наблюдение по бою.** В `ModelStore.download` две проверки размера:
`written != declaredBody` (первая) и `part.length() != expectedTotal` (вторая, из `Content-Range`).
Через `java.net.http` первая на практике недостижима: оборванный ответ всегда даёт `IOException`,
а не чистый EOF. Значит это защитный код, а не дыра в покрытии — в таблице мутаций его надо
заменить на мутацию ВТОРОЙ проверки, которая достижима (см. ниже).
## Что сделать
### Правка теста `resumesPartialDownload`
Сделать так, чтобы «докачка» была доказана, а не предположена:
- сервер записывает в счётчики: сколько запросов пришло, у скольких был заголовок `Range`,
и сколько всего байт тела он отдал;
- **если заголовка `Range` нет для файла, у которого уже есть `.part`** — сервер отвечает кодом
`400` и тела не отдаёт (докачки без `Range` не бывает; тест не должен иметь запасного пути);
- после `ensure` тест обязан утверждать:
- `rangeRequests >= 1` — докачка действительно была запрошена;
- файл в итоге побайтно равен исходному (оставить);
- `.part` переименован (оставить).
При мутации №1 (`Range` не отправляется) сервер ответит `400` → `ensure` бросит исключение → тест упадёт.
### Замена сценария `failsOnSizeMismatch` на два теста
**`rejectsWrongTotalFromContentRange`** (новый, закрывает мутацию №2) — «сервер соврал про общий
размер, файл принимать нельзя»:
- в директории лежит `.part` = первые 4000 байт файла из 8000;
- сервер на запрос с `Range: bytes=4000-` отвечает `206`:
- `Content-Length: 4000`, тело — реальный хвост 4000 байт (то есть транспорт отдаёт ровно
столько, сколько объявил — никакой `IOException`);
- `Content-Range: bytes 4000-7999/999999` — **итог соврал**;
- ожидание: `ModelStore.ensure` бросает `IllegalStateException`, сообщение содержит `размер`,
целевого файла нет, `.part` удалён.
Проверить, что сценарий действительно бьёт в нужную проверку: при `if (false)` на
`part.length() != expectedTotal` тест обязан провалиться (файл будет установлен, исключения не будет).
**`truncatedResponseDoesNotProduceFile`** (переименовать бывший `failsOnSizeMismatch`) — оставить
как проверку поведения «оборванный ответ не оставляет файла», но **убрать из принимаемых сообщение
«не удалось скачать»**, чтобы тест не «зеленел» за счёт сетевой ошибки. Ожидать явно любое
`IllegalStateException` с непустым сообщением и отсутствие целевого файла и `.part`.
Отдельно проверить и записать в отчёте: какую ветку кода реально ловит этот тест (по сообщению) —
то есть является ли он проверкой размера или сетевого обрыва. В отчёте написать прямо.
### Таблица мутаций `e2e/mutations.tsv`
- **Убрать** строку, целящуюся в `if (declaredBody >= 0 && written != declaredBody) {`
(по разбору выше — недостижимо через `java.net.http`; ложная цель).
- **Добавить** мутацию во вторую проверку:
`if (expectedTotal >= 0 && actualTotal != expectedTotal) {` → `if (false) {`,
обязанный уронить `rejectsWrongTotalFromContentRange`.
- Строку про `Range` оставить, но теперь она обязана валить `resumesPartialDownload`.
Правило: каждая строка таблицы — либо убитая мутация, либо честное объяснение в комментарии,
почему цель недостижима (с доказательством прогоном).
## Обязательная проверка (приложить вывод)
```bash
cd /root/WORK/memo
python3 e2e/mutation_check.py --gradle ./gradlew 2>&1 | tail -25
echo "--- ожидается: ВСЕ МУТАЦИИ УБИТЫ"
./gradlew test --rerun-tasks -q 2>&1 | tail -3
```
Плюс отдельно, для каждого из двух новых/изменённых тестов — доказательство, что он валит мутацию:
применить мутацию руками, прогнать только `ModelStoreTest`, показать FAIL, откатить
(`git checkout -- memo-core/src/main/kotlin/memo/core/ModelStore.kt`), убедиться в PASS.
## СТРОГИЕ ЗАПРЕТЫ
- **Не менять боевой код** `ModelStore.kt` (и вообще ничего в `src/main`). Заказ — только тесты
и таблица мутаций.
- Не менять другие тесты.
- Не добавлять зависимости.
- Не выводить план текстом; сразу правь файлы.
- Не удалять `.gitignore`, не коммитить `models/` и `*.db`.