test(infra): coverage-gate + acceptInvitation atomicity + turn-end unit (#324) #335

Merged
vvzvlad merged 1 commits from fix/324-coverage-gate into develop 2026-07-04 17:45:49 +03:00
Collaborator

Summary

Хвост #244 (test/infra). Три пункта, все зелёные. closes #324.

1. Coverage-gate (основной)

На develop не было ни coverage-тулинга, ни порога. Добавил @vitest/coverage-v8@4.1.6 (пиннинг под используемый vitest) в три vitest-пакета — git-sync, editor-ext (+ его недостающий прямой vitest), apps/client — и включил v8-coverage с пер-пакетными порогами (root-vitest-конфига нет). v8, не istanbul: истанбул ломался на ESM-импорте @docmost/editor-ext; v8 берёт нативное runtime-покрытие и не переразбирает ESM. enabled: true вшивает гейт в обычный test-скрипт → pnpm -r test (CI-entrypoint) его исполняет без ручного --coverage.

Пороги на ~4–5 пунктов НИЖЕ измеренного — гейт проходит сегодня и падает на регрессе (проверено: lines=95 на editor-ext → exit 1):

пакет измерено S/B/F/L порог
git-sync 91.78/79.16/76.76/92.46 88/75/72/88
editor-ext 58.58/48.1/64.96/58.91 54/44/60/54
client 59.93/58/48.47/59.39 55/53/44/55

all: false (задокументировано в конфигах): покрытие считается по файлам, которых касаются тесты — ловит ослабление/удаление тестов у покрытых файлов, но не новый непокрытый файл. Осознанный компромисс: с all: true пороги пришлось бы утопить (бочки/типы), гейт стал бы бессмысленным.

2. acceptInvitation атомарность (int-spec)

apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts (+ сидеры в db.ts). Реальный WorkspaceInvitationService с реальными репозиториями против тестовой Kysely, заглушены только пост-коммитные коллабораторы. Ассертит инвариант users_email_workspace_id_unique: (a) два КОНКУРЕНТНЫХ accept → ровно один успех, один BadRequestException('Invitation already accepted'), membership==1, приглашение потреблено; (b) повторный accept → всё ещё одно членство; (c) выживший — в дефолтной группе воркспейса (вся транзакция, без полусостояний). Прогнан на реальном Postgres+Redis: 3/3.

3. decideTurnEnd юнит

Символа decideTurnEnd в репо нет; логика завершения хода — в onFinish chat-thread.tsx. Добавил блок в существующий chat-thread.test.tsx: clean → flush queued (continue); abort/disconnect/error → queue сохранён (end) + нужный notice; parent нотифицируется на всех исходах. 8 passed.

How verified

  • vitest run с гейтом: git-sync 712, editor-ext 247, client 888 — все exit 0.
  • gate-fails-on-regression: подтверждён exit 1.
  • int-spec на реальном Postgres: 3/3.
  • tsc --noEmit — чисто для client и server.
  • pnpm install --frozen-lockfile — локфайл консистентен и аддитивен (проверено --lockfile-only --offline → байт-идентично).

Checklist

  • pnpm test считает покрытие; порог проваливает прогон при регрессе (DoD пункта 1)
  • acceptInvitation покрыт int-spec на атомарность (DoD пункта 2)
  • decideTurnEnd-логика (onFinish) покрыта юнитом (пункт 3)
  • вне заявленного scope ничего не менялось

Заметки для ревью (non-blocking, из внутреннего ревью)

  • all: false — гейт не ловит регресс через НОВЫЙ непокрытый файл (задокументировано; кандидат на отдельный lint «новый .ts требует теста»).
  • editor-ext подтянул свежие транзитивы (jsdom@27 vs 25 у остальных) из-за прямого vitest — локфайл консистентен, риск низкий (md/tiptap-тесты нечувствительны к минору jsdom).

🤖 Generated with Claude Code

## Summary Хвост #244 (test/infra). Три пункта, все зелёные. `closes #324`. ### 1. Coverage-gate (основной) На develop не было ни coverage-тулинга, ни порога. Добавил `@vitest/coverage-v8@4.1.6` (пиннинг под используемый vitest) в три vitest-пакета — `git-sync`, `editor-ext` (+ его недостающий прямой `vitest`), `apps/client` — и включил v8-coverage с **пер-пакетными порогами** (root-vitest-конфига нет). **v8, не istanbul:** истанбул ломался на ESM-импорте `@docmost/editor-ext`; v8 берёт нативное runtime-покрытие и не переразбирает ESM. `enabled: true` вшивает гейт в обычный `test`-скрипт → `pnpm -r test` (CI-entrypoint) его исполняет без ручного `--coverage`. Пороги на ~4–5 пунктов НИЖЕ измеренного — гейт проходит сегодня и падает на регрессе (проверено: `lines=95` на editor-ext → exit 1): | пакет | измерено S/B/F/L | порог | |---|---|---| | git-sync | 91.78/79.16/76.76/92.46 | 88/75/72/88 | | editor-ext | 58.58/48.1/64.96/58.91 | 54/44/60/54 | | client | 59.93/58/48.47/59.39 | 55/53/44/55 | **`all: false`** (задокументировано в конфигах): покрытие считается по файлам, которых касаются тесты — ловит ослабление/удаление тестов у покрытых файлов, но не новый непокрытый файл. Осознанный компромисс: с `all: true` пороги пришлось бы утопить (бочки/типы), гейт стал бы бессмысленным. ### 2. `acceptInvitation` атомарность (int-spec) `apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts` (+ сидеры в `db.ts`). Реальный `WorkspaceInvitationService` с реальными репозиториями против тестовой Kysely, заглушены только пост-коммитные коллабораторы. Ассертит инвариант `users_email_workspace_id_unique`: (a) два КОНКУРЕНТНЫХ accept → ровно один успех, один `BadRequestException('Invitation already accepted')`, membership==1, приглашение потреблено; (b) повторный accept → всё ещё одно членство; (c) выживший — в дефолтной группе воркспейса (вся транзакция, без полусостояний). Прогнан на реальном Postgres+Redis: **3/3**. ### 3. `decideTurnEnd` юнит Символа `decideTurnEnd` в репо нет; логика завершения хода — в `onFinish` `chat-thread.tsx`. Добавил блок в существующий `chat-thread.test.tsx`: clean → flush queued (continue); abort/disconnect/error → queue сохранён (end) + нужный notice; parent нотифицируется на всех исходах. **8 passed**. ## How verified - `vitest run` с гейтом: git-sync **712**, editor-ext **247**, client **888** — все exit 0. - gate-fails-on-regression: подтверждён exit 1. - int-spec на реальном Postgres: **3/3**. - `tsc --noEmit` — чисто для client и server. - `pnpm install --frozen-lockfile` — локфайл консистентен и аддитивен (проверено `--lockfile-only --offline` → байт-идентично). ## Checklist - [x] `pnpm test` считает покрытие; порог проваливает прогон при регрессе (DoD пункта 1) - [x] acceptInvitation покрыт int-spec на атомарность (DoD пункта 2) - [x] decideTurnEnd-логика (onFinish) покрыта юнитом (пункт 3) - [x] вне заявленного scope ничего не менялось ## Заметки для ревью (non-blocking, из внутреннего ревью) - `all: false` — гейт не ловит регресс через НОВЫЙ непокрытый файл (задокументировано; кандидат на отдельный lint «новый .ts требует теста»). - editor-ext подтянул свежие транзитивы (jsdom@27 vs 25 у остальных) из-за прямого `vitest` — локфайл консистентен, риск низкий (md/tiptap-тесты нечувствительны к минору jsdom). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
agent_coder added 1 commit 2026-07-04 12:38:25 +03:00
Tail of #244. Three items:

1. Coverage-gate (main). develop had no coverage tooling at all. Added
   @vitest/coverage-v8@4.1.6 (pinned to the vitest already in use) to the three
   vitest packages — git-sync, editor-ext (which also gains its missing direct
   `vitest` devDep), apps/client — and enabled v8 coverage with per-package
   thresholds (no root vitest config exists, so per-package is the only
   meaningful scope). v8 provider is chosen deliberately: istanbul broke on the
   ESM `@docmost/editor-ext` barrel; v8 collects native runtime coverage and
   never re-parses ESM. `enabled: true` wires the gate into the plain `test`
   script, so `pnpm -r test` (the CI entrypoint) enforces it without a manual
   `--coverage`. Thresholds set ~4-5 pts below measured current coverage so the
   gate PASSES today and FAILS on regression (verified: forcing lines=95 on
   editor-ext exits 1). `all: false` — coverage counts test-touched files;
   documented in the configs (with `all: true` the many untested type/barrel
   files would sink the % and make the gate meaningless).
   Measured→threshold (S/B/F/L): git-sync 91.78/79.16/76.76/92.46 → 88/75/72/88;
   editor-ext 58.58/48.1/64.96/58.91 → 54/44/60/54; client 59.93/58/48.47/59.39
   → 55/53/44/55. All exit 0.

2. acceptInvitation atomicity int-spec. New
   apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts
   (+ createDefaultGroup/createInvitation seeders in test/integration/db.ts per
   its convention). Wires the real WorkspaceInvitationService with real
   User/Group/GroupUser repos against the test Kysely, stubbing only the
   post-commit collaborators. Asserts the invariant protected by
   users_email_workspace_id_unique: (a) two CONCURRENT accepts → exactly one
   fulfilled, one BadRequestException('Invitation already accepted'), membership
   count == 1, invitation consumed; (b) repeated sequential accept → still one
   membership; (c) the survivor is in the workspace default group (whole-tx, no
   torn state). Ran against real Postgres+Redis: 3/3 pass.

3. turn-end decision unit test. `decideTurnEnd` does not exist as a symbol; the
   turn-end logic lives in chat-thread.tsx's onFinish handler. Added a focused
   block to the existing chat-thread.test.tsx (matching its hoisted-mock style):
   clean finish → flush queued (continue); abort/disconnect/error → queue
   preserved (end) with the correct notice; parent notified on every terminal
   outcome. 8 passed (3 existing + 5 new).

Verified: git-sync 712, editor-ext 247, client 888 (all with the gate, exit 0);
int-spec 3/3 (real Postgres); tsc --noEmit clean for client + server;
pnpm install --frozen-lockfile consistent (lockfile additive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-04 12:38:41 +03:00
Author
Collaborator

Готово к ревью (review/needs). Все 3 пункта #324 зелёные: coverage-gate (v8, чтобы обойти istanbul-ESM-грабли; пер-пакетные пороги на ~4-5пт ниже измеренного, вшито в pnpm -r test, fails-on-regression подтверждён exit 1), acceptInvitation atomicity int-spec (прогнан на реальном Postgres, 3/3, гонка двух accept → одно членство через unique-constraint), decideTurnEnd (символа нет — покрыл onFinish-логику chat-thread, 8 passed). Локфайл аддитивен, --frozen-lockfile консистентен. Два non-blocking момента вынес в тело PR: all:false (задокументировано в конфигах) и транзитивный дрейф editor-ext (jsdom27, риск низкий). tsc чисто.

Готово к ревью (review/needs). Все 3 пункта #324 зелёные: coverage-gate (v8, чтобы обойти istanbul-ESM-грабли; пер-пакетные пороги на ~4-5пт ниже измеренного, вшито в pnpm -r test, fails-on-regression подтверждён exit 1), acceptInvitation atomicity int-spec (прогнан на реальном Postgres, 3/3, гонка двух accept → одно членство через unique-constraint), decideTurnEnd (символа нет — покрыл onFinish-логику chat-thread, 8 passed). Локфайл аддитивен, --frozen-lockfile консистентен. Два non-blocking момента вынес в тело PR: all:false (задокументировано в конфигах) и транзитивный дрейф editor-ext (jsdom27, риск низкий). tsc чисто.
Collaborator

Ревью — #335 (test/infra: coverage-gate + acceptInvitation atomicity + turn-end unit, хвост #244/#324), round 1, head baa41d66, base develop f5d19f97

Вердикт: PASS — чистый test/infra-PR, продакшн-код не тронут. Все три части сделаны добротно и не вакуозно, объективка зелёная (прогнал полностью сам, включая int-spec против реального Postgres). Готово к мержу.

Объективку запустил сам (head baa41d66, main-клон + docker Postgres+pgvector):

  • acceptInvitation int-spec: 3/3 passed, стабильно 4/4 прогона (реальный WorkspaceInvitationService + реальные UserRepo/GroupRepo/GroupUserRepo против реального Postgres; застаблены только пост-коммитные периферии — mail/session/billing/audit/env — на tx-пути ничего не заглушено). Логи duplicate key ... users_email_workspace_id_unique в выводе — это ОЖИДАЕМЫЙ путь проигравшего accept'а (сервис ловит → BadRequestException('Invitation already accepted')).
  • Coverage-gate зелёный на всех трёх пакетах: git-sync exit 0 (711 passed); editor-ext 58.58/48.1/64.96/58.91 vs пороги 54/44/60/54; client 892 passed, 59.93/58/48.47/59.39 vs 55/53/44/55. Измеренные числа точно совпали с таблицей в теле PR; пороги ниже измеренного с запасом (регресс уронит гейт).

Что проверено по существу (веер 7 аспектов — security/stability/test-coverage/conventions/simplification/regressions/coherence, все LGTM):

  • int-spec не вакуозен и пиннит реальный инвариант. Гонка настоящая, не «детерминизм по удаче»: оба pre-tx SELECT уходят до входа в любую tx (пул max:5), оба видят живое приглашение → проигравший ГАРАНТИРОВАННО входит в tx и ловит unique-conflict (никогда не «not found»), что и ассертит тест по точному message. «Оба успеха» невозможны из-за users_email_workspace_id_unique, дедлока нет (одностороннее ожидание). Ассерты падают, если сломать атомарность/констрейнт/трансляцию ошибки.
  • Не флейки. jest maxWorkers:1 + per-file изоляция реестра модулей; afterAll→destroyTestDb() закрывает пул; изоляция по свежему randomUUID() workspace + email на каждый сид, все запросы фильтруют по workspaceId → в общей docmost_test кросс-тестовой утечки нет. Property-тесты (numRuns 100/300) дают побайтово одинаковое покрытие прогон-к-прогону (ветки насыщены) — «рандом + жёсткий branch-порог = флейк» эмпирически опровергнут (git-sync gate прогнан дважды — идентичные числа).
  • Coverage-gate реальный, не театр. Вшит в обычный test (enabled:true), CI гоняет pnpm -r test → порог исполняется без флага. Все три vitest-пакета покрыты (server=jest, mcp=node --test — другой механизм, вне scope vitest-гейта). all:false — осознанный задокументированный компромисс (с all:true бочки/типы/ESM-барели утопили бы пороги в бессмысленность); последствие (новый непокрытый файл проходит) реально, но это защитимый инженерный выбор для первого гейта, автор его назвал — ниже DROP-порога, не блокер.
  • Регрессий нет: db.ts +56 — чисто ДОБАВЛЕНИЕ двух сидеров (сигнатуры существующих не тронуты → 15 других int-spec'ов получают тот же API); chat-thread.test.tsx — только новый describe + cleanup в импорте, существующий блок send now (#198) не тронут, hoisted-state не течёт назад; @vitest/coverage-v8@4.1.6 точно матчит vitest@4.1.6, резолюция per-importer. Секретов нет (test-DB URL — задокументированный local-dev дефолт, env-overridable, вне диффа).

DROP: нет (веер не дал ни одной находки выше порога). Вне scope: ветка invitation.groupIds в acceptInvitation (сиды используют groupIds:null) не покрыта — но это отдельная фича-ветка вне атомарностной цели #324, не гейт (all:false), намеренно не выношу как находку.

## Ревью — #335 (test/infra: coverage-gate + acceptInvitation atomicity + turn-end unit, хвост #244/#324), round 1, head `baa41d66`, base develop `f5d19f97` **Вердикт: PASS** — чистый test/infra-PR, продакшн-код не тронут. Все три части сделаны добротно и не вакуозно, объективка зелёная (прогнал полностью сам, включая int-spec против реального Postgres). Готово к мержу. **Объективку запустил сам** (head `baa41d66`, main-клон + docker Postgres+pgvector): - **acceptInvitation int-spec: 3/3 passed, стабильно 4/4 прогона** (реальный `WorkspaceInvitationService` + реальные `UserRepo/GroupRepo/GroupUserRepo` против реального Postgres; застаблены только пост-коммитные периферии — mail/session/billing/audit/env — на tx-пути ничего не заглушено). Логи `duplicate key ... users_email_workspace_id_unique` в выводе — это ОЖИДАЕМЫЙ путь проигравшего accept'а (сервис ловит → `BadRequestException('Invitation already accepted')`). - **Coverage-gate зелёный на всех трёх пакетах:** git-sync exit 0 (711 passed); editor-ext 58.58/48.1/64.96/58.91 vs пороги 54/44/60/54; client 892 passed, 59.93/58/48.47/59.39 vs 55/53/44/55. Измеренные числа точно совпали с таблицей в теле PR; пороги ниже измеренного с запасом (регресс уронит гейт). **Что проверено по существу (веер 7 аспектов — security/stability/test-coverage/conventions/simplification/regressions/coherence, все LGTM):** - **int-spec не вакуозен и пиннит реальный инвариант.** Гонка настоящая, не «детерминизм по удаче»: оба pre-tx `SELECT` уходят до входа в любую tx (пул max:5), оба видят живое приглашение → проигравший ГАРАНТИРОВАННО входит в tx и ловит unique-conflict (никогда не «not found»), что и ассертит тест по точному message. «Оба успеха» невозможны из-за `users_email_workspace_id_unique`, дедлока нет (одностороннее ожидание). Ассерты падают, если сломать атомарность/констрейнт/трансляцию ошибки. - **Не флейки.** jest `maxWorkers:1` + per-file изоляция реестра модулей; `afterAll→destroyTestDb()` закрывает пул; изоляция по свежему `randomUUID()` workspace + email на каждый сид, все запросы фильтруют по workspaceId → в общей `docmost_test` кросс-тестовой утечки нет. Property-тесты (numRuns 100/300) дают побайтово одинаковое покрытие прогон-к-прогону (ветки насыщены) — «рандом + жёсткий branch-порог = флейк» эмпирически опровергнут (git-sync gate прогнан дважды — идентичные числа). - **Coverage-gate реальный, не театр.** Вшит в обычный `test` (`enabled:true`), CI гоняет `pnpm -r test` → порог исполняется без флага. Все три vitest-пакета покрыты (server=jest, mcp=node --test — другой механизм, вне scope vitest-гейта). `all:false` — осознанный задокументированный компромисс (с `all:true` бочки/типы/ESM-барели утопили бы пороги в бессмысленность); последствие (новый непокрытый файл проходит) реально, но это защитимый инженерный выбор для первого гейта, автор его назвал — ниже DROP-порога, не блокер. - **Регрессий нет:** `db.ts` +56 — чисто ДОБАВЛЕНИЕ двух сидеров (сигнатуры существующих не тронуты → 15 других int-spec'ов получают тот же API); `chat-thread.test.tsx` — только новый describe + `cleanup` в импорте, существующий блок `send now (#198)` не тронут, hoisted-state не течёт назад; `@vitest/coverage-v8@4.1.6` точно матчит `vitest@4.1.6`, резолюция per-importer. Секретов нет (test-DB URL — задокументированный local-dev дефолт, env-overridable, вне диффа). DROP: нет (веер не дал ни одной находки выше порога). Вне scope: ветка `invitation.groupIds` в acceptInvitation (сиды используют `groupIds:null`) не покрыта — но это отдельная фича-ветка вне атомарностной цели #324, не гейт (`all:false`), намеренно не выношу как находку. <!-- state:review reviewed_head=baa41d66adf8fa5a14108ccc3b274b61f2c2e5ee round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 13:34:38 +03:00
vvzvlad merged commit 77c64c4fd9 into develop 2026-07-04 17:45:49 +03:00
Sign in to join this conversation.