feat(s25): autonomous continuous quality monitoring (8/8)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

Hourly smoke watchdog + auto-fix loop + dashboard + multi-tenant guard
+ perf regression + cleanup job + README badge.

1. ~/quality-watchdog.sh (cron 5 * * * *) — 8 checks (~60s):
   /health/ready, signup→login→/api/me, GET products, Playwright UI
   smoke (3.1 product CRUD), /metrics format, /hubs/notifications
   negotiate with token, multi-tenant isolation, perf p95.
2. Auto-fix loop: 2× consecutive red → ~/.fm-watchdog/incident-*.txt
   + queue/0000-incident-* to bump it ahead of Server-Claude's
   sprint queue. fm-watchdog.sh sees prefix 0000- as next.
3. scripts/quality-dashboard.py — renders docs/quality-status.md
   (current emoji, 8-step table, perf baseline, 7-day history,
   24-run sparkline) + injects README badge 🟢/🟡/🔴.
4. Multi-tenant smoke: signup 2 orgs `quality-{epoch}-A/B`, create
   product in A, verify B sees 404/403 + total=0.
5. Perf regression: p95 over 10 reqs for /api/me, products,
   sales/retail/stats. Baseline = median of last 10 samples
   (robust to noise). >50% from baseline → alert. First 5 runs
   always green (warm-up).
6. HousekeepingJobs.PruneQualityTestOrgsAsync (cron 30 2 * * * UTC):
   finds orgs `quality-%` older than 24h, dynamically scans
   information_schema for tables with OrganizationId, iteratively
   DELETEs with FK-violation retry (up to 10 passes), then cleans
   AspNetUser*/OpenIddict* by email pattern `quality-%@test-fm.local`,
   finally users + organizations.
7. README badge: <!-- quality-badge --> marker updated each run.

Validated: stage deploy ✓, Hangfire job registered ✓, dry-run SQL on
24 stage candidates → 0 remaining ✓, 3 cron-triggered runs all 8/8
green (12:42/12:45/12:48 +05) ✓.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-08 12:50:35 +05:00
parent 4cc4922463
commit 019c57ae3b
8 changed files with 718 additions and 1 deletions

View file

@ -1,5 +1,7 @@
# food-market
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
[![CI](http://192.168.1.193:3000/nns/food-market/actions/workflows/ci.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
[![Docker API](http://192.168.1.193:3000/nns/food-market/actions/workflows/docker-api.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
[![Stage verify](http://192.168.1.193:3000/nns/food-market/actions/workflows/stage-verify.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
@ -124,4 +126,4 @@ food-market/
## Лицензия
Internal proprietary, не для публикации без разрешения владельца.
Internal proprietary, не для публикации без разрешения владельца.

60
docs/quality-status.md Normal file
View file

@ -0,0 +1,60 @@
# Quality status
_Обновлено: 2026-06-08T07:48:24+00:00 · auto-gen из `~/quality-watchdog.sh`_
## 🟢 Текущий статус
**Последний прогон:** `2026-06-08T12:48:01+05:00`
**Зелёных шагов:** 8/8
**Красных шагов:** 0
## Шаги smoke-suite
| Шаг | Статус | Последнее изменение | Consecutive fail |
|---|---|---|---|
| /health/ready | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| signup→login→/api/me | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| GET /api/catalog/products | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| Playwright UI (product CRUD) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| /metrics (Prometheus) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| /hubs/notifications/negotiate | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| Multi-tenant isolation | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
| Performance p95 vs baseline | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
## Performance baseline (p95, ms)
| Endpoint | p95 (ms) |
|---|---|
| `/api/catalog/products/page/1/pageSize/10` | 233 |
| `/api/me` | 259 |
| `/api/sales/retail/stats/days/7` | 228 |
_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._
## История за 7 дней
**Прогонов:** 14
**С красным:** 7
**Green-ratio:** 50%
### Прогоны с красным шагом
| Время | Красные шаги |
|---|---|
| `2026-06-08T12:16:05+05:00` | multi_tenant |
| `2026-06-08T12:16:52+05:00` | multi_tenant |
| `2026-06-08T12:18:01+05:00` | multi_tenant |
| `2026-06-08T12:18:35+05:00` | multi_tenant |
| `2026-06-08T12:22:49+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
| `2026-06-08T12:23:00+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
| `2026-06-08T12:23:18+05:00` | perf |
## Последние 24 прогона
`🔴🔴🔴🔴🟢🟢🔴🔴🔴🟢🟢🟢🟢🟢`
---
Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`).
Источник: `~/.fm-watchdog/quality-history.jsonl`.
Sprint 25 — autonomous continuous quality monitoring.

216
docs/sprint25-progress.md Normal file
View file

@ -0,0 +1,216 @@
# Sprint 25 — autonomous continuous quality monitoring
Цель: чтобы существующие фичи постоянно проверялись на работоспособность,
и при поломке — автоматическая попытка починить через инцидент-задачу в
очередь Server-Claude'а.
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7.
## Чек-лист
- [x] **1. Hourly smoke watchdog**`~/quality-watchdog.sh` запускается
cron'ом каждый час в минуту 5. 8 проверок (~60 сек): /health/ready,
signup→login→/api/me, GET /api/catalog/products, Playwright UI flow
(3.1 product create-list-get), /metrics, /hubs/notifications/negotiate,
multi-tenant изоляция, performance p95. Падение → Telegram + лог в
`~/.fm-watchdog/quality.log`.
- [x] **2. Auto-fix loop при двукратном падении** — при consecutive_fail≥2
watchdog создаёт `~/.fm-watchdog/incident-{ts}-{step}.txt` + копирует в
очередь Server-Claude'а как `queue/0000-incident-*` (префикс `0000-`
гарантирует сортировку в начало). После завершения spirinta Server-Claude
возвращается к текущему.
- [x] **3. Quality dashboard**`docs/quality-status.md` рендерится
hourly через `scripts/quality-dashboard.py`: текущий статус-эмодзи,
список 8 шагов с last-green/last-red, perf baseline (median p95 по
последним 10 измерениям), история падений за 7 дней, sparkline 24
последних прогонов.
- [x] **4. Multi-tenant smoke 24/7** — создаёт TWO org с уникальными
именами `quality-{epoch}-A/B`, заводит товар в A, проверяет что B
не видит его ни через GET-by-id (404/403), ни через search (total=0).
Leak в multi-tenant изоляции — самый дорогой баг, поэтому ежечасный
контроль.
- [x] **5. Performance regression hourly** — p95 за 10 повторов для
`/api/me`, `/api/catalog/products`, `/api/sales/retail/stats`. Baseline
= median последних 10 измерений (robust к шуму). Регрессия = текущий
p95 >50% от baseline → alert + (при consecutive≥2) incident.
Первые 5 запусков всегда green (собираем стартовую выборку).
- [x] **6. Hangfire job: cleanup quality test orgs**
`HousekeepingJobs.PruneQualityTestOrgsAsync` (recurring-id
`prune-quality-test-orgs`, default cron `30 2 * * *` UTC). Удаляет
org'и `quality-%` старше 24h: динамически по `information_schema`
находит все tenant-таблицы с `OrganizationId`, итеративно DELETE'ит
с обработкой FK-violation (до 10 проходов), затем чистит
`AspNetUserRoles/Tokens/Claims/Logins`, `OpenIddictTokens/Authorizations`
по email-шаблону `quality-%@test-fm.local`, и finally `users` +
`organizations`. Threshold переопределяется через
`Cleanup:QualityTestOrgHours`.
- [x] **7. Status badge в README**`<!-- quality-badge -->` маркер с
эмодзи 🟢/🟡/🔴 + ссылкой на `docs/quality-status.md`. Обновляется
через `scripts/quality-dashboard.py`. 🟢 = последний run all green;
🟡 = красные шаги, но <2 consecutive (transient); 🔴 = 2+
consecutive (incident-уровень).
- [x] **8. Cron-валидация** — временно поменял на `*/3 * * * *`,
поймал 3 cron-запуска подряд (12:42 / 12:45 / 12:48 +05), все
8/8 green. Вернул на hourly (`5 * * * *`). См. `~/.fm-watchdog/quality-cron.log`.
## Журнал
### 2026-06-08 старт
Sprint 24 закрыт (8/8). Поехали по quality watchdog.
### 2026-06-08 quality-watchdog.sh (#1)
Главный скрипт ~16 КБ:
- shell-helpers `state_get`/`state_set`/`mark_red`/`mark_green`/`create_incident`.
- Все шаги логируют в `~/.fm-watchdog/quality.log` + state в
`~/.fm-watchdog/quality-state.json` (per-step `consecutive_fail`,
`last_green`, `last_red`, `last_detail`).
- Telegram-нотификация на каждый red-шаг. Если token/chat не настроены
(default на dev-vm) — просто лог без падения.
- Лог ротируется при превышении 1 МБ.
Подобранные через debug refs:
- `/api/catalog/units-of-measure` (не `/api/refs/units`)
- `/api/catalog/product-groups` (обязательное поле ProductGroupId)
- POST product требует `barcodes: [{code, type, isPrimary}]` + price
`[{priceTypeId, amount, currencyId}]` где priceTypeId = isRequired-row
из `/api/catalog/price-types`.
### 2026-06-08 auto-fix loop (#2)
`create_incident` пишет 2 файла: один в `~/.fm-watchdog/incident-{ts}-{step}.txt`
(для архива/обзора инженером), другой в `~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt`.
Префикс `0000-` критичен — `fm-watchdog.sh` ротирует очередь через
`ls | sort | head -1`, так что инцидент-задача рулит раньше любой
плановой работы. Проверил через симуляцию (STAGE_URL=https://nonexistent.example.invalid):
1-я итерация — yellow, 2-я — RED + 7 incident-файлов в очереди (по одному
на каждый упавший шаг, кроме ui_flow — там node_modules + playwright всё
равно работают).
### 2026-06-08 dashboard + history (#3)
`scripts/quality-dashboard.py` (Python 3.11+, no deps) рендерит markdown
из `quality-history.jsonl` (1 JSONL-строка на прогон, append-only,
trimmed до 400 последних = ~2 недели hourly). Содержит:
- Текущий статус-эмодзи.
- Таблицу 8 шагов: `статус | last-change | consecutive_fail`.
- Performance baseline (median p95 по 10 последним измерениям).
- История за 7 дней + green-ratio %.
- Sparkline последних 24 прогонов (🟢/🔴).
`updateReadmeBadge` инжектит `<!-- quality-badge --> ... <!-- /quality-badge -->`
маркер в README между h1 и existing badges.
### 2026-06-08 multi-tenant scenario (#4)
2 signup'a с unique `quality-{epoch}-A/B`, токены через `/connect/token`,
POST product в A → GET в B должен вернуть 404/403 + search в B должен
дать total=0. Хитрость: B видит "Все товары" (root group)
**своей** организации (создан bootstrap'ом при signup), не A's; это
правильно. Leak бы означал, что product создан вне tenant'a A.
### 2026-06-08 performance baseline (#5)
Первая версия использовала `min(old, new)` как baseline — после первого
быстрого замера (cold connection) baseline=200ms, и любой warm-replay
295ms давал +47% и срабатывал alert. Переделал на median последних 10
измерений + первые 5 запусков всегда green (warm-up). Это сглаживает
шум и держит alert на действительно sustained регрессиях.
Файл `~/.fm-watchdog/quality-perf-baseline.json` теперь содержит:
```json
{
"median": { "_api_me": 245, ... },
"samples": { "_api_me": [216, 245, 297, ...], ... }
}
```
### 2026-06-08 PruneQualityTestOrgs job (#6)
Добавил метод в `HousekeepingJobs` + регистрация в
`HangfireJobsConfigurator` под id `prune-quality-test-orgs`,
cron `30 2 * * *` UTC.
Реализация:
1. `_db.Organizations.IgnoreQueryFilters().Where(Name LIKE 'quality-%' AND CreatedAt < threshold)` → candidate ids.
2. `DO $do$ ... END $do$` блок с `org_ids uuid[] := ARRAY[...]`.
3. Итеративный FOR pass IN 1..10 LOOP по
`information_schema.columns WHERE column_name = 'OrganizationId'`
— каждая итерация пытается DELETE из каждой tenant-таблицы; при
`foreign_key_violation` пропускает (следующий проход уберёт parent
запись). Реально хватает 2 проходов (нашёл employees ↔ employee_roles).
4. AspNetUserRoles/Tokens/Claims/Logins/OpenIddictTokens/Authorizations
через email-pattern `quality-%@test-fm.local`.
5. `users` + `organizations` отдельным шагом в конце.
Dry-run на stage (24 кандидата, ROLLBACK): pass 1 remaining=2, pass 2
remaining=0, итог 0 orgs в `quality-%` — работает.
### 2026-06-08 README badge (#7)
`updateReadmeBadge` инжектит маркированный блок:
```html
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
```
Каждый запуск watchdog'a обновляет эмодзи по результату последнего прогона.
### 2026-06-08 cron-валидация (#8)
Crontab: `5 * * * * /home/nns/quality-watchdog.sh >> ~/.fm-watchdog/quality-cron.log 2>&1`.
Для быстрого подтверждения временно поменял на `*/3 * * * *`, поймал
3 запуска подряд (12:42 / 12:45 / 12:48 +05), все 8/8 green. Восстановил
hourly.
## Архитектура
```
~/quality-watchdog.sh (cron 5 * * * *)
↓ запускает 8 проверок
├── HTTP curl (steps 1, 2, 3, 5, 6, 7, 8)
└── Playwright @smoke (step 4, использует tests/regression/flows/03)
↓ итог
├── ~/.fm-watchdog/quality.log (append-only)
├── ~/.fm-watchdog/quality-state.json (per-step consecutive_fail)
├── ~/.fm-watchdog/quality-history.jsonl (1 line/run, 400 lines max)
├── ~/.fm-watchdog/quality-perf-baseline.json (median + samples)
└── docs/quality-status.md + README badge (через quality-dashboard.py)
↓ если consecutive_fail ≥ 2
├── ~/.fm-watchdog/incident-{ts}-{step}.txt
└── ~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt (для fm-watchdog.sh ротации)
Hangfire job `prune-quality-test-orgs` (cron 30 2 * * * UTC):
- находит orgs `quality-%` старше 24h
- итеративно DELETE'ит из всех tenant-таблиц
- чистит Identity+OpenIddict по email-pattern
- DELETE users + organizations
```
## Что найдено и зафиксировано
- **Stage perf шумит на старте**: первые 1-2 запроса к /api/sales/retail/stats
доходят до 400ms (cold DB cache), затем 200-250ms steady. Поэтому
baseline стал median-based, не min-based.
- **POST product требует non-trivial payload**: barcodes (массив объектов
с code/type/isPrimary), prices (массив с priceTypeId/amount/currencyId),
productGroupId — все обязательные. Зафиксил в watchdog: вытягивает
refs из дефолтных значений orga через `/api/catalog/{units,product-groups,price-types,currencies}`.
- **FK dependency между employees и employee_roles**: первый DELETE на
`employee_roles` валится на employees.RoleId FK. Решил итеративным
ретраем (10 проходов) — гибкая стратегия, не привязанная к ручному
порядку, новые tenant-таблицы автоматом подцепятся.
## Метрики
| | До Sprint 25 | После | Δ |
|---|---|---|---|
| **Watchdog scripts** | `fm-watchdog.sh` + `nightly-verify.sh` + `nightly-perf-check.sh` | + `quality-watchdog.sh` + `quality-dashboard.py` | +2 |
| **Cron entries** | 2 | 3 | +1 |
| **Hangfire recurring jobs** | 11 | 12 | +1 (prune-quality-test-orgs) |
| **Hourly smoke шаги** | (нет hourly smoke) | 8 шагов | new |
| **Docs (.md в `docs/`)** | 60 | 62 | +2 (quality-status.md, sprint25-progress.md) |
| **README badges** | 5 | 6 | +1 (quality 🟢/🟡/🔴) |
| **Auto-fix integration** | (нет) | incident → fm-watchdog queue | new |
## Итог
8/8 ✓. 3 cron-trigger'd runs все green. `~/.fm-watchdog/DONE` создан
после ratiosit's финального прогона.

239
scripts/quality-dashboard.py Executable file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Sprint 25: рендерер docs/quality-status.md из ~/.fm-watchdog/quality-history.jsonl.
Запускается из ~/quality-watchdog.sh после каждого прогона.
Также обновляет статус-бейдж в README.md (🟢/🟡/🔴).
Usage: python3 scripts/quality-dashboard.py
"""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
HISTORY = Path.home() / ".fm-watchdog" / "quality-history.jsonl"
STATE = Path.home() / ".fm-watchdog" / "quality-state.json"
BASELINE = Path.home() / ".fm-watchdog" / "quality-perf-baseline.json"
DASHBOARD = REPO / "docs" / "quality-status.md"
README = REPO / "README.md"
STEP_NAMES = {
"health": "/health/ready",
"auth_me": "signup→login→/api/me",
"products": "GET /api/catalog/products",
"ui_flow": "Playwright UI (product CRUD)",
"metrics": "/metrics (Prometheus)",
"signalr": "/hubs/notifications/negotiate",
"multi_tenant": "Multi-tenant isolation",
"perf": "Performance p95 vs baseline",
}
def load_history() -> list[dict]:
if not HISTORY.exists():
return []
out = []
for line in HISTORY.read_text().splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
continue
return out
def load_state() -> dict:
if not STATE.exists():
return {}
try:
return json.loads(STATE.read_text())
except Exception:
return {}
def load_baseline() -> dict:
"""Возвращает dict {endpoint: median_ms} из нового формата с samples/median."""
if not BASELINE.exists():
return {}
try:
raw = json.loads(BASELINE.read_text())
except Exception:
return {}
if "median" in raw:
return raw["median"]
# Совместимость со старым форматом (просто {key: value}).
return {k: v for k, v in raw.items() if isinstance(v, (int, float))}
def parse_ts(s: str) -> datetime:
# python <3.11 не парсит +03:00 в datetime.fromisoformat для всех вариантов; чистим.
s = s.replace("Z", "+00:00")
try:
return datetime.fromisoformat(s)
except Exception:
return datetime.now(timezone.utc)
def determine_color(history: list[dict]) -> str:
"""🟢 если последний run all green; 🟡 если есть red но <consec 2;
🔴 если есть consecutive-2+ red (incident-уровень)."""
if not history:
return "🟡"
last = history[-1]
if not last.get("red"):
return "🟢"
# Считаем consecutive_fail из state.
state = load_state()
for step in last.get("red", []):
if int(state.get(step, {}).get("consecutive_fail", 0)) >= 2:
return "🔴"
return "🟡"
def render_dashboard(history: list[dict], state: dict, baseline: dict) -> str:
last = history[-1] if history else None
color = determine_color(history)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
out = ["# Quality status", "", f"_Обновлено: {now} · auto-gen из `~/quality-watchdog.sh`_", ""]
out.append(f"## {color} Текущий статус")
out.append("")
if last:
out.append(f"**Последний прогон:** `{last['ts']}` ")
out.append(f"**Зелёных шагов:** {len(last.get('green', []))}/{len(STEP_NAMES)} ")
out.append(f"**Красных шагов:** {len(last.get('red', []))} ")
else:
out.append("стория пока пуста — quality-watchdog ещё не запускался cron'ом._")
out.append("")
# Step-by-step table.
out.append("## Шаги smoke-suite")
out.append("")
out.append("| Шаг | Статус | Последнее изменение | Consecutive fail |")
out.append("|---|---|---|---|")
last_green = set(last.get("green", [])) if last else set()
last_red = set(last.get("red", [])) if last else set()
for key, label in STEP_NAMES.items():
if key in last_green:
icon = "🟢"
elif key in last_red:
icon = "🔴"
else:
icon = ""
s = state.get(key, {})
recent = s.get("last_green") if icon == "🟢" else s.get("last_red", "")
cf = s.get("consecutive_fail", 0)
out.append(f"| {label} | {icon} | `{recent or ''}` | {cf} |")
out.append("")
# Red detail для текущего прогона.
if last and last.get("details"):
out.append("## Детали падений (последний прогон)")
out.append("")
for d in last["details"]:
out.append(f"- `{d}`")
out.append("")
# Performance baseline.
if baseline:
out.append("## Performance baseline (p95, ms)")
out.append("")
out.append("| Endpoint | p95 (ms) |")
out.append("|---|---|")
for k, v in sorted(baseline.items()):
# _api_me → /api/me. Грубо, восстанавливаем читаемое имя.
pretty = k.replace("_", "/")
out.append(f"| `{pretty}` | {v} |")
out.append("")
out.append("_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._")
out.append("")
# История за неделю.
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
week_runs = []
for h in history:
ts = parse_ts(h["ts"])
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts >= week_ago:
week_runs.append(h)
out.append("## История за 7 дней")
out.append("")
if not week_runs:
out.append("_Нет прогонов за последнюю неделю._")
else:
red_runs = [h for h in week_runs if h.get("red")]
out.append(f"**Прогонов:** {len(week_runs)} ")
out.append(f"**С красным:** {len(red_runs)} ")
out.append(f"**Green-ratio:** {((len(week_runs)-len(red_runs))*100)//max(1,len(week_runs))}% ")
out.append("")
if red_runs:
out.append("### Прогоны с красным шагом")
out.append("")
out.append("| Время | Красные шаги |")
out.append("|---|---|")
for h in red_runs[-20:]:
out.append(f"| `{h['ts']}` | {', '.join(h.get('red', []))} |")
out.append("")
# Последние 24 прогона как sparkline.
out.append("## Последние 24 прогона")
out.append("")
spark = "".join("🟢" if not h.get("red") else "🔴" for h in history[-24:])
out.append(f"`{spark or '— нет данных —'}`")
out.append("")
out.append("---")
out.append("")
out.append("Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`). ")
out.append("Источник: `~/.fm-watchdog/quality-history.jsonl`. ")
out.append("Sprint 25 — autonomous continuous quality monitoring.")
out.append("")
return "\n".join(out)
BADGE_RE = re.compile(r"<!-- quality-badge -->.*?<!-- /quality-badge -->", re.S)
def update_readme_badge(color: str) -> None:
if not README.exists():
return
txt = README.read_text()
badge = f"<!-- quality-badge --> {color} **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->"
if BADGE_RE.search(txt):
new_txt = BADGE_RE.sub(badge, txt)
else:
# Вставляем после первого заголовка.
lines = txt.splitlines()
for i, ln in enumerate(lines):
if ln.startswith("# "):
lines.insert(i + 1, "")
lines.insert(i + 2, badge)
break
new_txt = "\n".join(lines)
if new_txt != txt:
README.write_text(new_txt)
def main() -> int:
history = load_history()
state = load_state()
baseline = load_baseline()
DASHBOARD.parent.mkdir(parents=True, exist_ok=True)
DASHBOARD.write_text(render_dashboard(history, state, baseline))
color = determine_color(history)
update_readme_badge(color)
print(f"dashboard updated: {DASHBOARD} (status={color})")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -63,6 +63,15 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronTokens,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 25: чистка quality-{epoch}-* test-org, созданных
// ~/quality-watchdog.sh hourly. Default 02:30 UTC, до утреннего prune-блока.
var cronQualityOrgs = _cfg["Hangfire:Cron:PruneQualityTestOrgs"] ?? "30 2 * * *";
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-quality-test-orgs",
methodCall: j => j.PruneQualityTestOrgsAsync(CancellationToken.None),
cronExpression: cronQualityOrgs,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час

View file

@ -134,4 +134,124 @@ public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = def
deleted, threshold);
return deleted;
}
/// <summary>Sprint 25: hard-delete тестовых организаций, которые
/// создаёт <c>~/quality-watchdog.sh</c> по конвенции
/// <c>quality-{epoch}-A/B</c>. Stage-БД иначе разрастается на
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
/// никогда не задеть реальные орги (даже архивные).
///
/// Реализация: динамически находим все таблицы с колонкой
/// <c>OrganizationId</c> через information_schema, и DELETE'им из
/// них строки для match'ащих org-id. Заказ в одной транзакции через
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
/// если делать DELETE в правильном порядке (сначала дочерние,
/// потом родительские); используем общий приём «удаляем по списку
/// org-id из всех таблиц, последний — Organizations».
///
/// Порог давности — <c>Cleanup:QualityTestOrgHours</c> (default 24)
/// чтобы свежие диагностические прогоны не зачищались до того, как
/// инженер посмотрит лог. Возвращает количество удалённых
/// организаций.</summary>
public async Task<int> PruneQualityTestOrgsAsync(CancellationToken ct = default)
{
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
var threshold = DateTime.UtcNow.AddHours(-minHours);
var ids = await _db.Organizations
.IgnoreQueryFilters()
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
.Select(o => o.Id)
.ToListAsync(ct);
if (ids.Count == 0)
{
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
return 0;
}
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
//
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
// (depth FK графа гораздо меньше).
var sql = $@"
DO $do$
DECLARE
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
tbl text;
pass int := 0;
remaining int;
BEGIN
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
-- Пропускаем organizations и users отдельные шаги ниже.
FOR pass IN 1..10 LOOP
remaining := 0;
FOR tbl IN
SELECT c.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE c.table_schema = 'public'
AND c.column_name = 'OrganizationId'
AND t.table_type = 'BASE TABLE'
AND c.table_name NOT IN ('organizations', 'users')
LOOP
BEGIN
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
EXCEPTION WHEN foreign_key_violation THEN
remaining := remaining + 1;
END;
END LOOP;
EXIT WHEN remaining = 0;
END LOOP;
IF remaining > 0 THEN
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
END IF;
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
-- 3. AppUser (public.users) привязан через OrganizationId, чистим по нему.
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
-- 4. Сама organizations.
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
END $do$;
";
await _db.Database.ExecuteSqlRawAsync(sql, ct);
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
ids.Count, minHours);
return ids.Count;
}
}

27
tests/e2e/s23-setup.ts Normal file
View file

@ -0,0 +1,27 @@
import { login, makeClient } from './lib/api.js'
// Создаём 2 org'и с tokens, печатаем в формате shell env-vars.
async function main() {
const ts = Date.now()
const api = makeClient()
const orgs = []
for (const tag of ['A', 'B']) {
const email = `s23-${tag.toLowerCase()}-${ts}@food-market.local`
await api.post('/api/auth/signup', {
organizationName: `S23-${tag}-${ts}`, email, password: 'Stage12345!',
phone: '+77001234567',
})
const sess = await login(email, 'Stage12345!')
const t = makeClient(sess.accessToken)
const me = await t.get('/api/me')
orgs.push({ tag, token: sess.accessToken, orgId: me.data.orgId, email, userId: me.data.sub })
}
console.log('export S23_TS=' + ts)
for (const o of orgs) {
console.log(`export S23_${o.tag}_TOKEN='${o.token}'`)
console.log(`export S23_${o.tag}_ORG='${o.orgId}'`)
console.log(`export S23_${o.tag}_EMAIL='${o.email}'`)
console.log(`export S23_${o.tag}_USER='${o.userId}'`)
}
}
main().catch(e => { console.error('FAIL:', e.response?.status, e.response?.data ?? e.message); process.exit(1) })

View file

@ -0,0 +1,44 @@
/**
* Sprint 7 item 6 визуальная проверка breadcrumbs на edit-странице.
* Логинимся, seed-demo, открываем первый товар, скриншот шапки.
*/
import { chromium } from 'playwright'
import { makeClient, login } from '../lib/api.js'
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
const TS = Date.now()
const EMAIL = `crumb-shot-${TS}@food-market.local`
const PASS = 'CrumbShot12345!'
async function ensureSession() {
const api = makeClient()
const r = await api.post('/api/auth/signup', {
email: EMAIL, password: PASS,
organizationName: `CrumbShot ${TS}`, phone: '+77011190001', plan: 'start',
})
if (r.status !== 200) throw new Error(`signup ${r.status}: ${JSON.stringify(r.data)}`)
const sess = await login(EMAIL, PASS)
await makeClient(sess.accessToken).post('/api/admin/seed-demo', {})
return sess
}
async function main() {
const sess = await ensureSession()
const browser = await chromium.launch({ headless: true })
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
await page.goto(`${BASE}/`)
await page.evaluate(({ token }) => localStorage.setItem('fm.access_token', token), { token: sess.accessToken })
await page.goto(`${BASE}/catalog/products`, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle')
await page.locator('tbody tr').first().waitFor({ timeout: 12000 })
await page.locator('tbody tr').first().click()
await page.waitForLoadState('networkidle')
await page.waitForSelector('nav[aria-label="Хлебные крошки"]', { timeout: 8000 })
await page.screenshot({ path: `reports/breadcrumbs-product-${TS}.png`, fullPage: false })
console.log(`[shot] saved → reports/breadcrumbs-product-${TS}.png`)
await browser.close()
}
main().catch(err => { console.error(err); process.exit(1) })