Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,55 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage2-settings.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos
- Task 1: complete (review clean; 38 PASS; Decrypt без `enc:` → "" — приемлемо, legacy-данных нет). Отчёт: task-1-report.md.
- [x] Task 1: Шифрование секретов (AES-GCM)
- Task 2: complete (review clean; каталог ключей полный 1:1 с прототипом; 51 PASS). Отчёт: task-2-report.md.
- [x] Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища
- Task 3: complete (review clean; снимок+PATCH 1:1; 87 PASS). Отчёт: task-3-report.md.
- Task 3: complete (review clean; 87 PASS; delay-клампы {5,600} — приёмка T5 должна ожидать это, не {5,700}). Отчёт: task-3-report.md.
- [x] Task 3: SettingsService — public-снимок и PATCH 1:1
- Task 4: complete (review clean; build 0/0, 88 PASS; dev-check psql: дефолты на пустой схеме + SetAsync создал строку value_json; схема devcheck_t4 удалена). Отчёт: task-4-report.md.
- Task 4: complete (build 0/0; 88 PASS; dev-check на deal-postgres 24/24). Отчёт: task-4-report.md.
- [x] Task 4: KV-адаптер SettingsStore (EF) и DI
- Task 5: complete (build 0/0, 88 PASS; curl-приёмка :5080 — PASS=42 FAIL=0; psql: enc: в aiConfigs/tgKeys; delay-клампы {5,600}; dev-БД очищена после прогона). Отчёт: task-5-report.md.
- Task 5: complete (review clean; curl 42/42; delay {5,600}; enc: в psql). Отчёт: task-5-report.md. Note: при 3-м Endpoints-файле — общий HTTP-хелпер (401/400); DecoderFallbackException → 400.
- [x] Task 5: Эндпоинты GET/PATCH /api/settings + curl-приёмка
- Task 6: complete (build 0/0, 98 PASS; +10 AiConnectionCheckerTests; curl :5080 — PASS=23 FAIL=0: без ключа → «Не задан API-ключ», недоступный порт deepseek → «Ошибка соединения», ollama → «Локальный сервер…», SSRF-гейт ftp-схемы; общий EndpointResults 401/400; dev-БД очищена). Отчёт: task-6-report.md.
- Task 6: complete (review clean; 98 PASS; curl 23/23). Отчёт: task-6-report.md. Note: прод-SSRF — host-allowlist + запрет авто-редиректов.
- [x] Task 6: ИИ-провайдеры и POST /api/ai/check
- Task 7: complete (build 0/0, 113 PASS; +15 PromptDefaultsTests; сверка промптов 3/3 идентичны data.js построчно; curl :5080 — PASS=19 FAIL=0: PATCH/GET aiPrompt с плейсхолдерами 1:1, myPrompts 3 записи camelCase на месте, logout→401; dev-БД очищена). Отчёт: task-7-report.md.
- Task 7: complete (review clean; 113 PASS; curl 19/19). Отчёт: task-7-report.md. Note: Git-Bash искажает кириллицу в args curl.exe (cp1251) — JSON-тела curl-приёмки читаются из UTF-8-файлов (--data-binary @file).
- Task 7: complete (113 PASS; промпты 3/3 идентичны data.js; /api/prompts* не нужны). Отчёт: task-7-report.md.
- [x] Task 7: Промпты и «Мои промпты»
- Task 8: complete (build 0/0; 148 PASS; +35 RatesServiceTests/CbrRateSourceTests; curl :5080 — PASS=22 FAIL=0: 401, дефолт-мок без кэша (source mock/updatedAt null), ratesCache не публикуется в /settings, PATCH mock→refresh ok:true→GET тот же кэш, psql {rates,source,updatedAtMs}, реальный ЦБ ok:true (USD 86.5857), logout→401; dev-БД очищена). Отчёт: task-8-report.md.
- Task 8: complete (review clean; 148 PASS; curl 22/22). Отчёт: task-8-report.md. Note: rateSource дефолт "cbr", дефолт ответа без кэша — мок; PATCH-хук (Ruling 6) в SettingsEndpoints (HTTP-слой), фон — RatesRefreshScheduler (Api, свой scope + in-flight guard); ShouldFetch симметричен (смена источника обе стороны); cbr-URL фиксирован (SSRF); AddHttpClient typed client transient (как Task 6).
- Task 8: complete (review clean; 148 PASS; curl 22/22; cbr живой). Отчёт: task-8-report.md.
- [x] Task 8: Курсы валют — сервис, кэш, /api/rates*
- Task 9: complete (build 0/0; 161 PASS; +13 LocalMlClientTests; curl :5080 — PASS=31 FAIL=0: 401 без сессии, status форма §4.10 1:1 (reachable:true/ready:false/outbox:0), mlEnabled false→enabled:false, predict «x»→400 «Введите текст», predict с текстом → take:false/.../type:null, reset {ok:true} без error, candidates {items:[]}, apply 404, psql: нет ml_outbox/learning_log, logout→401; dev-БД очищена). Отчёт: task-9-report.md.
- Task 9: complete (review clean; 161 PASS; curl 31/31). Отчёт: task-9-report.md.
- [x] Task 9: ML-панель — IMlClient, заглушка, /api/ml
- Task 10: complete (build 0/0; 175 PASS; +14 IncomingRulesTests; curl :5080 — PASS=25 FAIL=0: 401 без куки, дефолты «Заработок на крипте…» → stage1.pass:true/stage2.skipped:true/passed:true, пустой текст → «короче 24 символов», PATCH stopPhrases=[взаимный пиар]+minLen=10 → «стоп-фраза «взаимный пиар»»/passed:false, «Ищу работу python…» → «резюме соискателя («ищу работу»)/passed:false, валидный → passed:true, logout→401; kind/kw — внутри IncomingRules (wire 1:1 §4.10); dev-БД очищена). Отчёт: task-10-report.md.
- Task 10: complete (review clean; 175 PASS; curl 25/25). Отчёт: task-10-report.md.
- [x] Task 10: Тестер фильтров — IncomingRules и /api/admin/check-message
- Task 11: complete (review pending). Отчёт: task-11-report.md.
- Task 11: complete (review clean; сквозная приёмка 60/60). Отчёт: task-11-report.md.
- **Этап 2 завершён**: финальное whole-scope ревью ✅ (build 0/0, 175 PASS, миграций новых нет, docs/roadmap актуальны). Note в этап 3: проверить, что PATCH aiConfigs не шлёт keyMasked обратно как apiKey; SSRF-контур ai/check — прод-ужесточение позже.
- [x] Task 11: Финал этапа — интеграция и сквозная приёмка
## Pre-flight scan
| Пара | Производит / потребляет | Результат |
|---|---|---|
| T1 → T3/T6 | ISecretCipher потребляется SettingsService/ai check | Чисто |
| T2 → T3/T4 | каталог ключей + дефолты + ISettingsStore → сервис/адаптер | Чисто |
| T3 → T5 | SettingsService → эндпоинты | Чисто |
| T4 → T5 | DI адаптера | Чисто |
| T8 → T2/T3 | ratesCache — внутренний KV-ключ через ISettingsStore | Чисто (внутренние ключи не публичны) |
| T9 | Contracts/Integrations IMlClient — новый проект Contracts наполняется | Проверить ссылки (Contracts уже referenced) |
| T10 | IncomingRules — переиспользуется этапом 4 | Чисто |
| T6/T7 | HTTP наружу (ai/check), cbr (rates) | SSRF-риск: только baseUrl из настроек тенанта (allowlist провайдеров) — следить в ревью |
| T2 | новые EF-таблицы не создаются | Таблица settings существует |
## Task status
@@ -0,0 +1,99 @@
# Task 1 — «Шифрование секретов (AES-GCM)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 38/38 PASS (было 25, добавлено 13).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 1 L126148, Ruling 2 L5965).
## Файлы
### Созданы
- `src/core/Deal.Modules.Settings/Application/ISecretCipher.cs` — порт в модуле Settings
(namespace `Deal.Modules.Settings.Application`): `string Encrypt(string plainText)`,
`string Decrypt(string cipherText)`. XML-doc фиксирует формат `enc:` + Base64(nonce ‖ ct ‖ tag)
и контракт: Decrypt повреждённого/чужого значения → пустая строка **без исключений**.
Модуль НЕ получил новых ссылок — интерфейс чистый (BCL). Grep по папке модуля:
`Infrastructure|EntityFramework|Npgsql`**0 совпадений** (модуль остался чистым).
- `src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs``public sealed`, реализует
`ISecretCipher`. AES-256-GCM (`System.Security.Cryptography.AesGcm`), ключ 32 байта передаётся
в конструктор; именованные константы: `NonceSizeBytes = 12`, `TagSizeBytes = 16`,
`KeySizeBytes = 32`, `EncryptedPrefix = "enc:"`.
- `src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs``public sealed`, разрешает
ключ по Ruling 2, кэширует после первого разрешения (`GetKey()`), ctor принимает ContentRoot.
- `src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs` — 13 тест-кейсов (см. ниже).
- `.superpowers/sdd/deal-stage2-settings/task-1-report.md` — этот отчёт.
### Изменены
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
`Deal.Modules.Settings` (по образцу ссылки на `Deal.Modules.Tenants` — реализация порта модуля).
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` — добавлен метод
`AddDealSecurity(this IServiceCollection services, string contentRootPath)`.
- `src/core/Deal.Api/Program.cs` — вызов `builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);`
рядом с `AddDealPersistence()` (как Program.cs регистрирует Infrastructure).
## Решения
### DI: отдельный `AddDealSecurity`, а не `AddDealPersistence`
`AddDealPersistence` — регистрация scoped-EF-адаптеров (порт/адаптер персистентности), а шифрование —
не персистентность. Добавлен отдельный метод в тот же статический класс `ServiceCollectionExtensions`
(Infrastructure, точка регистрации как у `AddDealPersistence`), вызывается в `Program.cs` той же строкой-соседом.
`ISecretCipher` регистрируется **singleton** (реализация без разделяемого состояния — потокобезопасна).
Сигнатура `(IServiceCollection, string contentRootPath)` вместо предложенной в ТЗ
`(IServiceCollection, IConfiguration)`: провайдеру нужен ContentRoot (каталог файла-ключа), которого
в `IConfiguration` нет; env-переменные (`DEAL_ENCRYPTION_KEY`, `DEAL_ENCRYPTION_KEY_FILE`) провайдер
читает из окружения напрямую — они не проходят через секции конфигурации.
### EncryptionKeyProvider — вне DI (обоснование)
Ключ разрешается **один раз при вызове `AddDealSecurity`** (на старте приложения) и сразу передаётся
в конструктор `AesGcmSecretCipher`; провайдер после этого рантайм-сервисам не нужен. Преимущества:
(а) невалидный env-ключ останавливает запуск (план Task 1: «невалидный env-ключ → исключение при
старте», семантика `crypto._get_fernet`); (б) контейнер не хранит лишнего состояния и зависимостей.
Порядок разрешения — как в `crypto.py L2242`: env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64,
декодирование терпимо к urlsafe-алфавиту и отсутствию padding) → иначе файл
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`); при первом
старте файл генерируется (`RandomNumberGenerator`, 32 случайных байта, запись urlsafe-Base64).
Ошибки файла/окружения оборачиваются в `InvalidOperationException` с понятным сообщением.
### `.NET 10` AesGcm API (зафиксировано)
Использован конструктор `new AesGcm(key, tagSizeInBytes)` — явный размер тега 16 (без неявного
дефолта, который в .NET 8+ помечен SYSLIB0053). Nonce — 12 байт, генерируется
`RandomNumberGenerator.GetBytes`. One-shot-вызовы `Encrypt(nonce, plain, cipher, tag)` /
`Decrypt(nonce, cipher, tag, plain)` со спан-слайсами payload'а; повреждённый tag →
`AuthenticationTagMismatchException` (подкласс `CryptographicException`) → возврат `""`.
Экземпляр `AesGcm` создаётся на операцию (операции с секретами редкие; отсутствие разделяемого
состояния снимает вопросы потокобезопасности singleton).
### Тесты (`SecretCipherTests`, фиксированный 32-байтовый ключ 1..32)
1. Roundtrip Encrypt→Decrypt для кириллицы, спецсимволов, URL и пустой строки (4 кейса) — исходная строка.
2. `Encrypt("")``""`.
3. Непустой Encrypt даёт токен с префиксом `enc:`.
4. Два Encrypt одной строки — разные токены (случайный nonce).
5. Decrypt строки без префикса `enc:``""`.
6. Decrypt мусора: `enc:`, `enc:не-base64!`, payload короче nonce+tag (`enc:AAAA`) → `""` без исключений.
7. Decrypt токена с повреждённым последним байтом тега → `""`.
8. Decrypt токена, зашифрованного чужим ключом → `""`.
## Проверки (выводы)
```
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (все 10 проектов)
dotnet test tests/Deal.Tests.Unit --no-build
Сводка теста: всего: 38; сбой: 0; успешно: 38; пропущено: 0 (было 25 → +13)
```
## Отклонения от кода плана
1. **Decrypt строки без префикса `enc:` возвращает `""`, а не «как есть».** План Task 1
(L130) и `crypto.decrypt_text` (L55–56) предписывают passthrough незашифрованных значений
«ранних версий». ТЗ задачи (список тестов, п. 5) явно требует обратное: «Decrypt строки без
префикса `enc:` → пустая строка». Реализовано по ТЗ: значение без префикса трактуется как
«чужое» (Ruling 2) — в новой .NET-БД legacy-значений нет, все секреты пишутся через Encrypt.
Если passthrough понадобится позже (миграция старых данных) — это однострочное изменение.
2. **`MaybeEncrypt` и `EncryptionOptions.cs` из плана не создавались** — ТЗ задачи задаёт
интерфейс только из `Encrypt`/`Decrypt`; пустые значения обрабатывает сам `Encrypt` (→ `""`,
семантика `crypto.encrypt_text`). IOptions-секция для пути файла-ключа не нужна: путь задаётся
env `DEAL_ENCRYPTION_KEY_FILE` + ContentRoot из конструктора.
3. **Warning-лог при генерации файла-ключа** (Ruling 2, `crypto.py L40`) не выводится: ключ
разрешается синхронно в `AddDealSecurity` до построения контейнера, где `ILogger` недоступен,
а провайдер сознательно не регистрируется в DI. Упрощение осознанное; при появлении
потребителя-сервиса warning можно добавить (провайдер остаётся вне DI).
4. **Дополнительный тест** `Decrypt_TokenEncryptedWithAnotherKey_ReturnsEmptyString` добавлен сверх
списка ТЗ (покрывает ветку «зашифровано чужим ключом» из Ruling 2 — тот же путь, что
повреждённый tag).
@@ -0,0 +1,198 @@
#!/usr/bin/env sh
# Task 10 curl-приёмка /api/admin/check-message на :5080 (план Task 10 L388-391; Ruling 4/8;
# dashboard_routes.py L267-284, api-map §4.10 L364). Сценарий: 401 без куки → login →
# дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → stage1.pass:true,
# stage2.skipped:true, passed:true → пустой текст → stage1.pass:false «короче 24 символов» →
# PATCH stopPhrases=[«взаимный пиар»], minLen=10 → текст со стоп-фразой → stage1.pass:false
# «стоп-фраза «взаимный пиар»» → текст «Ищу работу python…» → stage1.pass:false
# «резюме соискателя» (маркер «ищу работу»; stopPhrases уже не содержит «ищу работу») →
# валидный текст → passed:true → logout → 401. Вывод всех шагов в stdout.
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
RESUME_BODY="$SCRIPT_DIR/task-10-resume.json"
PATCH_BODY="$SCRIPT_DIR/task-10-patch.json"
JAR="/tmp/task10-jar.txt"
OUT="/tmp/task10-out.txt"
LOG="/tmp/task10-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. POST /api/admin/check-message без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. Дефолты: «Заработок на крипте…» (длина ≥24, без стоп-фраз) → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1.pass:true, reason:null" '"stage1":{"pass":true,"reason":null}'
check "stage2 skipped:true pass:true (ИИ-фильтр этапа 2 = skipped, Ruling 4/8)" '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true'
echo
echo "== 4. Пустой текст (дефолт minLen=24) → stage1.pass:false «короче 24 символов» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" -d '{"text":""}' > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1 fail по длине" '"stage1":{"pass":false,"reason":"короче 24 символов"}'
check "stage2 pass:false skipped:true" '"stage2":{"pass":false,"reason":null,"skipped":true}'
check "passed:false" '"passed":false'
echo
echo "== 5. PATCH обработки: stopPhrases=[«взаимный пиар»], minLen=10 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]' '"stopPhrases":["взаимный пиар"]' '"minLen":10'
echo
echo "== 6. Текст со стоп-фразой «взаимный пиар» → stage1.pass:false, причина = фраза =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1 fail по стоп-фразе" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
check "passed:false" '"passed":false'
echo
echo "== 7. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → резюме =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$RESUME_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1 fail по резюме (маркер «ищу работу»)" '"pass":false,"reason":"резюме соискателя («ищу работу»)"'
check "passed:false" '"passed":false'
echo
echo "== 8. Валидный текст (после PATCH) → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1.pass:true" '"stage1":{"pass":true,"reason":null}'
check "stage2 skipped pass:true" '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true'
echo
echo "== 9. POST /api/auth/logout, затем check-message — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT"
echo
check "после logout check-message 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 10. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1 @@
{"text": "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу"}
@@ -0,0 +1 @@
{"stopPhrases":["взаимный пиар"],"minLen":10}
@@ -0,0 +1,55 @@
# Task 10 — «Тестер фильтров — этап-1 правила и POST /api/admin/check-message» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 175/175 PASS (было 161, добавлено 14: `IncomingRulesTests`); curl-приёмка :5080 — PASS=25 FAIL=0 (скрипт `task-10-curl-acceptance.sh`, лог `task-10-curl-acceptance.log`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 10 L367391, Ruling 4 L7175, Ruling 8 L97105; референс `pipeline.py` stage1_plain L94124 + `_resume_reason` L644658, `dashboard_routes.py` L267284, api-map §3.2 L109/§4.10 L364; фронт `SettingsView.vue` L142155/тестер-блок L12451272, `store.js` checkIncomingMessage L17221733).
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `Deal.Modules.Settings/Application/IncomingRules.cs` | create | Этап-1 правила (чистая реализация `stage1_plain` поверх `ISettingsStore`): `CheckAsync(text, ct)` читает переопределения одним `GetAllAsync` и считает вердикт чистой функцией над снимком «дефолты + сохранённые» (Ruling 1). Порядок и причины 1:1: длина (`minLen`) → стоп-фразы (`stopPhrases`, подстрочное вхождение casefold) → резюме (`blockResumes`+`resumeMarkers`, guard слова «резюме») → тип (`wantedType`+`hireMarkers`). Константы kind: length/stop/resume/type (на проходе ""), Stage=1. Повреждённые строки KV — мягкий дефолт (как SettingsService/RatesService); int("24")-строки читаются (семантика `int(x or default)` python), JSON-строка в списке маркеров — `[s]` (`isinstance` python L620623). |
| `Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs` | create | Результат `{pass, reason, stage, kind, kw}` 1:1 pipeline.py L9799; kind/kw — для тестера-мониторинга и этапа 4 (Ruling 8), наружу в /admin/check-message НЕ идут. |
| `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddSettingsModule()`: +`AddScoped<IncomingRules>()` (scoped — ISettingsStore на TenantDbContext). |
| `Deal.Api/Endpoints/FilterTesterEndpoints.cs` | create | `MapFilterTesterEndpoints`: POST `/api/admin/check-message` (тег settings). 401-гейт {detail}, резолв IncomingRules через RequestServices после гейта. Ответ 1:1 `dashboard_routes.py` L273284/§4.10: `{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}`; этап-1 не прошёл → `stage2={pass:false,reason:null,skipped:true}, passed:false`, иначе `stage2={pass:true,reason:null,skipped:true}, passed:true` (ИИ-фильтр этапа 2 на этапе 2 всегда skipped, Ruling 4/8; реальный ИИ — этап 6). |
| `Deal.Api/Endpoints/CheckMessageRequest.cs` | create | Тело POST `{text}` (1:1 `CheckMessageBody`, dashboard_routes.py L7273). |
| `Deal.Api/Program.cs` | modify | `app.MapFilterTesterEndpoints()`. |
| `tests/…/IncomingRulesTests.cs` | test | +14 тестов (см. ниже). |
| `.superpowers/sdd/deal-stage2-settings/task-10-curl-acceptance.sh`/`.log` (+4 UTF-8 body-json) | sh/log | curl-приёмка (25 проверок). |
## Границы и решения
- **kind/kw — только внутри IncomingRules**: план Task 10 L375 требует результат `{pass, reason, stage, kind, kw}`, но wire тестера (api-map §4.10 L364, dashboard_routes L273284) и фронт (`SettingsView.vue` тестер-блок) читают только `stage1.{pass,reason}/stage2.{pass,reason,skipped}/passed`. Поэтому эндпоинт отдаёт ровно форму прототипа; kind («какое правило») и kw (фраза/маркер) возвращает `IncomingRulesResult` — их проверяют unit-тесты, а curl-приёмка видит их в причинах («стоп-фраза «…»», «резюме соискателя («ищу работу»)»). Приёмка плана «kind:resume/stop» в curl выполняется через причины + unit-уровень.
- **Дефолтные стоп-фразы содержат «резюме» и «ищу работу»** (constants.py L55) — стоп-проверка идёт ДО resume-проверки (pipeline.py L106108), поэтому resume/type-ветки в тестах и curl достигаются после переопределения `stopPhrases` непересекающимся списком (ровно как пользователь в «Обработке сообщений»). Текст «Ищу работу python» (16 симв.) с дефолтами падает на длине (minLen=24); сценарий плана — после PATCH minLen=10.
- **Guard «резюме»** — 1:1 `_resume_reason` L644–658: маркер «резюме» с hire-маркером ДО него в тексте («…вакансия…, присылайте резюме») не режется. Python итерирует маркеры как set (произвольный порядок) — в C# итерация по порядку списка настроек (детерминированно); kw возвращается нормализованным (trim+lowercase, как set-значение python).
- **Порядок правил фиксирован** (длина → стоп → резюме → тип), причины — фиксированные строки python; на проходе `kind/kw=""`, `reason=null`.
- **ИИ-фильтр тестера не вызывается** (план Task 10 L377–380, требование задачи п.5): этап 6 — вне этапа; ветка «ошибка ИИ → skipped pass:true» прототипа (L281) здесь не нужна — ИИ не зовётся вовсе, поэтому «успех этапа 2» = `{pass:true, reason:null, skipped:true}` всегда.
## Тесты (14 новых; всего 175 PASS)
`IncomingRulesTests`: короткий текст → kind=length «короче 24 символов»; пустой/пробельный текст → length; minLen из настроек (10) пропускает текст между дефолтом и оверрайдом; чистый длинный текст с дефолтами → pass (kind/kw ""); стоп-фраза из настроек → kind=stop, kw=фраза, причина «стоп-фраза «…»»; регистронезависимое совпадение стоп-фразы (kw сохраняет регистр как в настройках); резюме blockResumes=вкл → kind=resume kw=«ищу работу»; blockResumes=выкл → pass; маркер «резюме» без hire-маркера до → режется; guard «…вакансия… присылайте резюме» → pass; wantedType=freelance с вакансионным текстом → kind=type (причина freelance); wantedType=vacancy с разовым заказом → kind=type (причина vacancy); wantedType=freelance с заказом → pass; wantedType=both (дефолт) с вакансией → pass.
## Приёмка (curl :5080, admin/admin)
1. check-message без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → дефолты: «Заработок на крипте…» (≥24, без стоп-фраз) → `{"stage1":{"pass":true,"reason":null},"stage2":{"pass":true,"reason":null,"skipped":true},"passed":true}` (форма §4.10 1:1).
3. Пустой текст → 200 `stage1.pass:false, reason:"короче 24 символов"`, `stage2.pass:false skipped:true`, `passed:false`.
4. PATCH `{"stopPhrases":["взаимный пиар"],"minLen":10}` → снимок `"stopPhrases":["взаимный пиар"],"minLen":10`.
5. Текст со «взаимный пиар» → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false`.
6. «Ищу работу python…» (≥minLen; стоп-фразы уже без «ищу работу») → `reason:"резюме соискателя («ищу работу»)"`, `passed:false`.
7. Валидный текст после PATCH → `passed:true`.
8. logout → check-message 401. Итог: **PASS=25 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания
1. **kind не в wire**: для наблюдаемости «какого правила сработало» на этапе 4 у IncomingRules есть kind/kw; если позже понадобится показывать kind в тестере — это будет расширение контракта §4.10 (сейчас 1:1 с прототипом, kind/kw не отдаём).
2. **«Резюме»-семантика зависит от настроек пользователя**: пока в `stopPhrases` лежит «резюме»/«ищу работу» (дефолты), такие тексты режутся стоп-списком раньше resume-ветки — это поведение прототипа 1:1 (порядок правил L106–108), не баг.
3. **Python-`casefold` ≈ .NET `ToLowerInvariant`** для кириллицы/латиницы; экзотика (`ß`→ss) не воспроизводится — для RU/EN-текстов каналов незначимо.
4. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/wire-ветки эндпоинта покрыты curl-приёмкой; юнит-уровень — IncomingRules (правила) + wire-форма в curl.
## Проверки
```
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175 (было 161, +14)
sh task-10-curl-acceptance.sh → PASS=25 FAIL=0 (лог task-10-curl-acceptance.log)
```
@@ -0,0 +1 @@
{"text": "Ищу работу python backend разработчик с опытом 5 лет, удалённая занятость, фриланс"}
@@ -0,0 +1 @@
{"text": "Заметил у вас отличный сервис и взаимный пиар в чатах, давайте продвигать каналы друг друга бесплатно"}
@@ -0,0 +1,296 @@
#!/usr/bin/env sh
# Task 11 curl-приёмка этапа 2 «Дейл»: единый сквозной сценарий на :5080 (план Task 11 L393-409,
# Self-Review L411-429). Порядок: 401 без куки → login admin/admin → GET /api/settings (дефолты)
# → PATCH группы Tasks 5/7 (minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/
# colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource) → GET сверка (секреты
# замаскированы; ratesCache/mlDecisions/aiDecisions не публикуются) → psql (enc: в aiConfigs/tgKeys,
# открытого ключа нет) → POST /api/rates/refresh + GET /api/rates (mock-кэш) →
# POST /api/ai/check (ветка по настройкам: локальный ollama) → GET /api/ml/status (ready:false-
# структура) + predict + reset → POST /api/admin/check-message (валидный → passed:true; текст со
# стоп-фразой → отсев) → logout → 401 на GET /api/settings → очистка dev-БД. PASS/FAIL каждого шага.
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
PATCH_BODY="$SCRIPT_DIR/task-11-patch.json"
GOOD_BODY="$SCRIPT_DIR/task-10-good.json"
STOP_BODY="$SCRIPT_DIR/task-10-stop.json"
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
JAR="/tmp/task11-jar.txt"
OUT="/tmp/task11-out.txt"
LOG="/tmp/task11-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo " ----- полный ответ -----"
cat "$OUT"
echo " ------------------------"
fi
}
check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1
pat=$2
if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не должно присутствовать: $pat"
echo " ----- полный ответ -----"
cat "$OUT"
echo " ------------------------"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pat)"
fi
}
show() {
# Короткий превью ответа в лог (полный ответ — в $OUT, печатается при FAIL)
head -c 200 "$OUT"
echo " …"
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null)
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
show
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. GET /api/settings — дефолтный снимок (чистая БД) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show
check "GET 200" '[HTTP:200]'
check "int/bool-дефолты" '"minLen":24' '"archiveAfterDays":14' '"mlEnabled":true' '"aiEnabled":true'
check "дефолтные stopPhrases (4)" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
check "строковые дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
check "colState пуст, tgKeys пусты" '"colState":{}' '"tgKeys":{"apiId":"","apiHashSet":false}'
check "aiConfigs deepseek без ключа (маска)" '"deepseek":{"baseUrl":"https://api.deepseek.com","model":"deepseek-v4-flash","keySet":false,"keyMasked":""}'
PROV_COUNT=$(grep -o '"id":"' "$OUT" | wc -l)
if [ "$PROV_COUNT" = "7" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] providers — 7 провайдеров ($PROV_COUNT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] providers — ожидалось 7, найдено $PROV_COUNT"
fi
check_absent "в дефолтном снимке нет шифротекста enc:" 'enc:'
check_absent "внутренние ключи не публикуются" 'ratesCache'
check_absent "внутренние ключи не публикуются" 'mlDecisions'
check_absent "внутренние ключи не публикуются" 'aiDecisions'
echo
echo "== 4. PATCH-группа: minLen/archiveAfterDays/autoArchive/remindersEnabled/stopPhrases/colState/aiProvider/aiConfigs+apiKey/tgKeys/myPrompts/rateSource =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary "@$PATCH_BODY" > "$OUT"
show
check "PATCH 200" '[HTTP:200]'
check "обработка: minLen/stopPhrases" '"minLen":30' '"stopPhrases":["взаимный пиар"]'
check "хранение/уведомления" '"autoArchive":false' '"archiveAfterDays":7' '"remindersEnabled":false'
check "colState passthrough" '"colState":{"review":1,"done":2}'
check "валюта: rateSource mock" '"rateSource":"mock"'
check "ИИ: активный провайдер ollama" '"aiProvider":"ollama"'
check "aiConfigs: deepseek keySet+маска (без открытого ключа)" '"keySet":true,"keyMasked":"sk-1…90ab"'
check "tgKeys: apiId + apiHashSet" '"tgKeys":{"apiId":"123456","apiHashSet":true}'
check "myPrompts: 1 запись с id pp_" '"myPrompts":[{"id":"pp_'
check_absent "открытого apiKey в ответе PATCH нет" 'sk-1234567890ab'
check_absent "enc: в public-снимке нет" 'enc:'
echo
echo "== 5. GET /api/settings — сверка: переопределения видны, секреты замаскированы =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show
check "GET 200" '[HTTP:200]'
check "переопределения применены" '"minLen":30' '"archiveAfterDays":7' '"autoArchive":false' '"remindersEnabled":false'
check "stopPhrases/colState новые" '"stopPhrases":["взаимный пиар"]' '"colState":{"review":1,"done":2}'
check "rateSource/aiProvider новые" '"rateSource":"mock"' '"aiProvider":"ollama"'
check "myPrompts сохранены" '"myPrompts":[{"id":"pp_'
check "deepseek keySet+маска" '"keySet":true,"keyMasked":"sk-1…90ab"'
check "tgKeys apiHashSet" '"apiId":"123456","apiHashSet":true'
check_absent "открытого apiKey в GET нет" 'sk-1234567890ab'
check_absent "enc: в GET-снимке нет (наружу только маски)" 'enc:'
check_absent "внутренний ratesCache не публикуется" 'ratesCache'
check_absent "внутренний mlDecisions не публикуется" 'mlDecisions'
check_absent "внутренний aiDecisions не публикуется" 'aiDecisions'
echo
echo "== 6. psql: строки settings созданы; aiConfigs/tgKeys — enc:, без открытого ключа =="
$PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings WHERE \"Key\" IN ('aiConfigs','tgKeys','minLen','stopPhrases','colState','aiProvider','rateSource','myPrompts');" > "$OUT" 2>/dev/null
check "8 ожидаемых ключей-переопределений на месте" '8'
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='aiConfigs';" > "$OUT" 2>/dev/null
check "aiConfigs.ValueJson содержит enc: (ключ зашифрован)" 'enc:'
check_absent "aiConfigs.ValueJson не содержит открытого ключа" 'sk-1234567890ab'
$PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null
check "tgKeys.ValueJson содержит enc: (apiHash зашифрован)" 'enc:'
echo
echo "== 7. POST /api/rates/refresh — ok:true, mock-кэш =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" \
-H "Content-Type: application/json" > "$OUT"
cat "$OUT"
echo
check "refresh 200 ok:true" '[HTTP:200]' '"ok":true' '"source":"mock"' '"base":"RUB"'
echo
echo "== 7b. GET /api/rates — тот же mock-кэш (updatedAt на месте) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "GET rates 200, source mock" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
check_absent "updatedAt не null после refresh" '"updatedAt":null'
echo
echo "== 8. POST /api/ai/check — ветка по настройкам (активный провайдер ollama — локальный) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" \
-H "Content-Type: application/json" > "$OUT"
cat "$OUT"
echo
check "ai/check 200 ok:true" '[HTTP:200]' '"ok":true' '"provider":"ollama"' '"local":true' '"name":"Ollama (локально)"'
check "сообщение локального сервера" 'Локальный сервер «Ollama (локально)» (ping в проде)'
echo
echo "== 9. GET /api/ml/status — ready:false-структура (заглушка LocalMlClient, Ruling 5) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT"
echo
check "ml/status 200, форма §4.10" '[HTTP:200]' '"enabled":true' '"reachable":true' '"service":{"ready":false' '"outbox":0' '"eval":{"count":0,"correct":0,"accuracy":0}'
echo
echo "== 9b. POST /api/ml/predict с текстом — «не уверен», все поля 1:1 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT"
cat "$OUT"
echo
check "predict 200" '[HTTP:200]'
check "take:false/label:null/scores:{}" '"take":false' '"label":null' '"scores":{}'
check "ready:false/terms:[]/type:null" '"ready":false' '"terms":[]' '"type":null'
echo
echo "== 9c. POST /api/ml/reset — {ok:true} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" \
-H "Content-Type: application/json" > "$OUT"
cat "$OUT"
echo
check "ml/reset 200 ok:true" '[HTTP:200]' '{"ok":true}'
echo
echo "== 10. POST /api/admin/check-message: валидный текст → passed:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$GOOD_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1.pass:true / stage2 skipped pass:true" '"stage1":{"pass":true,"reason":null}' '"stage2":{"pass":true,"reason":null,"skipped":true}'
check "passed:true" '"passed":true'
echo
echo "== 10b. POST /api/admin/check-message: текст со стоп-фразой «взаимный пиар» → отсев =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/check-message" \
-H "Content-Type: application/json" --data-binary "@$STOP_BODY" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "stage1 fail по стоп-фразе из настроек" '"pass":false,"reason":"стоп-фраза «взаимный пиар»"'
check "stage2 skipped, passed:false" '"stage2":{"pass":false,"reason":null,"skipped":true}' '"passed":false'
echo
echo "== 11. logout → GET /api/settings — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
show
check "после logout settings 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;" > /dev/null 2>&1
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;" 2>/dev/null)
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки сквозной curl-приёмки этапа 2 прошли"
@@ -0,0 +1 @@
{"minLen":30,"archiveAfterDays":7,"autoArchive":false,"remindersEnabled":false,"stopPhrases":["взаимный пиар"],"colState":{"review":1,"done":2},"aiProvider":"ollama","aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}},"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"},"myPrompts":[{"name":"Тестер","description":"проверка","prompt":"Ты — помощник оператора"}],"rateSource":"mock"}
@@ -0,0 +1,87 @@
# Task 11 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
Статус: **complete (review pending)**. Build 0 warnings / 0 errors; unit-тесты 175/175 PASS;
сквозная curl-приёмка :5080 — **PASS=60 FAIL=0** (один сценарий: `task-11-curl-acceptance.sh`,
лог `task-11-curl-acceptance.log`). Код/конфиги (кроме доков и ledger) не менялись.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 11 L393409,
Self-Review L411429).
## Сквозной сценарий приёмки (один прогон, dev-БД очищается до/после)
| Шаг | Проверка | Результат |
|---|---|---|
| 0 | psql: таблица `settings` дефолтного тенанта пуста; старт Deal.Api :5080 (Development) | PASS |
| 1 | `GET /api/settings` без куки → 401 `{"detail":"Требуется авторизация"}` | PASS |
| 2 | `POST /api/auth/login` admin/admin → `{ok:true, login:admin}` | PASS |
| 3 | `GET /api/settings` (чистая БД): дефолты — `minLen:24`, `archiveAfterDays:14`, `stopPhrases` 4 деф., `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`, `tgKeys:{apiId:"",apiHashSet:false}`, `colState:{}`, `aiConfigs` без ключей (маски `keyMasked:""`), `providers` — 7; нет `enc:`, нет `ratesCache`/`mlDecisions`/`aiDecisions` | PASS (11 проверок) |
| 4 | `PATCH /api/settings` группой Tasks 5/7 (разные типы: `minLen` int, `archiveAfterDays` int, `autoArchive`/`remindersEnabled` bool, `stopPhrases` list, `colState` dict, `aiProvider` string, `aiConfigs.deepseek.apiKey` секрет, `tgKeys` секрет+apiId, `myPrompts`, `rateSource`) → снимок: значения применены, `keySet:true,keyMasked:"sk-1…90ab"`, `apiHashSet:true`, `myPrompts[0].id` = `pp_…`, открытого ключа и `enc:` в ответе нет | PASS (11 проверок) |
| 5 | `GET /api/settings` сверка: переопределения на месте, секреты замаскированы, `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются, `enc:`/открытый ключ отсутствуют | PASS (12 проверок) |
| 6 | psql: 8 ключей-переопределений созданы; `aiConfigs.ValueJson` и `tgKeys.ValueJson` содержат `enc:`, открытого ключа в БД нет | PASS (4 проверки) |
| 7 | `POST /api/rates/refresh``{ok:true, rates:{base:"RUB",…,source:"mock",updatedAt:<ms>}}`; затем `GET /api/rates` — тот же кэш (source mock, `updatedAt` не null) | PASS (3 проверки) |
| 8 | `POST /api/ai/check` (без тела — ветка по настройкам: активный `aiProvider:"ollama"`) → `ok:true`, «Локальный сервер «Ollama (локально)» (ping в проде)», `local:true` | PASS (2 проверки) |
| 9 | `GET /api/ml/status` — форма §4.10 (`enabled:true`, `reachable:true`, `service.ready:false`, `outbox:0`, eval обнулён); `POST /api/ml/predict` с текстом → `take:false,label:null,scores:{},ready:false,terms:[],type:null`; `POST /api/ml/reset``{ok:true}` | PASS (5 проверок) |
| 10 | `POST /api/admin/check-message`: валидный текст → `stage1.pass:true`, `stage2.skipped:true`, `passed:true`; текст со стоп-фразой «взаимный пиар» (из настроек PATCH) → `stage1.pass:false, reason:"стоп-фраза «взаимный пиар»"`, `passed:false` | PASS (6 проверок) |
| 11 | logout → `GET /api/settings` 401 | PASS |
| 12 | Очистка dev-БД (0 строк), сервер остановлен, порт 5080 свободен | PASS |
Порядок эндпоинтов совпадает с задачами 5/7→8→6→9→10, т.е. проверены все группы этапа 2 и их
связность на одном состоянии БД: настройки из PATCH реально влияют на `/api/ai/check` (ветка по
`aiProvider`) и `/api/admin/check-message` (`stopPhrases`/`minLen` из настроек), курсы кэшируются
внутренним ключом, который не «протекает» в GET /settings.
## Сборка и тесты
```
dotnet build Deal.sln (src/core) → 0 warnings / 0 errors
dotnet test Deal.sln --no-build → всего: 175; сбой: 0; успешно: 175
sh scripts/build.sh && sh scripts/test.sh → build 0/0; тесты 175 PASS
```
## Изменения в доках и ledger
- `docs/technical/Техническая-документация-Дейл.md`: §11 — добавлен блок «Выполнено на этапе 2
(2026-09-06)» (модуль Settings: настройки/шифрование/ai-check/rates/ML-заглушка/тестер, 175 PASS)
и TODO про оживление Vue-фронта на этапе 3; §13 — заголовок «актуально для этапа 2», intro
дополнен этапом 2 и кредами; новые подразделы «4a. Шифрование секретов настроек» (env
`DEAL_ENCRYPTION_KEY`, файл `data/encryption.key`, env `DEAL_ENCRYPTION_KEY_FILE`, формат `enc:`,
маски) и «4b. Эндпоинты этапа 2» (GET/PATCH `/settings` + таблица `settings` тенанта, `/ai/check`,
`/rates*`, `/api/ml/*`, `/admin/check-message`); §13.6 — ожидается 175 PASS.
- `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: заголовок «на конец этапа 2»; этап 2 внесён
в «Выполнено» (задачи 1–11, 175 PASS, curl 60/60 + psql) с ограничением «фронт полностью оживёт на
этапе 3»; из «Оставшихся этапов» блок этапа 2 убран (оставшиеся начинаются с этапа 3).
- `.superpowers/sdd/deal-stage2-settings/progress.md`: строка «Task 11: complete (review pending).
Отчёт: task-11-report.md.» + todo `[x]`.
## Артефакты
- `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.sh` — скрипт сценария (PASS/FAIL каждого шага).
- `.superpowers/sdd/deal-stage2-settings/task-11-curl-acceptance.log` — лог прогона (PASS=60 FAIL=0).
- `.superpowers/sdd/deal-stage2-settings/task-11-patch.json` — тело PATCH-группы (переиспользованы
`task-10-good.json`/`task-10-stop.json`/`task-9-predict.json` для текстов).
## Отклонения и замечания
1. **Формулировка «resume»-ветки в тестере**: этап-1 правила проверены в Task 10 (unit + curl);
в сквозном сценарии Task 11 по плану гоняются только «валидный текст» и «текст со стоп-фразой» —
resume/type-ветки здесь не повторяются (план Task 11 L400 требует именно стоп-фразу и валидный).
2. **`/api/ml/candidates|apply` не повторялись в сквозном сценарии** — они приняты в Task 9
(curl 31/31); сценарий Task 11 включает status/predict/reset (план L396399).
3. **Psql-колонки — PascalCase** (`Key`/`ValueJson`/`UpdatedAt`, конвенция EF): psql-проверка
использует кавычки `"Key"`/`"ValueJson"` (в первых прогонах сценария snake_case-запросы падали —
исправлено в скрипте; финальный прогон 60/60).
4. **ai/check в сценарии детерминирован без внешней сети**: активный провайдер `ollama` (локальный) —
ветка «по настройкам» без реального HTTP; облачные ветки (401/403/сеть) приняты в Task 6.
5. Известные ограничения этапа зафиксированы в отчёте Task 11 плана и в доке §11: Telegram-вкладка,
«Проверить правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи»
(ai/suggest-keywords), канбан-фронт и полный `boot()` Vue-фронта — этапы 36 (Ruling 8/11);
реальные ml/ai/telegram-сервисы — этап 6.
6. Docker из direct-команд терминала недоступен (sandbox), psql внутри sh-скриптов работает —
приёмка выполнялась скриптом, как и в Tasks 510.
## Проверки
```
dotnet build Deal.sln → 0 warnings / 0 errors
dotnet test Deal.sln --no-build → 175 PASS
sh task-11-curl-acceptance.sh → PASS=60 FAIL=0 (лог task-11-curl-acceptance.log)
```
@@ -0,0 +1,79 @@
# Task 2 — «Модуль Settings: каталог ключей, дефолты, DTO, порт хранилища» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 51/51 PASS (было 38, добавлено 13 — `SettingsCatalogTests`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 2 L148184, Ruling 1/3/4/9/10).
## Файлы
Все созданы в `src/core/Deal.Modules.Settings/` (модуль остался чистым: без EF/Infrastructure/Npgsql — grep 0 совпадений; без эндпоинтов, без SettingsService, без Registrar — это Tasks 3–5). Изменений в существующих файлах нет (csproj не трогался: новых ссылок модулю не потребовалось).
| Файл | Тип | Содержание |
|---|---|---|
| `Application/SettingKind.cs` | enum | Категории ключей: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal (план L151). |
| `Application/SettingsKeys.cs` | static class | Константы имён ключей (39 публичных + 3 внутренних) + каталог `PublicKeys: ключ → SettingKind` + `FindPublicKind`. |
| `Application/SettingsDefaults.cs` | static class | Дефолтные значения (см. ниже). |
| `Application/DefaultPrompts.cs` | static class | `DefaultAiPrompt`/`DefaultCardPrompt`/`DefaultAiFilterPrompt` — тексты из `data.js`. |
| `Application/AiProviderDefinition.cs` | record | Id/Name/Base/Local/Models/ApiStyle? (`null`=OpenAI-совм., `"anthropic"`). |
| `Application/AiProviders.cs` | static class | Список 7 провайдеров. |
| `Application/MockRates.cs` | static class | Мок-курсы + `RatesFetchInterval` (6 ч). |
| `Application/ISettingsStore.cs` | interface | Порт KV-хранилища (см. ниже). |
| `Application/Models/SettingValue.cs` | record | `(Key, ValueJson, UpdatedAt)` — элемент порта. |
| `Application/Models/AiConfigSetting.cs` | record | Внутренняя (БД) форма конфига провайдера: `(ApiKey, BaseUrl, Model)`. |
| `Application/Models/TgKeysSetting.cs` | record | Внутренняя (БД) форма: `(ApiId, ApiHash)`. |
| `Application/Models/MyPromptDto.cs` | record | Элемент «Моих промптов» (public). |
| `Application/Models/AiConfigPublicDto.cs` | record | `(BaseUrl, Model, KeySet, KeyMasked)` — public-форма aiConfigs (Ruling 3). |
| `Application/Models/TgKeysPublicDto.cs` | record | `(ApiId, ApiHashSet)` — public-форма tgKeys (Ruling 3). |
| `Application/Models/ProviderPublicDto.cs` | record | `(Id, Name, Base, Local, Models)` — public-форма провайдера (без `api_style`). |
| `Application/Models/PublicSettingsDto.cs` | record | Все поля public-снимка §4.6, PascalCase-свойства (наружу camelCase даёт ASP.NET). |
| `tests/Deal.Tests.Unit/SettingsCatalogTests.cs` | xUnit | 13 тестов (см. ниже). |
## Каталог ключей (`SettingsKeys.PublicKeys`, 39 ключей)
- **Int (9):** `archiveAfterDays`, `archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discEvalSample`, `discEvalThreshold`.
- **Bool (11):** `autoArchive`, `aiEnabled`, `aiFilterEnabled`, `conversionOn`, `remindersEnabled`, `mlEnabled`, `blockResumes`, `budgetRequiredHire`, `budgetRequiredOrder`, `autoMonitorNew`, `discPaused`.
- **String (10):** `targetCurrency`, `rateSource`, `aiProvider`, `aiPrompt`, `aiFilterPrompt`, `cardPrompt`, `domainDescription`, `wantedType`, `hireLabel`, `orderLabel`.
- **List (5):** `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`.
- **Dict (1):** `colState`.
- **special:** `myPrompts` (MyPrompts), `aiConfigs` (AiConfigs), `tgKeys` (TgKeys).
- **Внутренние (константы, вне `PublicKeys` — Ruling 1):** `ratesCache`, `mlDecisions`, `aiDecisions`.
Список сверен с планом Task 2 (L152161) и api-map §4.6/PATCH L340 (тест `PublicKeys_CoverAllApiMapKeysWithCorrectCategories` — эталон из 39 wire-имён).
## Источники значений
- **Дефолты** — `backend/app/constants.py`: `DEFAULT_SETTINGS` L189245, стоп-фразы L55, маркеры найма L144–149, грейдов L151154, резюме L163167; `aiConfigs` — на каждого провайдера (пустой ключ, base, первая модель, constants.py L231234); `tgKeys={apiId:"",apiHash:""}` L236; `discPaused=false` — рантайм (в `DEFAULT_SETTINGS` нет; дефолт из GET прототипа, api-map L333). Ключевые значения, проверяемые приёмкой Task 5: `aiEnabled/mlEnabled=true`, `minLen=24`, `archiveAfterDays=14`, `stopPhrases` — 4 шт., `wantedType="both"`, `rateSource="cbr"`, `aiProvider="deepseek"`, `targetCurrency="RUB"`.
- **Промпты** — `src/frontend/src/data.js` L94–141 (фронт — высший авторитет; план L47, Task 2 L165167). В `constants.py` те же тексты L63–141, но с расхождениями в разбивке на строки и формулировке (например, пункт «title»), поэтому за основу взят data.js. Тексты скопированы построчно; сверка на данном шаге — маркерные тесты (`{domain}`, `{keywords}`, «О заявке», «страж входящих»); полное сравнение строк — в Task 7 (`PromptDefaultsTests`).
- **Провайдеры** — `constants.py` `AI_PROVIDERS` L170186 (7 шт., значения совпадают с `data.js` AI_PROVIDERS L1780). `api_style="anthropic"` только у anthropic — внутреннее поле, в public-форму не выходит (Ruling 3).
- **Мок-курсы** — `constants.py` `MOCK_RATES` L41–50; интервал 6 ч — `rates.py` L20 (Ruling 6).
## Порт `ISettingsStore`
```csharp
Task<SettingValue?> GetAsync(string key, CancellationToken ct);
Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct);
Task SetAsync(string key, string valueJson, CancellationToken ct);
Task RemoveAsync(string key, CancellationToken ct);
```
`SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt)` — 1:1 со строкой таблицы `settings` (сущность `TenantSettingEntity`: `Key`/`ValueJson`/`UpdatedAt` уже есть, новых EF-таблиц нет).
## Тесты (`SettingsCatalogTests`, 13)
Каталог покрывает §4.6 с корректной категорией; счётчик 39; внутренние ключи не в публичном каталоге (и wire-имена `ratesCache`/`mlDecisions`/`aiDecisions`); провайдеры — 7 шт., id/base/local/api_style/custom-models; MockRates — состав и RUB=1; дефолты Task 5-приёмки (вкл. `discPaused=false`, пустые `colState`/`myPrompts`); tgKeys пустые; aiConfigs посеяны на всех провайдеров с пустым ключом и первой моделью; промпт-маркеры.
## Расхождения с планом (и почему)
1. **Порт оперирует JSON-строками (`SettingValue.ValueJson`), а не `object?`-значениями** (план L173175: `GetAsync(key)→object?`, `SetAsync(key, object?)`). ТЗ задачи задаёт порт именно так: record `SettingValue(Key, ValueJson, UpdatedAt)` + `GetAllAsync/SetAsync/RemoveAsync` (+опц. `GetManyAsync`). Это 1:1 с колонками таблицы и снимает с адаптера (Task 4) необходимость знать типы значений: он становится простым маппером строк; сериализацию/десериализацию JSON держит модуль (он знает категории ключей). К плану добавлен `GetAsync` (план его требует; `GetManyAsync` не добавлялся — потребителей нет, YAGNI), `RemoveAsync` — по ТЗ.
2. **DTO public-снимка созданы в Task 2** (`Application/Models/*Dto.cs`): план относит их в Task 3 (L187–189), но ТЗ задачи (п. 4 «Что сделать») включает record-DTO для public-снимка. Созданы только типы (без SettingsService) — Task 3 остаётся их потребителем.
3. **Добавлены внутренние модели `AiConfigSetting`/`TgKeysSetting`** (в плане нет): нужны `SettingsDefaults` для точных дефолтов `aiConfigs`/`tgKeys` (план L164: «aiConfigs для каждого провайдера с первым model, tgKeys={apiId:"",apiHash:""}») без `object?`-словарей; переиспользуются Task 3 при (де)сериализации сохранённых значений.
4. **`RatesFetchInterval` размещён в `MockRates`** (план L172 группирует его с MockRates в один файл): один файл — один тип, поэтому интервал стал членом класса `MockRates` (обе величины — константы раздела «курсы»).
5. **Промпты — `static readonly`, не `const`** (план L165: «константы»): многострочные тексты в `const` невозможны; значения неизменяемы и инициализируются один раз (нормализация `\r\n``\n` и снятие завершающего перевода строки raw-литерала для идентичности строке шаблона data.js).
6. **Registrar не создавался** (п. 6 ТЗ «может быть заготовлена, если план требует»): план создаёт `SettingsModuleRegistrar` в Task 4 (L228) — здесь не требуется.
## Проверки
```
dotnet build Deal.sln → Сборка успешно выполнено, 0 предупреждений / 0 ошибок (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 51; сбой: 0; успешно: 51; пропущено: 0 (было 38 → +13)
grep 'Infrastructure|EntityFramework|Npgsql' в src/core/Deal.Modules.Settings → 0 совпадений
```
@@ -0,0 +1,56 @@
# Task 3 — «SettingsService: public-снимок и частичное обновление (PATCH 1:1)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 87/87 PASS (было 51, добавлено 36 — `SettingsServiceTests`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 3 L184220, Rulings 1/3/9/10; референс `settings_routes.py` L75192, `store.js` applySettings, api-map §4.6/§4.7).
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `src/core/Deal.Modules.Settings/Application/SettingsService.cs` | class | Сервис: `GetPublicAsync` + `ApplyPatchAsync` + приватные хелперы (клампы, маски, merge дефолтов и переопределений). |
| `src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs` | class | In-memory `ISettingsStore` (JSON-строки) с `Preload/GetStoredJson/Keys` для проверки «что ушло в БД». |
| `src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs` | class | Детерминированный шифр «enc: + Base64»: честен для сценария «сбойный токен → ""». |
| `src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs` | xUnit | 36 тестов (см. ниже). |
DTO не создавались — используются записи Task 2 (`PublicSettingsDto`, `AiConfigPublicDto`, `TgKeysPublicDto`, `AiConfigSetting`, `TgKeysSetting`, `MyPromptDto`, `SettingValue`).
## Поведение и сигнатуры (по плану Task 3 L190193)
```csharp
public sealed class SettingsService(ISettingsStore store, ISecretCipher secretCipher)
public Task<PublicSettingsDto> GetPublicAsync(CancellationToken ct);
public Task<PublicSettingsDto> ApplyPatchAsync(Dictionary<string, JsonElement> body, CancellationToken ct);
```
- **GET/снимок** = дефолты `SettingsDefaults`, перекрытые сохранёнными переопределениями из `ISettingsStore` (GetAllAsync → JSON). Маски Ruling 3: `aiConfigs {baseUrl, model, keySet, keyMasked}`, `tgKeys {apiId: маска, apiHashSet}`; ключи расшифровываются через `ISecretCipher.Decrypt` (сбойный/чужой `enc:``""``keySet=false`, изоляция T1). Внутренние ключи (`ratesCache`/`mlDecisions`/`aiDecisions`) в снимок не входят (Ruling 1). Повреждённая JSON-строка в БД не роняет снимок (трактуется как отсутствующая).
- **PATCH** = только переданные поля; ответ — полный снимок после применения (затем повторный `GetPublicAsync`). Неизвестные/внутренние ключи игнорируются (Ruling 1). Семантика 1:1 `settings_routes.py`:
- Int: клампы `archiveAfterDays 1..30`, `minLen 10..500`, `discJoinLimit 1..200`, `discJoinDelayMin/Max 5..600`, `discEvalSample 3..30`, `discEvalThreshold 1..100`; нечисловое → пропуск; `archiveClearDays`/`trashClearDays` без клампа.
- Интервалы задержек: пара — клампы 5..600 затем swap при min>max; один конец — кламп относительно эффективного (дефолт+stored) другого конца (L80–109).
- Bool: только JSON-булево (строки не «питон-булеватся»); String: `targetCurrency`→Upper, `aiProvider` вне `AiProviders` → пропуск; List: массив → строки, срез 200; Dict `colState` — passthrough raw JSON (Ruling 9).
- `myPrompts`: срез 100 raw-элементов, trim+срезы name 80 / description 300 / prompt 8000, пустые name/prompt — дроп, id ≤40 или генерация `pp_`+8 hex.
- `aiConfigs`: только провайдеры каталога; `baseUrl`/`model` — строки; `apiKey` ≥8 без префикса `enc:``Encrypt` (в БД `enc:`); `keySet`/`keyMasked` наружу не пишутся, вычисляются при чтении.
- `tgKeys`: `apiId` — только ASCII-цифры, длина 6..9 (stored открыто); `apiHash` ≥16 без `enc:``Encrypt`.
- Хранение только переопределений: применённое значение aiConfigs/tgKeys, равное дефолту, в БД не пишется (`RemoveAsync`); секреты никогда не возвращаются в открытом виде (маска `1234…5678`, символ «…»).
## Тесты (36, SettingsServiceTests)
Снимок дефолтов на пустом хранилище; перекрытие дефолтов сохранёнными (int/list/colState/aiConfigs/tgKeys); маскирование (`sk-1…90ab`, apiId 10 цифр → `1234…7890`); сбойный `enc:` → keySet=false без исключения; PATCH меняет только переданные поля (1 строка в хранилище); неизвестные/внутренние ключи игнорируются; каждый Int-кламп + числовая строка + нечисловое; swap пары интервалов; одиночные концы (дефолтный и сохранённый другой конец); Bool-строка игнорируется, false хранится; `targetCurrency` Upper; `aiProvider` неизвестный/известный; List со скалярами и срез 200; myPrompts (clean+генерация pp_, дроп, trim, срезы 80/300/8000/40, срез 100, не-массив); aiConfigs (enc в store + `keySet/keyMasked`, короткий ключ не пишется, неизвестный провайдер игнорируется, baseUrl/model, сохранение ключа при follow-up patch без apiKey); tgKeys (enc хэша, не-цифровой apiId, короткий хэш, маска 9 цифр); colState passthrough (store raw + снимок); ответ PATCH == снимок последующего GET.
## Расхождения (план vs бриф/прототип)
1. **Имена методов**: бриф задачи называл `GetPublicSnapshotAsync`/`ApplyPatchAsync(SettingsPatchDto)`; план (файл, L190193) — `GetPublicAsync(ct)` и `ApplyPatchAsync(Dictionary<string, JsonElement> body, ct)`. Взят план: Task 5 («тело — произвольный JSON-объект») и мягкая семантика («невалидное поле просто не применяется») требуют словаря JsonElement, а не типизированного DTO (иначе ошибки десериализации тела). `SettingsPatchDto` не создавался.
2. **Приёмка Task 5 (L256257)**: `{discJoinDelayMin:700, discJoinDelayMax:5}` ожидает ответ `{5, 700}`; фактически по коду прототипа (L88–93: клампы ПЕРЕД swap) и семантике Task 3 (L199201) ответ `{5, 600}` (700 → 600, затем swap). Следовал коду прототипа; строку приёмки Task 5 считаю опечаткой (swap без клампов).
3. **Bool**: прототип хранит `bool(value)` (строка `"false"` → true); по плану Task 3 строки не «питон-булеватся» — только JSON true/false. План приоритетнее.
4. **List**: не-скалярные элементы (объект/массив/null) пропускаются, а не `str()`-ятся как в Python (`"None"`, `"{...}"`); срез 200 после конвертации — 1:1.
5. **Int**: принимается JSON-число (int64) и числовая строка; float/булево/>int64 — «нечисловое» → пропуск (Python `int()` конвертирует шире). Реалистичные запросы фронта — целые.
6. **«Один конец интервала»** клампится относительно эффективного другого конца (дефолт+stored): в прототипе БД посеяна дефолтами, в нашей модели дефолты в коде — для пустого хранилища результат эквивалентен (мин. 700 → 70; макс. 5 → 50).
7. **aiConfigs в БД** — полный эффективный словарь всех провайдеров (как в прототипе, где строка всегда полна); GET мержит дефолт+stored по провайдеру — устойчиво к дрейфу каталога. «Только существующие провайдеры» = каталог `AiProviders` (дефолтный aiConfigs покрывает всех).
8. **Побочные эффекты** (fire-and-forget `RatesService.RefreshAsync` при `rateSource`, пересчёт при `targetCurrency`/`conversionOn`, L186–192) в сервис не входят: Tasks 5/8; в этапе 2 leads нет.
9. **`Decrypt` без `enc:` → ""** (решение T1, legacy-данных нет) — legacy-открытый секрет показал бы `keySet=false`; запись всегда шифрует, строки-исключения нет.
## Проверки
```
dotnet build Deal.sln → Сборка успешно, 0 предупреждений / 0 ошибок (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 87; не пройдено: 0; успешно: 87 (было 51 → +36)
```
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Deal.SettingsDevCheck</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\core\Deal.Infrastructure\Deal.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\..\src\core\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
</ItemGroup>
</Project>
@@ -0,0 +1,115 @@
using Deal.Infrastructure;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
// Dev-проверка Task 4 (план L235236): KV-адаптер SettingsStore (EF) и DI против реального
// Postgres deal-postgres (:5433, контейнер deploy/compose.dev.yml). Проект лежит вне решения —
// в сборку/тесты src/core не входит. Сценарий = acceptance задачи: на пустой схеме тенанта
// GET через сервис возвращает дефолты; SetAsync создаёт строку с value_json (её затем показывает
// psql); upsert/remove/updated_at — семантика адаптера. Проверочная схема devcheck_t4
// создаётся tenant-миграциями (как TenantProvisioningService) и удаляется после psql-проверки.
const string baseConnection = "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
const string schemaName = "devcheck_t4";
const string migrationsHistoryTable = "__TenantMigrationsHistory";
int failures = 0;
Console.WriteLine($"== Сброс и провижининг схемы {schemaName} (tenant-миграции) ==");
await ExecSqlAsync($"DROP SCHEMA IF EXISTS \"{schemaName}\" CASCADE; CREATE SCHEMA \"{schemaName}\";");
var migrationOptions = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql(
$"{baseConnection};Search Path={schemaName}",
npgsql => npgsql.MigrationsHistoryTable(migrationsHistoryTable, schemaName))
.Options;
await using (var migrator = new TenantDbContext(migrationOptions))
{
await migrator.Database.MigrateAsync();
}
Console.WriteLine();
Console.WriteLine("== DI: AddDealPersistence() + AddSettingsModule() + scoped TenantDbContext ==");
var services = new ServiceCollection();
services.AddDbContext<TenantDbContext>(options => options.UseNpgsql($"{baseConnection};Search Path={schemaName}"));
services.AddDealPersistence();
// Ключ шифрования не важен: секреты (aiConfigs/tgKeys) в проверке не пишутся — только дефолтный снимок.
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(new byte[32]));
services.AddSettingsModule();
await using ServiceProvider provider = services.BuildServiceProvider();
await using AsyncServiceScope scope = provider.CreateAsyncScope();
SettingsService settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
ISettingsStore store = scope.ServiceProvider.GetRequiredService<ISettingsStore>();
Console.WriteLine();
Console.WriteLine("== 1. Пустая схема: GET через сервис -> дефолты ==");
Check((await store.GetAllAsync(default)).Count == 0, "хранилище пусто (0 строк)");
Check(await store.GetAsync(SettingsKeys.ArchiveAfterDays, default) is null, "GetAsync отсутствующего ключа -> null");
PublicSettingsDto snapshot = await settings.GetPublicAsync(default);
Check(snapshot.AiEnabled, "aiEnabled=true");
Check(snapshot.MlEnabled, "mlEnabled=true");
Check(snapshot.MinLen == 24, "minLen=24");
Check(snapshot.ArchiveAfterDays == 14, "archiveAfterDays=14");
Check(snapshot.StopPhrases.Count == 4, "stopPhrases: 4 дефолтные");
Check(snapshot.WantedType == "both", "wantedType=both");
Check(snapshot.RateSource == "cbr", "rateSource=cbr");
Check(snapshot.AiProvider == "deepseek", "aiProvider=deepseek");
Check(snapshot.TgKeys.ApiId == string.Empty && !snapshot.TgKeys.ApiHashSet, "tgKeys={apiId:\"\", apiHashSet:false}");
Check(snapshot.ColState.Count == 0, "colState={}");
Check(snapshot.Providers.Count == 7, "providers: 7 шт.");
Console.WriteLine();
Console.WriteLine("== 2. SetAsync (upsert по PK) + Get/GetAll + updated_at UTC ==");
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(default);
Check(rows.Count == 1, "SetAsync создал 1 строку");
SettingValue? row = await store.GetAsync(SettingsKeys.ArchiveAfterDays, default);
Check(row is not null && row.ValueJson == "30", "GetAsync вернул value_json=\"30\"");
Check(row is not null && DateTimeOffset.UtcNow - row.UpdatedAt < TimeSpan.FromMinutes(1), "updated_at ~ UTC-now");
await store.SetAsync(SettingsKeys.ArchiveAfterDays, "30", default);
Check((await store.GetAllAsync(default)).Count == 1, "повторный SetAsync не дублирует (upsert по PK)");
await store.SetAsync(SettingsKeys.ColState, "{\"kanban\":true}", default);
Check((await store.GetAllAsync(default)).Count == 2, "второй ключ — вторая строка");
Check(await store.GetAsync(SettingsKeys.ColState, default) is { ValueJson: "{\"kanban\":true}" }, "value_json хранится текстом как есть");
Console.WriteLine();
Console.WriteLine("== 3. RemoveAsync ==");
await store.RemoveAsync("noSuchKey", default);
Check((await store.GetAllAsync(default)).Count == 2, "RemoveAsync отсутствующего ключа — no-op");
await store.RemoveAsync(SettingsKeys.ColState, default);
Check((await store.GetAllAsync(default)).Count == 1, "RemoveAsync удалил существующую строку");
Console.WriteLine();
Console.WriteLine("== 4. GET через сервис после SetAsync (переопределение дефолта) ==");
PublicSettingsDto after = await settings.GetPublicAsync(default);
Check(after.ArchiveAfterDays == 30, "archiveAfterDays=30 — сохранённое переопределило дефолт 14");
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ИТОГ: все проверки прошли." : $"ИТОГ: не прошли проверок: {failures}.");
Console.WriteLine($"Схема {schemaName} оставлена с 1 строкой settings для psql-проверки value_json.");
Environment.ExitCode = failures == 0 ? 0 : 1;
void Check(bool condition, string description)
{
Console.WriteLine($" {(condition ? "[PASS]" : "[FAIL]")} {description}");
if (!condition)
{
failures++;
}
}
async Task ExecSqlAsync(string sql)
{
await using var connection = new NpgsqlConnection(baseConnection);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
@@ -0,0 +1,79 @@
# Task 4 — «KV-адаптер SettingsStore (EF) и DI» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (было 87, добавлен 1);
psql-приёмка на реальном `deal-postgres` (:5433) — все проверки PASS (см. ниже).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 4 L220236, Ruling 1;
эталон — `AuthStore.cs`/`TenantRepository.cs`, `TenantModuleRegistrar.cs`, `ServiceCollectionExtensions.cs`).
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs` | class | EF-адаптер `ISettingsStore` поверх `TenantDbContext.Settings` (таблица settings tenant-схемы, сущность `TenantSettingEntity` уже была — миграции/таблицы НЕ добавлялись). Маппинг 1:1 `SettingValue(Key, ValueJson, UpdatedAt)` ↔ сущность; `value_json` хранится текстом без интерпретации (сериализацию выполняет модуль Settings — см. порт). |
| `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealPersistence()`: добавлено `AddScoped<ISettingsStore, SettingsStore>()`; XML-doc метода дополнен. |
| `src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | class | `AddSettingsModule()`: `AddScoped<SettingsService>()` (паттерн `TenantModuleRegistrar`; вызов из `Program.cs` — Task 5). |
| `src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj` | modify | `PackageReference Microsoft.Extensions.DependencyInjection.Abstractions 10.0.11` (для регистратора). |
| `src/core/Deal.Api/Deal.Api.csproj` | modify | `ProjectReference` на `Deal.Modules.Settings` (план Task 4; вызов модуля в Program.cs — Task 5). |
| `src/core/tests/Deal.Tests.Unit/TenantSettingEntityTests.cs` | xUnit | Лёгкий тест сущности `TenantSettingEntity` (по образцу `TenantEntityTests`). |
| `.superpowers/sdd/deal-stage2-settings/task-4-devcheck/` (csproj+Program.cs) | dev-check | Консольная приёмка адаптера против реального Postgres (вне решения, в сборку не входит). |
`Deal.Infrastructure.csproj` уже ссылался на `Deal.Modules.Settings` — изменений не потребовалось.
## Поведение адаптера
- `GetAsync``AsNoTracking`, `SingleOrDefaultAsync` по PK `Key`; отсутствующий ключ → `null` (модуль трактует как дефолт).
- `GetAllAsync` — все строки, `AsNoTracking`, упорядочены по `Key`.
- `SetAsync` — upsert по PK: существующая строка обновляется, отсутствующая добавляется; `UpdatedAt = DateTimeOffset.UtcNow`; сохранение — `SaveChangesAsync` (в стиле `AuthStore.CreateSessionAsync`).
- `RemoveAsync``ExecuteDeleteAsync` по ключу: существующая строка удаляется, отсутствующий ключ — no-op (в стиле `AuthStore.DeleteSessionAsync`).
- Маппинг DTO ↔ сущности — вручную (порт модуля не видит EF-сущности), как в эталонных адаптерах.
## Отклонения и решения
1. **`SettingsModuleRegistrar` регистрирует только `SettingsService`.** Список файлов Task 4 упоминает `RatesService` и
`IncomingRules` — их типов в модуле ещё нет (задачи 8/10), регистрация несуществующих типов сломала бы build 0/0.
Регистратор расширяется по мере появления сервисов модуля (задачи 8/10 добавляют свои строки).
2. **Вызов `AddSettingsModule()` в `Program.cs` и runtime-регистрация scoped `TenantDbContext` — Task 5** (план: «вызывается
в A/Program.cs (Task 5)», L229). Поэтому HTTP/curl-часть приёмки (`GET /api/settings`) невозможна до Task 5.
3. **psql-приёмка выполнена dev-check-харнессом** (п. «Проверки»): эндпоинтов ещё нет, поэтому acceptance исполнен на уровне
«сервис + адаптер» против реального `deal-postgres` на временной схеме `devcheck_t4` (провижининг — tenant-миграцией,
как `TenantProvisioningService`), после проверки схема удалена. Схема дефолтного тенанта не тронута (0 строк settings, как было).
4. **Unit-тест адаптера без БД невозможен** (EF-провайдер нужен); InMemory по конвенции проекта не используется.
В unit добавлен лёгкий тест сущности-носителя (образец `TenantEntityTests`), полный сценарий — dev-проверка выше.
5. **Ключ шифрования в dev-check — нулевой** (32 нулевых байта): секреты aiConfigs/tgKeys в проверке не пишутся,
используется только дефолтный снимок (AES-операций нет) — достаточно для проверки KV-адаптера и DI.
## Проверки
### build + unit
```
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0. (11 проектов)
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88 (было 87 → +1)
```
### psql / dev-check (acceptance Task 4, L235236)
Харнесс (схема `devcheck_t4`, tenant-миграция `InitialTenant` применена; DI: `AddDealPersistence` +
`AddSettingsModule` + scoped `TenantDbContext`) — все 24 проверки `[PASS]`:
1. **Пустая схема: GET через сервис → дефолты** — `aiEnabled:true, mlEnabled:true, minLen:24,
archiveAfterDays:14, stopPhrases:4, wantedType:"both", rateSource:"cbr", aiProvider:"deepseek",
tgKeys:{apiId:"",apiHashSet:false}, colState:{}, providers:7` (совпадает с Task 5-приёмкой L253255);
`GetAsync` отсутствующего ключа → null.
2. **SetAsync создаёт строку** — после `SetAsync("archiveAfterDays","30")`:
`GetAllAsync` → 1 строка, `GetAsync` → `value_json="30"`, `updated_at` ≈ UTC-now; повторный SetAsync
не дублирует (upsert по PK); второй ключ — вторая строка; JSON хранится текстом как есть.
3. **RemoveAsync** — отсутствующий ключ no-op; существующая строка удаляется.
4. **GET через сервис после записи** — `archiveAfterDays:30` (переопределение дефолта 14).
psql после прогона (строка на месте, до очистки схемы):
```
$ docker exec deal-postgres psql -U deal -d deal -c 'SELECT "Key", "ValueJson", "UpdatedAt" FROM devcheck_t4.settings;'
Key | ValueJson | UpdatedAt
------------------+-----------+-------------------------------
archiveAfterDays | 30 | 2026-09-06 01:13:55.443698+00
```
Очистка: `DROP SCHEMA devcheck_t4 CASCADE` выполнен; `\dn` — только public + tenant_…001; в схеме дефолтного
тенанта settings пуста (как до проверки).
@@ -0,0 +1,286 @@
#!/usr/bin/env sh
# Task 5 curl-приёмка GET/PATCH /api/settings на :5080 (план Task 5 L252263).
# Сценарий: 401 без куки → login admin/admin → GET дефолты → PATCH клампы+swap →
# myPrompts → aiConfigs (deepseek apiKey) → tgKeys → поля (archiveAfterDays/minLen/stopPhrases/colState)
# → GET: изменения видны, секреты замаскированы → PATCH невалидных клампов повторно →
# неизвестный ключ → logout. В конце psql: строки settings + enc:. Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
CORE_DIR="C:/telbase/src/core"
API_DIR="$CORE_DIR/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task5-jar.txt"
OUT="/tmp/task5-out.txt"
LOG="/tmp/task5-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
check_count() {
# $1 — описание; $2 — подстрока; $3 — ожидаемое число вхождений
desc=$1
pat=$2
want=$3
got=$(grep -oF -- "$pat" "$OUT" | wc -l)
if [ "$got" = "$want" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc ($got)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено $got, ожидалось $want"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" /tmp/task5-body8.json
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторные прогоны
# и прерванные запуски могли оставить переопределения). Чистим до старта сервера.
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
# Ждём health (до 30 с; bootstrap провижинит схему дефолтного тенанта до первого ответа).
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/settings без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 1b. PATCH /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"archiveAfterDays":7}' > "$OUT"
cat "$OUT"
echo
check "401 PATCH без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 2b. PATCH /api/settings невалидный JSON — ожидаем 400 {detail} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"archiveAfterDays":' > "$OUT"
cat "$OUT"
echo
check "400 на не-JSON тело" '[HTTP:400]' '"detail":"Тело запроса должно быть JSON-объектом"'
echo
echo "== 3. GET /api/settings — дефолты (пустая таблица settings) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET 200" '[HTTP:200]'
check "int-дефолты" '"archiveAfterDays":14' '"minLen":24'
check "bool-дефолты" '"autoArchive":true' '"aiEnabled":true' '"mlEnabled":true'
check "string-дефолты" '"wantedType":"both"' '"rateSource":"cbr"' '"aiProvider":"deepseek"'
check "stopPhrases — 4 дефолтные" '"stopPhrases":["взаимный пиар","резюме","ищу работу","набор в команду"]'
check "tgKeys пустые" '"apiId":""' '"apiHashSet":false'
check "colState пустой" '"colState":{}'
check "aiConfigs deepseek пустой ключ" '"deepseek":' '"keySet":false' '"keyMasked":""'
check_count "providers — 7 провайдеров" '"id":"' 7
echo
echo "== 4. PATCH клампы+swap {archiveAfterDays:99, minLen:3, delayMin:700, delayMax:5} =="
echo " ожидаем archiveAfterDays:30, minLen:10, delayMin:5, delayMax:600 (клампы 5..600 ПЕРЕД swap)"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "клампы применены" '"archiveAfterDays":30' '"minLen":10'
check "интервал задержек {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
echo
echo "== 5. PATCH myPrompts [{name:x,prompt:y},{name:empty}] → 1 элемент c id pp_ =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"myPrompts":[{"name":"x","prompt":"y"},{"name":"","prompt":""}]}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check_count "ровно 1 промпт" '"name":"x"' 1
check "id генерируется с префиксом pp_" '"id":"pp_'
echo
echo "== 6. PATCH aiConfigs.deepseek.apiKey sk-1234567890ab =="
echo " ожидаем keySet:true, keyMasked: sk-1…90ab"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "deepseek keySet/mask" '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo
echo "== 7. PATCH tgKeys {apiId:123456, apiHash:abcdefghijklmnop} → apiHashSet:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "apiId как есть (len<=8) и apiHashSet true" '"apiId":"123456"' '"apiHashSet":true'
echo
# Тело с кириллицей передаём файлом (UTF-8): в Windows curl аргумент командной строки
# с кириллицей перекодируется в cp1251, и сервер получает невалидный UTF-8.
cat > /tmp/task5-body8.json <<'BODY8'
{"archiveAfterDays":7,"minLen":30,"stopPhrases":["стоп раз","стоп два"],"colState":{"review":1,"done":2}}
BODY8
echo "== 8. PATCH поля: archiveAfterDays=7, minLen=30, stopPhrases, colState =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @/tmp/task5-body8.json > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "новые значения в ответе" '"archiveAfterDays":7' '"minLen":30'
check "stopPhrases заменены" '"stopPhrases":["стоп раз","стоп два"]'
check "colState passthrough" '"colState":{"review":1,"done":2}'
echo
echo "== 9. GET /api/settings — финальная проверка: изменения видны, секреты замаскированы =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET 200" '[HTTP:200]'
check "переопределения применены" '"archiveAfterDays":7' '"minLen":30'
check "stopPhrases новые" '"stopPhrases":["стоп раз","стоп два"]'
check "colState новый" '"colState":{"review":1,"done":2}'
check "myPrompts сохранён" '"name":"x"' '"id":"pp_'
check "deepseek keySet+маска" '"keySet":true' '"keyMasked":"sk-1…90ab"'
if grep -qF "sk-1234567890ab" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] открытый ключ sk-1234567890ab утёк в ответ GET"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] открытого ключа в ответе GET нет (только маска sk-1…90ab)"
fi
check "tgKeys apiHashSet" '"apiHashSet":true'
echo
echo "== 10. PATCH невалидных клампов повторно {archiveAfterDays:99, delayMin:700, delayMax:5} =="
echo " ожидаем archiveAfterDays:30, delay {5,600}"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"archiveAfterDays":99,"discJoinDelayMin":700,"discJoinDelayMax":5}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "кламп 30" '"archiveAfterDays":30'
check "delay {5,600}" '"discJoinDelayMin":5' '"discJoinDelayMax":600'
echo
echo "== 11. PATCH неизвестного ключа {\"foo\":1} — мягкая семантика, без ошибки, без foo =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"foo":1}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200 без detail" '[HTTP:200]'
if grep -qF '"foo"' "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] foo попал в снимок"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] foo отсутствует в снимке"
fi
echo
echo "== 12. POST /api/auth/logout =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
echo
echo "== 13. psql: строки settings в схеме дефолтного тенанта; aiConfigs/tgKeys зашифрованы enc: =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
echo "--- проверка enc: в секретах ---"
$PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';"
echo
echo
echo "== 14. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию; psql-свидетельство выше) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,109 @@
# Task 5 — «Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты 88/88 PASS (без изменений количества);
curl-приёмка на :5080 — **PASS=42 FAIL=0** (скрипт `task-5-curl-acceptance.sh`, лог `task-5-curl-acceptance.log`).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 5 L238263, Rulings 3/8/11;
эталон — `AuthEndpoints.cs`). Приёмка delay-клампов — **{5,600}**, не {5,700} из плана (опечатка плана;
клампы 5..600 ПЕРЕД swap — см. ledger/отчёт T3).
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `src/core/Deal.Api/Program.cs` | modify | Регистрация scoped `TenantDbContext` (см. ниже); `AddSettingsModule()`; `app.MapSettingsEndpoints()`. |
| `src/core/Deal.Api/Endpoints/SettingsEndpoints.cs` | class | `MapSettingsEndpoints`: группа `/api`, tag `settings`, GET/PATCH `/settings` (стиль `AuthEndpoints`). |
| `.superpowers/sdd/deal-stage2-settings/task-5-curl-acceptance.sh` | sh | curl-приёмка (42 проверки, см. ниже). |
## Регистрация TenantDbContext (Task 5, п.1)
В `Program.cs` рядом с `DealDbContext`/`ITenantContext`/`ConnectionStringProvider` добавлено:
- `AddDbContext<TenantDbContext>((sp, options) => …)` с `contextLifetime/optionsLifetime: Scoped`.
**optionsLifetime: Scoped** обязателен: при singleton-опциях строка подключения первого запроса
(с Search Path его тенанта) закешировалась бы на весь процесс. Опции строятся на каждый scope по
текущему `ITenantContext` (заполняет `SessionMiddleware`) → `ConnectionStringProvider.ForTenant(TenantId)`.
`MigrationsHistoryTable("__TenantMigrationsHistory")` **без схемы** (схема — search_path; runtime-контекст
миграции не выполняет — их применяет `TenantProvisioningService` на старте).
- Безопасность: `!tenantContext.HasTenant``InvalidOperationException` с понятным сообщением **при резолве**
(500 на запрос = ошибка конфигурации; на нормальных запросах без сессии эндпоинт отвечает 401 раньше).
## Эндпоинты (Task 5, п.2, контракт §3.4/§4.6)
`GET /api/settings` → 200, публичный снимок (дефолты+переопределения, маски Ruling 3, providers).
`PATCH /api/settings` — тело произвольный JSON-объект → `ApplyPatchAsync` → 200, полный снимок после
применения (фронт затирает локальный state ответом). 401 `{"detail":"Требуется авторизация"}` без сессии;
не-JSON-объект тела → 400 `{"detail":"Тело запроса должно быть JSON-объектом"}` (в прототипе FastAPI — 422).
Мягкая семантика невалидных полей — из сервиса (Task 3), эндпоинт исключений не добавляет.
**Отклонение (важное):** `SettingsService` резолвится из `context.RequestServices` **внутри обработчика после
проверки сессии**, а не параметром эндпоинта. DI-биндинг параметров минимальных API выполняется до тела
обработчика, а зависимость сервиса — scoped `TenantDbContext`, опции которого требуют tenant-контекст
(без сессии — не разрешим). При резолве параметром запрос без куки получал бы 500 вместо 401 —
проверено эмпирически на первом прогоне (см. Concerns). Поведение 401 для GET и PATCH подтверждено.
## Проверки
### build + unit
```
dotnet build Deal.sln → Сборка успешно завершена. Предупреждений: 0. Ошибок: 0.
dotnet test tests/Deal.Tests.Unit → всего: 88; не пройдено: 0; успешно: 88
```
### curl-приёмка (PASS=42 FAIL=0; полный вывод — task-5-curl-acceptance.log)
1. Очистка `settings` дефолтного тенанта (повторяемость) → GET/PATCH без куки → **401** `Требуется авторизация`;
PATCH невалидного JSON с кукой → **400** `Тело запроса должно быть JSON-объектом`.
2. login admin/admin → **GET дефолты**: `autoArchive/aiEnabled/mlEnabled:true`, `archiveAfterDays:14, minLen:24`,
`stopPhrases` 4 дефолтные, `wantedType:"both"`, `rateSource:"cbr"`, `aiProvider:"deepseek"`,
`tgKeys {apiId:"", apiHashSet:false}`, `colState:{}`, aiConfigs keySet:false/keyMasked:"", **providers: 7**.
3. `PATCH {archiveAfterDays:99, minLen:3, discJoinDelayMin:700, discJoinDelayMax:5}`
**`archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:600`** (swap, кламп {5,600}).
4. `PATCH myPrompts [{name:x,prompt:y},{name:"",prompt:""}]` → 1 элемент, **id `pp_…`**.
5. `PATCH aiConfigs.deepseek.apiKey sk-1234567890ab`**keySet:true, keyMasked:"sk-1…90ab"**.
6. `PATCH tgKeys {apiId:"123456", apiHash:"abcdefghijklmnop"}`**apiId:"123456", apiHashSet:true**.
7. `PATCH {archiveAfterDays:7, minLen:30, stopPhrases:[«стоп раз»,«стоп два»], colState:{review:1,done:2}}`
(тело файлом UTF-8, см. Concerns) → значения применены, colState passthrough как есть.
8. **GET после PATCH**: все изменения видны; секреты замаскированы (`sk-1…90ab`), открытого ключа нет;
keySet/apiHashSet true; myPrompts/colState на месте.
9. Повторный PATCH невалидных клампов `{99, 700/5}`**30 и {5,600}**.
10. `PATCH {foo:1}` → 200 без ошибки, `foo` в снимке отсутствует. → logout 200.
### psql (до очистки; схема `tenant_000…001`)
```
9 строк: aiConfigs, archiveAfterDays=30, colState, discJoinDelayMin=5, discJoinDelayMax=600,
minLen=30, myPrompts, stopPhrases, tgKeys. enc: — в 2 строках:
aiConfigs → "deepseek":{"apiKey":"enc:5quPTxzr…","baseUrl":… (полный словарь всех провайдеров)
tgKeys → {"apiId":"123456","apiHash":"enc:PKqyPl…"}
```
После прогона dev-БД возвращена к исходному состоянию: `DELETE FROM …settings` → 0 строк; процесс остановлен,
порт :5080 свободен, процессов `Deal.Api` нет.
## Отклонения и решения
1. **Резолв `SettingsService` через `RequestServices` внутри обработчиков** — см. выше; поведение (401 без
сессии, а не 500) зафиксировано в логе прогона-1 и в приёмке.
2. **`optionsLifetime: Scoped`** для `TenantDbContext` — осознанный выбор multi-tenancy: per-scope строка
подключения (иначе singleton-опции «залипли» бы на первом тенанте). Модель кешируется EF на внутренний
провайдер (ключ — опции), поэтому повторные запросы того же тенанта дешёвые; кол-во тенантов на инсталляцию
небольшое (dev — 1).
3. **Приёмка delay-клампов {5,600}** — план в L257 ожидает `discJoinDelayMax:700` (опечатка); фактическое
поведение (клампы 5..600 перед swap, референс `settings_routes.py` L8893) — `{5,600}`, подтверждено в двух
шагах приёмки и psql (`discJoinDelayMin=5, discJoinDelayMax=600`).
4. **Тело с кириллицей в curl** передаётся файлом UTF-8 (`--data-binary @file`): в Windows curl конвертирует
аргумент командной строки с кириллицей в cp1251 (`[F1]` вместо UTF-8) — сервер получал невалидный UTF-8 и
падал 500 (`DecoderFallbackException` в `JsonElement.GetString`). Артефакт приёмочного скрипта, не API:
реальные клиенты (Vue) шлют UTF-8. Наблюдение: API не валидирует UTF-8 строк тела явно (вне приёмки плана).
5. **400 для не-JSON-объекта** — план жёстких ошибок тела не специфицирует («Ошибок-исключений нет» относится
к полям); 400+detail — минимальная жёсткая граница протокола (в прототипе FastAPI на такое тело — 422).
## Concerns
- `SettingsEndpoints` резолвит scoped-сервис через `RequestServices` — отклонение от стиля `AuthEndpoints`
(инъекция параметром), но там зависимость на системном `DealDbContext` и резолв вне сессии безопасен.
Альтернатива (endpoint-filter до биндинга параметров) не гарантирует порядок «фильтр до резолва DI» — не стал
полагаться на недокументированное поведение.
- Невалидный UTF-8 в строках PATCH-тела даёт 500 (STJ валидирует строки лениво, `DecoderFallbackException`
ловится не как `JsonException`). Вне acceptance; при желании — ловить в `ApplyPatchAsync`/валидировать тело.
@@ -0,0 +1,204 @@
#!/usr/bin/env sh
# Task 6 curl-приёмка POST /api/ai/check на :5080 (план Task 6 L282284, Ruling 7).
# Сценарий: 401 без куки → login admin/admin → без ключа (дефолт deepseek) → «Не задан API-ключ» →
# PATCH deepseek {baseUrl: http://127.0.0.1:59999 (недоступный порт), apiKey} → «Ошибка соединения» →
# aiProvider=ollama (локальный) → «Локальный сервер …» → SSRF-гейт: ftp-схема baseUrl → ok:false →
# logout → psql: enc: в aiConfigs. Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
CORE_DIR="C:/telbase/src/core"
API_DIR="$CORE_DIR/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task6-jar.txt"
OUT="/tmp/task6-out.txt"
LOG="/tmp/task6-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
# Приёмка начинается с «дефолтов»: таблица settings тенанта должна быть пустой (повторяемость).
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. POST /api/ai/check без куки — ожидаем 401 {\"detail\":\"Требуется авторизация\"} =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. POST /api/ai/check без ключа (дефолт: deepseek) — ok:false «Не задан API-ключ» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "без ключа" '"ok":false' '"message":"Не задан API-ключ"'
check "статус deepseek (дефолты)" '"provider":"deepseek"' '"name":"DeepSeek"'
check "base/model дефолтные" '"base":"https://api.deepseek.com"' '"model":"deepseek-v4-flash"'
check "keySet false / маска пуста" '"keySet":false' '"keyMasked":""'
check "local false" '"local":false'
echo
echo "== 4. PATCH deepseek {baseUrl: http://127.0.0.1:59999, apiKey: sk-1234567890ab} =="
echo " недоступный порт — ветка сетевого сбоя детерминирована (без внешней сети)"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"aiConfigs":{"deepseek":{"baseUrl":"http://127.0.0.1:59999","apiKey":"sk-1234567890ab"}}}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200, keySet+маска" '[HTTP:200]' '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo
echo "== 4b. POST /api/ai/check — глубокое подключение недоступно → «Ошибка соединения» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT"
echo
check "check 200" '[HTTP:200]'
check "ошибка соединения (префикс)" '"ok":false' '"message":"Ошибка соединения:'
check "base из конфигурации" '"base":"http://127.0.0.1:59999"'
check "ключ задан и замаскирован" '"keySet":true' '"keyMasked":"sk-1…90ab"'
echo
echo "== 5. PATCH aiProvider=ollama → POST /api/ai/check — локальный провайдер, ok:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"aiProvider":"ollama"}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200, aiProvider ollama" '[HTTP:200]' '"aiProvider":"ollama"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT"
echo
check "локальный провайдер ok" '[HTTP:200]' '"ok":true'
check "сообщение локального сервера" '"message":"Локальный сервер «Ollama (локально)» (ping в проде)"'
check "статус ollama" '"provider":"ollama"' '"local":true'
echo
echo "== 6. PATCH deepseek baseUrl ftp://example.com (SSRF-гейт схемы) → ok:false =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"aiProvider":"deepseek","aiConfigs":{"deepseek":{"baseUrl":"ftp://example.com"}}}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200, aiProvider deepseek" '[HTTP:200]' '"aiProvider":"deepseek"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/check" > "$OUT"
cat "$OUT"
echo
check "недопустимая схема base URL" '[HTTP:200]' '"ok":false' '"message":"Недопустимый Base URL (ожидается http/https)"'
echo
echo "== 7. POST /api/auth/logout =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
echo
echo "== 8. psql: строки settings; aiConfigs зашифрован (enc:) без открытого ключа =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
echo "--- проверка enc: ---"
ENC_KEYS=$($PSQL_BASE -t -A -c "SELECT \"Key\" FROM $SCHEMA.settings WHERE \"ValueJson\" LIKE '%enc:%';")
echo "enc: найдено в: $ENC_KEYS"
if echo "$ENC_KEYS" | grep -q "aiConfigs"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] aiConfigs хранит ключ в формате enc:"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] aiConfigs без enc:"
fi
if $PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'aiConfigs';" | grep -qF "sk-1234567890ab"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] открытый ключ sk-1234567890ab найден в БД"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] открытого ключа в БД нет (только enc:)"
fi
echo
echo "== 9. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,106 @@
# Task 6 — «ИИ-провайдеры и POST /api/ai/check (проверка подключения)» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; unit-тесты **98/98 PASS** (+10 новых
`AiConnectionCheckerTests`); curl-приёмка на :5080 — **PASS=23 FAIL=0** (скрипт
`task-6-curl-acceptance.sh`, лог `task-6-curl-acceptance.log`). Отчёт по плану
`docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 6 L265285, Rulings 4/7/8;
референс `settings_routes.py` L195219, `ai.py` L36–58). После прогона dev-БД очищена, порт :5080
свободен.
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs` | interface | Модульный порт (Ruling 4): `Task<AiCheckResultDto> CheckAsync(AiCheckRequest, ct)`. |
| `src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs` | record | `{ProviderId, BaseUrl, Model, ApiKey, IsLocal, ApiStyle}` (поля — по плану Task 6). |
| `src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs` | record | `{Ok, Message, Provider, Name, Base, Model, Local, KeySet, KeyMasked}` (наружу camelCase). |
| `src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs` | class | HTTP-реализация (Ruling 7): ветки 1:1 с `ai_check`, таймаут 12 с, маска `mask_key`. |
| `src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs` | class | `MapAiCheckEndpoint` → POST `/api/ai/check`; 401-гейт; резолв RequestServices; чтение активного конфига провайдера (`ISettingsStore`+`ISecretCipher`). |
| `src/core/Deal.Api/Http/EndpointResults.cs` | class | Общий HTTP-хелпер 401/400 `{detail}` (note T5-ревью «при 3-м Endpoints-файле»); Auth/Settings эндпоинты переведены на него. |
| `src/core/Deal.Api/Program.cs` | modify | `AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(12 с)`; `app.MapAiCheckEndpoint()`. |
| `src/core/tests/Deal.Tests.Unit/AiConnectionCheckerTests.cs` | test | Ветки: без ключа; local; 200 (+URL/Bearer/маска/статус); 401/403; HTTP 500; сетевая ошибка; Anthropic `/v1/models`+`x-api-key`; SSRF-гейты (неизвестный провайдер, не-http(s) base). |
| `src/core/tests/Deal.Tests.Unit/StubHttpMessageHandler.cs` | test-double | Fake `HttpMessageHandler` (запись запросов, ответ/исключение по делегату). |
| `.superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh`/`.log` | sh/log | curl-приёмка (23 проверки). |
## Решения
1. **Дизайн порта (Ruling 4).** `IAiConnectionChecker` — в модуле Settings (Application), потребляется
только Settings-экраном; `IAiFacade` не заводится. Реализация — в Infrastructure/Integrations,
ctor принимает `HttpClient`; DI — typed client `AddHttpClient<IAiConnectionChecker,
AiConnectionChecker>` в `Program.cs` (фабрика, таймаут `RequestTimeoutSeconds = 12` с). Unit-тесты
строят адаптер напрямую `new AiConnectionChecker(new HttpClient(stub))`.
2. **Ветки 1:1** с `settings_routes.py` L195–219 (порядок кода прототипа: **локальный → ключ → HTTP**;
иначе приёмка ollama без ключа дала бы «Не задан API-ключ» вместо «Локальный сервер…»):
- локальный → `ok:true` «Локальный сервер «<name>» (ping в проде)» (HTTP не ходим);
- нет ключа → `ok:false` «Не задан API-ключ»;
- HTTP `<400` → «Подключение успешно»; `401/403` → «Ключ не принят (HTTP n) — проверьте ключ и
доступ к модели»; иное (≥400) → «HTTP n — проверьте Base URL и модель»; таймаут/сеть →
«Ошибка соединения: …». URL: `{base}/models`, Anthropic — `{base}/v1/models` + `x-api-key` +
`anthropic-version` (Bearer для OpenAI-совместимых). `base` rstrip("/") как в прототипе.
3. **`keyMasked` — маска `ai.py` mask_key L5358** (источник Ruling 7 указывает на mask_key): пусто →
`""`, len ≤ 8 → «x…», иначе «1234…5678». Отличие от маски Ruling 3 (len ≤ 8 — как есть) только для
ключей ≤ 8 симв.; PATCH принимает ключи ≥ 8 — на практике расхождения нет (зафиксировано).
4. **SSRF (см. preflight):** реализован allowlist **провайдеров** — checker отклоняет id вне
фиксированного каталога `AiProviders` (ok:false «Провайдер не из списка разрешённых», HTTP не
выполняется; в штатном потоке недостижимо — PATCH-гейт `aiProvider`/`aiConfigs`) + гейт схемы base
URL: только абсолютный `http/https` (иначе ok:false «Недопустимый Base URL (ожидается http/https)»).
Host-level рестрикции (совпадение baseUrl с дефолтом провайдера) **сознательно не вводил**: это
сломало бы локальные серверы на LAN (Ollama/LM Studio), кастомные OpenAI-совместимые эндпоинты и
ветку приёмки плана «недоступный хост → Ошибка соединения» (требует произвольного http(s)-хоста в
конфиге). Триггер — только авторизованный владелец настроек; запрос — один GET /models.
Продуктовая жёсткость (egress-фильтр/разрешённые хосты, проверка private-IP) — зафиксирована на
прод-этап (за Cloudflare/шлюзом). Решение зафиксировано в `AiConnectionChecker` (XML-doc).
5. **Эндпоинт без тела** (фронт `store.js checkAiConnection``POST /api/ai/check` без body): сервер
читает **активную** конфигурацию (aiProvider + aiConfigs) из `ISettingsStore` и расшифровывает ключ
`ISecretCipher` (1:1 с `ai_svc._cfg()`), как предписано планом; сборка запроса — приватный хелпер
эндпоинта (дефолты `SettingsDefaults` + переопределения). Резолв scoped-зависимостей — через
`RequestServices` **после** 401-гейта (паттерн SettingsEndpoints: иначе запрос без сессии — 500).
6. **Общий HTTP-хелпер `EndpointResults`** (401/400 `{detail}`): третья Endpoints-файла — по заметке
ревью T5; AuthEndpoints/SettingsEndpoints переведены, поведение не менялось (curl-приёмка T5-шагов
воспроизводится в логе T6 шагов 1–2).
## Отклонения от задания/плана
- Имя файла эндпоинта: задание задачи говорит `Endpoints/AiEndpoints.cs`, план (Task 6 Files) —
`AiCheckEndpoint.cs`. **План приоритетнее** — файл `AiCheckEndpoint.cs`, метод `MapAiCheckEndpoint`.
- Файл `RatesEndpoints`/`AiCheckEndpoint` tag: `/ai/check` в прототипе живёт в роутере settings
(tags=["settings"]) — tag `settings` (как в api-map §3.4).
- В `progress.md` preflight T6/T7 отмечен SSRF-риск — закрыт в объёме dev-режима (см. п.4).
## Проверки
```
dotnet build Deal.sln → Предупреждений: 0, Ошибок: 0
dotnet test tests/Deal.Tests.Unit → всего: 98; сбой: 0; успешно: 98
sh .superpowers/sdd/deal-stage2-settings/task-6-curl-acceptance.sh
→ PASS=23 FAIL=0 (лог task-6-curl-acceptance.log)
```
### curl-приёмка (сценарий и фактические ответы — в логе)
1. `POST /api/ai/check` без куки → **401** `{"detail":"Требуется авторизация"}`.
2. login admin/admin → без ключа (дефолт deepseek) → **200** `{"ok":false,"message":"Не задан
API-ключ","provider":"deepseek","name":"DeepSeek","base":"https://api.deepseek.com",
"model":"deepseek-v4-flash","local":false,"keySet":false,"keyMasked":""}`.
3. `PATCH aiConfigs.deepseek {baseUrl:"http://127.0.0.1:59999", apiKey:"sk-1234567890ab"}` (недоступный
порт — сетевой сбой детерминирован, без внешней сети) → check → **200** `{"ok":false,"message":
"Ошибка соединения: Подключение не установлено, т.к. конечный компьютер отверг запрос на
подключение. (127.0.0.1:59999)",…,"base":"http://127.0.0.1:59999","keySet":true,
"keyMasked":"sk-1…90ab"}`.
4. `PATCH aiProvider=ollama` → check → **200** `{"ok":true,"message":"Локальный сервер «Ollama
(локально)» (ping в проде)","provider":"ollama",…,"local":true}`.
5. `PATCH aiProvider=deepseek + baseUrl:"ftp://example.com"` → check → **200** ok:false
«Недопустимый Base URL (ожидается http/https)» (SSRF-гейт схемы).
6. logout 200. psql до очистки: строки `aiConfigs`/`aiProvider`; apiKey в `aiConfigs` — `enc:…`,
открытого ключа в БД нет. После прогона таблица `settings` очищена (0 строк), процесс остановлен.
## Concerns
- Модуль не узнаёт имя провайдера из запроса: checker резолвит `Name` по каталогу `AiProviders`
(в запросе имени нет — поля DTO заданы планом). Для неизвестного id (ручное вмешательство в БД)
`Name` = id; в штатном потоке недостижимо.
- Ветка таймаута не покрыта unit-тестом (требует реальной задержки); проверена логикой
`catch (OperationCanceledException) when (!ct.IsCancellationRequested)`. «Ошибка соединения» на
timeout соответствует прототипу (ловит все исключения).
- Расхождение маски короткого ключа (≤8) между `/api/ai/check` (mask_key: «x…») и GET/PATCH settings
(Ruling 3: как есть) — см. п.3 «Решений»; влияет только на ключи ≤ 8 симв., которых PATCH не создаёт.
@@ -0,0 +1,204 @@
#!/usr/bin/env sh
# Task 7 curl-приёмка границы промптов на :5080 (план Task 7 L303306).
# Сценарий: 401 без куки → login admin/admin → GET: дефолтный aiPrompt с {domain}/{keywords},
# myPrompts пуст → PATCH aiPrompt с плейсхолдерами → GET возвращает тот же текст →
# PATCH myPrompts 3 записи (id/name/description/prompt) → GET отдаёт их (camelCase) →
# psql: строки settings → logout → GET после logout = 401. Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task7-jar.txt"
OUT="/tmp/task7-out.txt"
LOG="/tmp/task7-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PROMPT_AI="ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"
PAY_AI="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-ai.json"
PAY_MY="C:/telbase/.superpowers/sdd/deal-stage2-settings/task-7-patch-myprompts.json"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
check_count() {
# $1 — описание; $2 — ожидаемое число; $3 — подстрока (по одному совпадению на элемент)
desc=$1
expected=$2
pattern=$3
actual=$(grep -o -F -- "$pattern" "$OUT" | wc -l)
if [ "$actual" = "$expected" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (нашлось $actual)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — ожидалось $expected, нашлось $actual"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/settings без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. GET /api/settings — дефолты промптов из data.js, myPrompts пуст =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET 200" '[HTTP:200]'
check "дефолтный aiPrompt (маркеры data.js)" '"aiPrompt":"Ты — классификатор входящих сообщений' '{domain}' '{keywords}'
check "myPrompts пуст" '"myPrompts":[]'
echo
echo "== 4. PATCH aiPrompt с плейсхолдерами {domain}/{keywords} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @"$PAY_AI" > "$OUT"
cat "$OUT"
echo
check "PATCH 200, текст на месте" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
echo
echo "== 4b. GET /api/settings — тот же текст промпта (с плейсхолдерами как есть) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET 200, тот же текст" '[HTTP:200]' "\"aiPrompt\":\"$PROMPT_AI\""
echo
echo "== 5. PATCH myPrompts — 3 записи (id/name/description/prompt) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data-binary @"$PAY_MY" > "$OUT"
cat "$OUT"
echo
check "PATCH 200" '[HTTP:200]'
check "все три записи в ответе" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
check "поля camelCase" '"name":"Кровля — строгий"' '"description":"Только явные заказы на кровлю"'
check "текст промпта доехал" '"prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"'
echo
echo "== 5b. GET /api/settings — «Мои промпты» на месте =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET 200" '[HTTP:200]'
check "три записи" '"id":"pp_aaaa1111"' '"id":"pp_bbbb2222"' '"id":"pp_cccc3333"'
check_count "myPrompts содержит 3 элемента" 3 '"id":"pp_'
check "описания на месте" '"description":"Вакансии разработчиков"'
echo
echo "== 6. psql: строки settings (aiPrompt, myPrompts) =="
$PSQL_BASE -c "SELECT \"Key\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
MYPROMPTS_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'myPrompts';")
echo "myPrompts value_json: $MYPROMPTS_JSON"
if echo "$MYPROMPTS_JSON" | grep -q "pp_aaaa1111"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] myPrompts сохранены в KV settings"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] myPrompts не найдены в KV settings"
fi
echo
echo "== 7. POST /api/auth/logout, затем GET — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 8. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1 @@
{"aiPrompt":"ТЕСТ-ПРОМПТ-7: классифицируй {domain} по маркерам {keywords} для кровли"}
@@ -0,0 +1,5 @@
{"myPrompts":[
{"id":"pp_aaaa1111","name":"Кровля — строгий","description":"Только явные заказы на кровлю","prompt":"Ты — классификатор кровли: {domain}, маркеры: {keywords}"},
{"id":"pp_bbbb2222","name":"Дизайн — гибкий","description":"","prompt":"Ты — классификатор дизайна: {domain}, маркеры: {keywords}"},
{"id":"pp_cccc3333","name":"Найм IT","description":"Вакансии разработчиков","prompt":"Ты — классификатор найма IT: {domain}, маркеры: {keywords}"}
]}
@@ -0,0 +1,52 @@
# Task 7 — «Промпты и „Мои промпты“: интеграционная проверка границы с фронтом» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 113/113 PASS (было 98, добавлено 15 — `PromptDefaultsTests`); curl-приёмка :5080 — PASS=19 FAIL=0.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 7 L287308, Ruling 8; референс `ai.py` L6377).
## Граница «библиотека промптов — фронт» (проверка кода, новых эндпоинтов НЕТ)
- Библиотека промптов **полностью фронтовая**: `data.js` `PROMPT_LIBRARY` L180200 (19 шаблонов, категории L167–176, готовые тексты вариантов поведения L155–165) + `buildClassifierPrompt` L148151. `PromptLibraryModal.vue` НЕ ходит в API: поиск/категории/превью — локальные `computed`; «Применить в редактор» = `emit('apply')``SettingsView.onApplyLib` пишет только в локальный `state.aiPrompt` (сохранение — отдельной кнопкой «Сохранить промпт» → PATCH `/api/settings`). «В Мои промпты» = `store.addMyPrompt``persistMyPrompts` → PATCH `/api/settings` `{myPrompts}`.
- Эндпоинтов `/api/prompts*` в репозитории нет (grep по docs/src — 0 совпадений; api.js — тонкий http-клиент, все пути инлайном в store.js/views). Наружу за границу выходят только строки-промпты (`aiPrompt`/`cardPrompt`/`aiFilterPrompt`) и `myPrompts` — через GET/PATCH `/api/settings`. Ruling 8 подтверждена.
- Формат «Моих промптов» 1:1: фронт шлёт `{id: "pp_…", name, description, prompt}` (store.js L16911705), бэк — `MyPromptDto(Id, Name, Description, Prompt)` → наружу camelCase `id/name/description/prompt`.
## Сверка DefaultPrompts с data.js (построчно, включая хвостовые \n)
Полная сверка выполнена скриптом `task-7-sverka-prompts.mjs` (извлекает шаблоны из data.js и raw-литералы DefaultPrompts.cs, нормализует `\r\n→\n` и `TrimEnd('\n')`) и **закреплена тестом** `PromptDefaultsTests` (читает data.js из репозитория, сравнивает построчно с сообщением о расхождении).
| Промпт | data.js | DefaultPrompts.cs | Итог |
|---|---|---|---|
| `aiPrompt` (DEFAULT_AI_PROMPT L94114) | 21 строка, 2858 симв., без хвостового `\n` | 21 строка, 2858 симв. | идентичны |
| `cardPrompt` (DEFAULT_AI_CARD_PROMPT L116123) | 8 строк, 1363 симв. | 8 строк, 1363 симв. | идентичны |
| `aiFilterPrompt` (DEFAULT_AI_FILTER_PROMPT L125141) | 17 строк, 686 симв. | 17 строк, 686 симв. | идентичны |
**Расхождений с data.js не найдено — правки DefaultPrompts.cs не потребовались.** В `constants.py` L63–141 те же тексты, но с расхождениями формулировок и разбивки на строки (напр., пункт «4. title»: constants.py L7980 — «без эмодзи, хэштегов и знаков препинания», а в data.js L103 — «без эмодзи, хэштегов, markdown-разметки…»); источник дефолтов — data.js (фронт), поэтому дефолты не менялись.
## Изменения
| Файл | Тип | Содержание |
|---|---|---|
| `src/core/Deal.Modules.Settings/Application/PromptFiller.cs` | create | Подстановка `{domain}`/`{keywords}` в текст промпта (чистая функция, аналог `ai.fill_prompt` L6377): пустой domain → фраза-фолбэк `FallbackDomain`; keywords — trim/отбрасывание пустых, склейка «, », срез ≤60 (`MaxKeywords`); пустые keywords → `NoKeywordsHint`; пустой prompt → `""`. Читает значения из аргументов (не из хранилища) — чтение `domainDescription`/`domainKeywords` остаётся за вызывающей стороной (этап 6). |
| `tests/Deal.Tests.Unit/PromptDefaultsTests.cs` | create | 15 тестов: маркеры из data.js («Ты — классификатор входящих сообщений», «О заявке», «страж входящих», `{domain}`/`{keywords}`); построчная сверка трёх дефолтов с data.js (граница 1:1); семантика fill-подстановки (фолбэк domain, склейка keywords, ≤60, trim, замена всех вхождений, пустой prompt). |
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-7-curl-acceptance.sh/.log; тела — UTF-8-файлы *.json)
1. GET без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → дефолты: `aiPrompt` из data.js (маркеры и `{domain}`/`{keywords}`), `myPrompts:[]`.
3. PATCH `aiPrompt` с плейсхолдерами → GET возвращает **тот же текст** (плейсхолдеры как есть).
4. PATCH `myPrompts` 3 записи (`pp_aaaa1111`, `pp_bbbb2222`, `pp_cccc3333`; id/name/description/prompt, в т.ч. пустой description) → ответ и GET содержат все 3 (camelCase); psql: строки `aiPrompt`/`myPrompts` в `settings`, `value_json` с записями.
5. logout → GET снова 401. Итог: **PASS=19 FAIL=0**; dev-БД очищена, сервер остановлен.
## Concerns / замечания
1. **Git-Bash + curl.exe (Windows)**: кириллица в аргументах `curl -d '…'` перекодируется в cp1251 → сервер отвечал HTTP 500 (invalid UTF-8). В приёмочном скрипте JSON-тела передаются из UTF-8-файлов (`--data-binary @file`) — для последующих задач держать в уме.
2. Тест `PromptDefaultsTests` читает `data.js` (путь от папки с `Deal.sln`): перенос фронта сломает тест с понятным сообщением — это намеренная интеграционная сверка 1:1 границы (Task 2 откладывал полное сравнение строк именно на Task 7).
3. `PromptFiller` — осознанно чистая функция с параметрами `(prompt, domain, keywords)`, в отличие от `ai.py fill_prompt`, который сам читает store: модуль Settings не получает зависимость на хранилище внутри утилиты (YAGNI до этапа 6).
## Проверки
```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 113, пройдено 113, пропущено 0
node task-7-sverka-prompts.mjs → ИТОГ: 3/3 совпали (построчно)
sh task-7-curl-acceptance.sh → PASS=19 FAIL=0 (лог task-7-curl-acceptance.log)
```
@@ -0,0 +1,71 @@
// Сверка текстов DefaultPrompts.cs (C#) с data.js (фронт — высший авторитет, план L47).
// Извлекает три DEFAULT_* из data.js (между обратными кавычками шаблона) и тела raw-string
// литералов DefaultPrompts.cs, нормализует \r\n→\n и завершающие \n (семантика Normalize),
// сравнивает построчно и печатает расхождения. Запуск: node task-7-sverka-prompts.mjs
import { readFileSync } from 'node:fs'
const DATA_JS = 'C:/telbase/src/frontend/src/data.js'
const CS_FILE = 'C:/telbase/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs'
const PAIRS = [
['DEFAULT_AI_PROMPT', 'DefaultAiPrompt'],
['DEFAULT_AI_CARD_PROMPT', 'DefaultCardPrompt'],
['DEFAULT_AI_FILTER_PROMPT', 'DefaultAiFilterPrompt'],
]
function extractDataJs(source, exportName) {
const re = new RegExp('export const ' + exportName + ' = `([\\s\\S]*?)`', 'm')
const m = source.match(re)
if (!m) throw new Error('data.js: блок ' + exportName + ' не найден')
return m[1].replace(/\r\n/g, '\n')
}
function extractCs(source, propName) {
const normalized = source.replace(/\r\n/g, '\n')
const re = new RegExp(
'public static readonly string ' + propName + ' = Normalize\\(\\s*"""\\n([\\s\\S]*?)\\n(\\s*)"""\\);',
)
const m = normalized.match(re)
if (!m) throw new Error('DefaultPrompts.cs: блок ' + propName + ' не найден')
const indent = m[2]
const lines = m[1].split('\n').map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
let text = lines.join('\n').replace(/\r/g, '')
text = text.replace(/\n+$/, '') // TrimEnd('\n') как в Normalize
return text
}
const js = readFileSync(DATA_JS, 'utf8')
const cs = readFileSync(CS_FILE, 'utf8')
let totalFail = 0
for (const [jsName, csProp] of PAIRS) {
const expected = extractDataJs(js, jsName)
const actual = extractCs(cs, csProp)
const expLines = expected.split('\n')
const actLines = actual.split('\n')
console.log(`=== ${jsName} <-> ${csProp} ===`)
console.log(` data.js: ${expLines.length} строк, ${expected.length} симв.`)
console.log(` C#: ${actLines.length} строк, ${actual.length} симв.`)
console.log(` хвостовой \\n: data.js=${expected.endsWith('\n')} C#=${actual.endsWith('\n')}`)
if (expected === actual) {
console.log(' [OK] тексты идентичны')
continue
}
totalFail++
console.log(' [DIFF] есть расхождения:')
const n = Math.max(expLines.length, actLines.length)
for (let i = 0; i < n; i++) {
const e = expLines[i] ?? '<нет строки>'
const a = actLines[i] ?? '<нет строки>'
if (e !== a) {
console.log(` строка ${i + 1}:`)
console.log(` data.js: ${JSON.stringify(e)}`)
console.log(` C#: ${JSON.stringify(a)}`)
}
}
}
console.log(totalFail === 0 ? '\nИТОГ: 3/3 совпали' : `\nИТОГ: расхождений в ${totalFail} блоках`)
process.exit(totalFail === 0 ? 0 : 1)
@@ -0,0 +1,221 @@
#!/usr/bin/env sh
# Task 8 curl-приёмка /api/rates* на :5080 (план Task 8 L329331; Ruling 6).
# Сценарий: 401 без куки → login admin/admin → GET /api/rates (нет кэша: source mock, updatedAt null) →
# GET /api/settings не содержит внутренний ratesCache → PATCH {"rateSource":"mock"} →
# POST /api/rates/refresh → {ok:true, rates.source mock, updatedAt ms} → GET /api/rates — тот же кэш →
# psql: строка ratesCache {rates, source, updatedAtMs} → реальный cbr (PATCH + refresh → ok:true, source cbr)
# → logout → GET 401. Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task8-jar.txt"
OUT="/tmp/task8-out.txt"
LOG="/tmp/task8-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
check_not() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1
pattern=$2
if grep -qF -- "$pattern" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не ожидалось: $pattern"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pattern)"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/rates без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. GET /api/rates — кэша нет: мок-курсы, source mock, updatedAt null =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "GET 200, дефолт-мок" '[HTTP:200]' '"base":"RUB"' '"source":"mock"' '"updatedAt":null'
check "курсы на месте" '"RUB":1' '"USD":92.5' '"EUR":99.9' '"USDT":92.5'
# GET при пустом кэше и rateSource=cbr (дефолт) мог запустить фоновый refresh ЦБ — даём ему завершиться,
# чтобы он не перетёр мок-кэш следующих шагов (интернет в окружении есть).
echo "== 3b. Пауза 4 с: фоновый refresh (если запустился) успевает завершиться =="
sleep 4
echo
echo "== 4. GET /api/settings — внутренний ключ ratesCache НЕ публикуется =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
cat "$OUT"
echo
check "GET settings 200" '[HTTP:200]'
check_not "нет ratesCache в public-снимке" 'ratesCache'
check "rateSource в снимке есть (дефолт/текущий)" '"rateSource":"'
echo
echo "== 5. PATCH {\"rateSource\":\"mock\"} (фоновый refresh по Ruling 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200, rateSource=mock" '[HTTP:200]' '"rateSource":"mock"'
echo
echo "== 6. POST /api/rates/refresh — ok:true, мок-кэш с updatedAt =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
cat "$OUT"
echo
check "refresh 200 ok:true" '[HTTP:200]' '"ok":true'
check "rates.source mock, base RUB, курсы" '"source":"mock"' '"base":"RUB"' '"RUB":1' '"USD":92.5'
check_not "updatedAt не null после refresh" '"updatedAt":null'
echo
echo "== 7. GET /api/rates — тот же кэш (source mock, updatedAt на месте) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "GET 200, тот же кэш" '[HTTP:200]' '"source":"mock"' '"base":"RUB"' '"USD":92.5'
check_not "updatedAt не null (кэш сохранён)" '"updatedAt":null'
echo
echo "== 8. psql: строка ratesCache в settings (внутренний KV-ключ) =="
$PSQL_BASE -c "SELECT \"Key\", \"ValueJson\", \"UpdatedAt\" FROM $SCHEMA.settings ORDER BY \"Key\";"
CACHE_JSON=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM $SCHEMA.settings WHERE \"Key\" = 'ratesCache';")
echo "ratesCache value_json: $CACHE_JSON"
if echo "$CACHE_JSON" | grep -q '"updatedAtMs"' && echo "$CACHE_JSON" | grep -q '"source":"mock"'; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ratesCache сохранён как {rates, source, updatedAtMs}"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] ratesCache не в форме Ruling 6: $CACHE_JSON"
fi
echo
echo "== 9. Реальный источник cbr: PATCH rateSource=cbr + POST refresh =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"cbr"}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200, rateSource=cbr" '[HTTP:200]' '"rateSource":"cbr"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
cat "$OUT"
echo
check "refresh cbr 200 ok:true (ЦБ доступен)" '[HTTP:200]' '"ok":true'
check "rates.source cbr, base RUB" '"source":"cbr"' '"base":"RUB"'
check_not "updatedAt не null после cbr-refresh" '"updatedAt":null'
echo
echo "== 9b. GET /api/rates — кэш cbr на месте =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "GET 200, source cbr" '[HTTP:200]' '"source":"cbr"' '"base":"RUB"'
echo
echo "== 10. POST /api/auth/logout, затем GET /api/rates — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
cat "$OUT"
echo
check "после logout 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 11. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,62 @@
# Task 8 — «Курсы валют: сервис, кэш, эндпоинты /api/rates*» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 148/148 PASS (было 113, добавлено 35: 26 — `RatesServiceTests`, 8 — `CbrRateSourceTests`, 1 — `FakeRatesSource` хелпер в общем счёте); curl-приёмка :5080 — PASS=22 FAIL=0 (включая реальный запрос к ЦБ РФ).
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 8 L308331, Ruling 6 L8389, Ruling 1; референс `rates.py` целиком, `constants.py` L4152).
## Реализация
| Файл | Тип | Содержание |
|---|---|---|
| `Deal.Modules.Settings/Application/IRatesSource.cs` | create | Порт источника: `Task<Dictionary<string,double>?> FetchAsync(ct)` — курсы к RUB, null при сбое (план L311). |
| `Deal.Modules.Settings/Application/Models/RatesDto.cs` | create | `RatesDto(Base, Rates, Source, UpdatedAtMs?)` — тело GET /rates и `rates.rates` refresh (план L312). Wire 1:1 с прототипом: поле сериализуется как `updatedAt` (`[JsonPropertyName]`) — фронт читает `r.updatedAt` (store.js L408, `applyRates`). |
| `Deal.Modules.Settings/Application/Models/RatesCacheValue.cs` | create | Форма KV-значения `ratesCache` = `{rates, source, updatedAtMs}` 1:1 Ruling 6 L83 (не публичный ключ, Ruling 1). |
| `Deal.Modules.Settings/Application/RatesService.cs` | create | `GetAsync` (кэш; нет кэша → дефолт MockRates/source `"mock"`/updatedAt null); `RefreshAsync` (source из настройки: mock → сохранить MockRates без порта; cbr/иное → `IRatesSource`, неуспех → false, кэш не тронут); `ShouldFetchAsync` (нет кэша / смена источника / ≥6 ч); статический чистый `ConvertAmount(amount, from, to, rates)` — USDT=USD, отсутствующая валюта → null, round 2 (L86103). Повреждённые строки хранилища → мягкий дефолт (как SettingsService). |
| `Deal.Infrastructure/Integrations/CbrRateSource.cs` | create | HTTP GET `https://www.cbr-xml-daily.ru/daily_json.js` (фиксированный URL — SSRF-allowlist, тенант не управляет адресом), `Valute[code].Value/Nominal` (Nominal>1: 100 KZT), `RUB:1`, round 6; таймаут 15 с; любой сбой → null + warning (лог). |
| `Deal.Api/RatesRefreshScheduler.cs` | create | Фоновый refresh ВНЕ запроса (отдельный scope через `IServiceScopeFactory` + in-flight guard): PATCH rateSource и ленивый GET не спамят ЦБ и не наследуют disposal запросного scope. Тенант пробрасывается через ExecutionContext (AsyncLocal ITenantContext). |
| `Deal.Api/Endpoints/RatesEndpoints.cs` | create | `MapRatesEndpoints`: GET `/api/rates` → RatesDto (при ShouldFetch — фоновый запуск RefreshAsync, ответ — текущий кэш); POST `/api/rates/refresh``{ok, rates}` (ok=false при сбое cbr; mock — true). 401-гейт {detail}, резолв через RequestServices после гейта (паттерн SettingsEndpoints/AiCheck). |
| `Deal.Api/Endpoints/SettingsEndpoints.cs` | modify | PATCH с полем `rateSource``RatesRefreshScheduler.Schedule()` (1:1 L188189: `if body.get("rateSource")`); ссылка на Task 8 в доке класса. |
| `Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs` | modify | `AddScoped<RatesService>()`. |
| `Deal.Api/Program.cs` | modify | `app.MapRatesEndpoints()`; `AddHttpClient<IRatesSource, CbrRateSource>` (таймаут 15 с; typed client — как IAiConnectionChecker Task 6); `AddSingleton<RatesRefreshScheduler>()`. |
| `tests/…/FakeRatesSource.cs`, `RatesServiceTests.cs`, `CbrRateSourceTests.cs` | create | 35 тестов (детали ниже). |
## Границы и решения
- **RateSource по умолчанию — `"cbr"`** (SettingsDefaults L128/constants.py L226, НЕ "mock"). Дефолт ОТВЕТА при пустом кэше — `source:"mock"` + мок-курсы + updatedAt null (как `get_rates()` rates.py L23–31 без строки; план Task 8 L313–314). Это подтверждено curl: первый GET на чистой БД отдал мок.
- **ShouldFetch «смена источника» — симметрично** (кэш mock/настройка cbr И кэш cbr/настройка mock; rates.py проверяет только mock→cbr): план L315 «смена источника» + Ruling 6 «лениво на GET при … смене источника»; значение настройки нормализуется к mock|cbr (не-mock → cbr), иначе произвольная строка в rateSource вызывала бы fetch на каждый GET.
- **PATCH-хук** (L188) живёт в HTTP-слое SettingsEndpoints, как и предписывает докласс SettingsService («побочные эффекты L186–192 выполняются HTTP-слоем — Task 8»). Files-список Task 8 его не называет, но Ruling 6 требует — расхождение плана с самим собой зафиксировано здесь; реализовано по Ruling.
- **Фоновые обновления** — fire-and-forget НЕ на запросном scope: `RatesRefreshScheduler` (Api, singleton) создаёт собственный scope (scoped ISettingsStore/TenantDbContext живут, пока идёт HTTP к ЦБ ≤15 с) и держит in-flight guard (1 одновременный refresh на процесс — не спамим ЦБ при частых GET; межтенантный дебаунс осознан: refresh редкий). IHostedService не понадобился — план Task 8 его не требует (Files-список без hosted-сервисов).
- **SSRF**: cbr-URL — фиксированная константа адаптера (allowlist); перенаправления — дефолтные (как httpx в python). Тенант не управляет адресом.
- **Фронт**: GET /api/rates (boot store.js L571581), POST /api/rates/refresh (кнопка «Обновить курсы», refreshRates L18431848), PATCH rateSource (schedulePersist) — всё с вкладки «Валюта и курсы» SettingsView; Ruling 8 «только для Settings-экрана» подтверждена, эндпоинты фронтом используются (пункт 4 задания).
- **`ConvertAmount`** — чистая функция с параметром `rates` (Ruling 6: в этапе 2 только чистый ConvertAmount; пересчёт карточек — этап 3).
## Тесты (35 новых; всего 148 PASS)
- `RatesServiceTests` (26): GetAsync — нет кэша (мок/source mock/updatedAt null), кэш, повреждённый кэш → дефолт; Refresh — mock без вызова порта, cbr успех, cbr сбой (false, кэш не тронут), дефолт source cbr, неизвестная настройка → cbr; ShouldFetch — нет кэша, смена источника (обе стороны), свежий кэш (false), ≥6 ч (граница включительно), <6 ч (false), «garbage»-настройка не вызывает fetch на каждый GET, повреждённый кэш; ConvertAmount — USD→RUB, USDT=USD (обе стороны), null-сумма, отсутствующая валюта (from/to), USDT без USD в курсах → собственный курс, cross-currency round 2; wire-формат RatesDto (ключи `base/rates/source/updatedAt`, null updatedAt).
- `CbrRateSourceTests` (8): парсинг образца daily_json.js (USD/EUR номинал 1, KZT номинал 100 → 0.19; RUB:1 добавлен), round 6, фиксированный URL; ветки сбоя → null: HTTP 500, сетевой сбой, не-JSON, нет объекта Valute, повреждённая запись (Value "abc"); Nominal=0 → 1.
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-8-curl-acceptance.sh/.log)
1. GET /rates без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → GET /rates на чистой БД: `{"base":"RUB","rates":{RUB:1,USD:92.5,…,USDT:92.5},"source":"mock","updatedAt":null}`.
3. GET /settings: ключа `ratesCache` в public-снимке НЕТ (Ruling 1); `rateSource` на месте.
4. PATCH `{"rateSource":"mock"}` → 200 `"rateSource":"mock"`.
5. POST /rates/refresh → `{"ok":true,"rates":{…,"source":"mock","updatedAt":1788660320268}}`; GET /rates — тот же кэш (updatedAt тот же).
6. psql: строка `ratesCache` в settings: `{"rates":{…},"source":"mock","updatedAtMs":1788660320268}` (форма Ruling 6).
7. Реальный ЦБ (интернет в окружении есть): PATCH `{"rateSource":"cbr"}` + POST /rates/refresh → `ok:true`, `source:"cbr"`, `updatedAt` (курсы ЦБ: USD 86.5857, EUR 100.5693, 55+ валют, KZT 0.189939…); GET /rates — кэш cbr на месте.
8. logout → GET /rates = 401. Итог: **PASS=22 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания
1. **Wire-нотация маленьких курсов**: реальный ЦБ-кэш содержит `"IRR":5.4E-05` (STJ-сериализация double в экспоненте с верхним E; python json.dumps пишет `5.4e-05`). Оба — валидный JSON, фронтовый `JSON.parse`/`toLocaleString` корректен — косметическое расхождение с прототипом.
2. **Запись mock-кэша ставит updatedAt = now**: после первого refresh `updatedAt` не null даже в mock-режиме — это 1:1 с python `save_rates` (acceptance L330 ожидает `updatedAt:<ms>`); фронт показывает «мок-курсы» только когда кэша ещё не было.
3. **Typed client `AddHttpClient<IRatesSource, CbrRateSource>`** регистрируется transient (как IAiConnectionChecker, Task 6), план писал «scoped»: внутри scope запроса/фоновой работы поведение эквивалентно (клиент живёт в рамках scope, сбой-безопасно); расхождение формулировок зафиксировано.
4. **Межтенантный дебаунс** фонового refresh (1 на процесс) осознан: обновление редкое (≤1/6 ч на тенант), худший случай — отложенный на секунды refresh второго тенанта.
5. 401-ветки эндпоинтов проверены curl (без куки/после logout), юнит-инфраструктуры хостинга Api в проекте нет (как и для прошлых задач) — ветки тела ответа покрыты на уровне сервисов + wire-тест RatesDto.
## Проверки
```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 148, пройдено 148, пропущено 0 (было 113, +35)
sh task-8-curl-acceptance.sh → PASS=22 FAIL=0 (лог task-8-curl-acceptance.log)
```
@@ -0,0 +1,239 @@
#!/usr/bin/env sh
# Task 9 curl-приёмка /api/ml* на :5080 (план Task 9 L361-365; Ruling 5/8; ml_routes.py).
# Сценарий: 401 без куки → login admin/admin → GET /api/ml/status (форма §4.10 L363: reachable:true,
# service.ready:false, eval обнулён, stats.outbox:0) → PATCH mlEnabled=false → статус enabled:false →
# PATCH true → predict {"text":"x"} → 400 «Введите текст» → predict с текстом (UTF-8 файл) →
# take:false/label:null/scores:{} → reset {ok:true} → candidates {items:[]} → apply 404
# «Исходное сообщение не найдено» → psql: нет таблиц ml_outbox/learning_log → logout → 401.
# Вывод всех шагов в stdout.
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
PREDICT_BODY="$SCRIPT_DIR/task-9-predict.json"
JAR="/tmp/task9-jar.txt"
OUT="/tmp/task9-out.txt"
LOG="/tmp/task9-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
check_not() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в ответе
desc=$1
pattern=$2
if grep -qF -- "$pattern" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не ожидалось: $pattern"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (отсутствует: $pattern)"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка таблицы settings дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings пуста"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo " [FAIL] сервер не поднялся за 30 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/ml/status без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. GET /api/ml/status — полная форма §4.10 (заглушка: reachable:true, ready:false) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT"
echo
check "status 200" '[HTTP:200]'
check "enabled:true (дефолт mlEnabled)" '"enabled":true'
check "reachable:true" '"reachable":true'
check "service.ready:false" '"service":{"ready":false'
check "service.classes {} и learned:0" '"classes":{},"learned":0'
check "service.eval обнулён" '"eval":{"count":0,"correct":0,"accuracy":0'
check "stats.ml/ai 0" '"stats":{"ml":0,"ai":0'
check "stats.learning/outbox 0" '"learning":0' '"outbox":0'
check "stats.reachable:true" '"stats":{"ml":0,"ai":0,"learning":0,"ready":false,"classes":{},"learned":0,"reachable":true'
echo
echo "== 4. PATCH {\"mlEnabled\":false} → статус enabled:false (счётчики из KV, Ruling 1) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"mlEnabled":false}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200 mlEnabled:false" '[HTTP:200]' '"mlEnabled":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
check "status enabled:false" '[HTTP:200]' '"enabled":false'
check_not "нет service в ответе" '"error"'
echo
echo "== 4b. PATCH {\"mlEnabled\":true} — возврат дефолта =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"mlEnabled":true}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200 mlEnabled:true" '[HTTP:200]' '"mlEnabled":true'
echo
echo "== 5. POST /api/ml/predict {\"text\":\"x\"} — 400 «Введите текст» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" -d '{"text":"x"}' > "$OUT"
cat "$OUT"
echo
check "predict 400" '[HTTP:400]' '"detail":"Введите текст"'
echo
echo "== 6. POST /api/ml/predict с текстом (UTF-8 из файла) — «не уверен», все поля =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/predict" \
-H "Content-Type: application/json" --data-binary "@$PREDICT_BODY" > "$OUT"
cat "$OUT"
echo
check "predict 200" '[HTTP:200]'
check "text эхом (кириллица без \\u-экранов)" 'Python backend на fastapi, бот в телеграм, удалённо, сделка'
check "take:false, label:null, scores:{}" '"take":false,"label":null,"scores":{}'
check "hits:0, ready:false" '"hits":0,"ready":false'
check "margin:null, terms:[], type:null" '"margin":null,"terms":[],"type":null'
echo
echo "== 7. POST /api/ml/reset — {ok:true} (без ключа error) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT"
cat "$OUT"
echo
check "reset 200" '[HTTP:200]' '{"ok":true}'
check_not "нет поля error при успехе" '"error"'
echo
echo "== 8. POST /api/ml/candidates — {items: []} (данных telegram нет до этапа 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/candidates" \
-H "Content-Type: application/json" -d '{"dialogId":"chan_test","limit":10}' > "$OUT"
cat "$OUT"
echo
check "candidates 200" '[HTTP:200]' '{"items":[]}'
echo
echo "== 9. POST /api/ml/apply — 404 «Исходное сообщение не найдено» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/apply" \
-H "Content-Type: application/json" -d '{"dialogId":"chan_test","msgId":1,"action":"spam"}' > "$OUT"
cat "$OUT"
echo
check "apply 404" '[HTTP:404]' '"detail":"Исходное сообщение не найдено"'
echo
echo "== 10. psql: таблиц ml_outbox/learning_log в схеме тенанта нет (Ruling 5 L81-82) =="
$PSQL_BASE -t -A -c "SELECT tablename FROM pg_tables WHERE schemaname = '$SCHEMA' AND tablename NOT IN ('settings', '__TenantMigrationsHistory');" > "$OUT"
cat "$OUT"
echo
if [ -s "$OUT" ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в схеме тенанта есть лишние таблицы (ml_outbox/learning_log?): $(tr '\n' ' ' < "$OUT")"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в схеме тенанта только settings (и история миграций)"
fi
echo
echo "== 11. POST /api/auth/logout, затем /api/ml* — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
cat "$OUT"
echo
check "после logout status 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ml/reset" > "$OUT"
cat "$OUT"
echo
check "после logout reset 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 12. Очистка: удаляем строки settings (dev-БД возвращается к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM $SCHEMA.settings;"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM $SCHEMA.settings;")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] таблица settings очищена (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблица settings не очистилась (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1 @@
{"text":"Python backend на fastapi, бот в телеграм, удалённо, сделка"}
@@ -0,0 +1,64 @@
# Task 9 — «ML-панель: порт IMlClient, детерминированная заглушка, эндпоинты /api/ml» — отчёт
Статус: **complete**. Build 0 warnings / 0 errors; тесты 161/161 PASS (было 148, добавлено 13: `LocalMlClientTests`, +1 прямой ProjectReference `Deal.Contracts` в тестовый проект); curl-приёмка :5080 — PASS=31 FAIL=0.
Отчёт по плану `docs/superpowers/plans/2026-09-05-deal-stage2-settings.md` (Task 9 L333367, Ruling 4 L7175, Ruling 5 L7682; референс `ml_routes.py` L6691/L112171, `ml_client.py` L101150, `mlservice/model.py` predict/status; фронт `MLPanel.vue` + `store.js` applyMlStatus L487502).
## Реализация
| Файл | Тип | Содержание |
|---|---|---|
| `Deal.Contracts/Integrations/IMlClient.cs` | create | Порт внешнего ML-сервиса (Ruling 4): `StatusAsync/PredictAsync/ResetAsync` (ct-параметры). PushAsync НЕ объявлен — добавится этапом 3 (Ruling 5). |
| `Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs` | create | `{Ready, Classes, Learned, Eval}` — «сырой» статус ML-сервиса 1:1 `model.py` status L325345. |
| `Deal.Contracts/Integrations/Models/MlEvalDto.cs` | create | `{Count, Correct, Accuracy}` — самооценка модели. |
| `Deal.Contracts/Integrations/Models/MlStatsDto.cs` | create | `{Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox}` — локальная статистика 1:1 `ml_client.snapshot()` L138150. |
| `Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs` | create | Тело GET /api/ml/status `{Enabled, Service, Reachable, Stats}` 1:1 `ml_routes.py` L7075 (api-map §4.10 L363). |
| `Deal.Contracts/Integrations/Models/MlPredictResultDto.cs` | create | `{Take, Label?, Scores, Hits, Ready, Margin?, Terms[], Type?}` — 1:1 `model.py` predict (Ruling 5 L80). |
| `Deal.Contracts/Integrations/Models/MlResetResultDto.cs` | create | `{Ok, Error?}`; `Error``[JsonIgnore(WhenWritingNull)]` → успех сериализуется ровно `{"ok":true}` (ветка `{ok:false,error}` зарезервирована, план L347). |
| `Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs` | create | Типизация поля `type` предсказания `{Take, Label:"hire"\|"order", Value, Margin}` (эталон `model.py` L233–238; в этапе 2 всегда null). |
| `Deal.Infrastructure/Integrations/LocalMlClient.cs` | create | Заглушка Ruling 5: детерминированная. `StatusAsync` — reachable=true, service {ready:false, classes:{}, learned:0, eval 0/0/0}; счётчики mlDecisions/aiDecisions и mlEnabled — из KV settings (Ruling 1); learning/outbox=0. `PredictAsync` — фиксированный «не уверен» (все 8 полей). `ResetAsync``{ok:true}`, KV не трогает (прототип чистит только outbox). |
| `Deal.Infrastructure/ServiceCollectionExtensions.cs` | modify | `AddDealIntegrations()``AddScoped<IMlClient, LocalMlClient>` (по образцу AddDealSecurity). |
| `Deal.Api/Endpoints/MlEndpoints.cs` | create | `MapMlEndpoints` (`/api/ml/*`, тег "ml"): GET status → MlStatusResponseDto; POST reset → `{ok:true}`; POST predict `{text}` — trim <2 → 400 «Введите текст», ответ `{text:<первые 200>, take, label, scores, hits, ready, margin, terms, type}`; POST candidates → `{items:[]}`; POST apply → 404 «Исходное сообщение не найдено». 401-гейт {detail}, резолв IMlClient через RequestServices после гейта (паттерн SettingsEndpoints). |
| `Deal.Api/Endpoints/MlPredictRequest.cs`, `MlCandidatesRequest.cs`, `MlApplyRequest.cs` | create | Тела POST (как LoginRequest). |
| `Deal.Api/Http/EndpointResults.cs` | modify | Добавлен `NotFound(detail)` (404 + {detail}, Ruling 10). |
| `Deal.Api/Program.cs` | modify | `AddDealIntegrations()` + `app.MapMlEndpoints()`. |
| `tests/…/Deal.Tests.Unit.csproj`, `LocalMlClientTests.cs` | modify/create | +13 тестов. |
## Границы и решения
- **`StatusAsync` возвращает полный `MlStatusResponseDto`** (включая enabled/stats), а не только статус сервиса: план предписывает заглушке самой читать счётчики «из KV settings» (Files-список Task 9 L342–344) — как прототип: `ml_routes.ml_status` + `ml_client.snapshot()` живут в одном модуле-клиенте. Эндпоинт — тонкий passthrough. На этапе 6 gRPC-адаптер собирает ответ так же (меняется только «service»-часть на сетевой вызов).
- **Deal.Contracts остался без зависимостей**: в контракте ML нет TenantId/SharedKernel — ссылку на SharedKernel добавлять не потребовалось (проверено: Contracts.csproj без ProjectReference; см. Ruling 4). Исключение контрактных моделей покрыто «модель в отдельном файле» (1 тип = 1 файл).
- **Два record-DTO сверх явного списка плана** (Files-список содержит «Models/*.cs»): `MlResetResultDto` (необходим как возврат ResetAsync) и `MlTypeDecisionDto` (типизация `Type?` предсказания вместо не типизированного object).
- **candidates/apply «честно пустые» по плану**: candidates — `{items:[]}` (телеграм-сообщений нет до этапа 6, L350–351); apply — всегда 404 «Исходное сообщение не найдено» (нет таблиц leads/messages до этапов 3/6, ветка skip — этап 6, L352353). ml/learn, ml/flush НЕ реализованы (фронт не вызывает — api-map п.9 L399).
- **Predict-ответ** — анонимный объект эндпоинта `{text, ...flatten}` (1:1 с python `{"text": text[:200], **result}`); text эхом = полный trim-нутый ввод (усечение только >200 символов, как `text[:200]`).
- **Очередь обучения**: reset не пишет/не чистит KV-счётчики (в прототипе чистится только ml_outbox — таблицы в этапе 2 нет, Ruling 5 L8182); счётчики mlDecisions/aiDecisions в статусе читаются, владельцы записи — этапы 3/4/6.
- **Ошибки тела**: predict с текстом <2 символов после trim — 400 `{"detail":"Введите текст"}` (ровно как FastAPI); повреждённые KV-строки (mlEnabled/mlDecisions) — мягкий дефолт (как SettingsService); mlEnabled=false даёт enabled:false, отсутствие/не-bool — true (семантика `is not False`).
## Тесты (13 новых; всего 161 PASS)
`LocalMlClientTests`: статус без настроек (все поля Ruling 5: enabled true, reachable true, service не готов, stats нули, outbox 0); счётчики из KV (ml=7/ai=3); mlEnabled false/true/не-bool/повреждён → disabled/enabled/дефолт; повреждённый счётчик → 0; predict неготовой модели — все 8 полей фиксированы; reset → ok:true и KV не тронут; wire-тесты: статус (ключи enabled/service/reachable/stats + вложенные service.eval/stats), predict (точная JSON-строка 1:1), reset (ровно `{"ok":true}` без error).
## Приёмка (curl :5080, admin/admin; скрипт + лог: task-9-curl-acceptance.sh/.log, тело predict — UTF-8 файл task-9-predict.json)
1. GET /api/ml/status без куки → 401 `{"detail":"Требуется авторизация"}`.
2. login → status: `{"enabled":true,"service":{"ready":false,"classes":{},"learned":0,"eval":{"count":0,"correct":0,"accuracy":0}},"reachable":true,"stats":{"ml":0,...,"outbox":0}}` (форма §4.10 1:1).
3. PATCH `{"mlEnabled":false}` → status `enabled:false`; PATCH true → `enabled:true` (KV живьём).
4. predict `{"text":"x"}` → 400 `{"detail":"Введите текст"}`.
5. predict с текстом → 200: `text` эхом (кириллица без \u), `take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null`.
6. reset → `{"ok":true}` (ключа error нет); candidates → `{"items":[]}`; apply → 404 «Исходное сообщение не найдено».
7. psql: в схеме тенанта таблиц ml_outbox/learning_log НЕТ (только settings + история миграций).
8. logout → status/reset 401. Итог: **PASS=31 FAIL=0**; dev-БД очищена, сервер остановлен (порт 5080 свободен).
## Concerns / замечания
1. **Wire числа**: `"accuracy":0` (STJ) против `0.0` у python json.dumps — валидный JSON, фронт читает `ev.accuracy || 0` (applyMlStatus) — косметика (как E-нотация курсов в Task 8).
2. **Typed-body binding**: тело POST (predict/candidates/apply) биндится до тела обработчика (паттерн LoginRequest) — невалидный JSON даст 400 фреймворка раньше 401-гейта; фронт шлёт валидный JSON только с сессией — приемлемо.
3. **Юнит-хостинга Api в проекте нет** (как в прошлых задачах): 401/404/400-ветки и wire эндпоинтов покрыты curl-приёмкой; юнит-уровень — LocalMlClient + wire-тесты DTO.
4. **Contracts без TenantId** — ссылка на SharedKernel не понадобилась (см. выше); если этап 6 добавит в контракт tenant-параметры — ссылка появится тогда.
## Проверки
```
dotnet build Deal.sln → 0 предупреждений / 0 ошибок
dotnet test Deal.sln → всего 161, пройдено 161, пропущено 0 (было 148, +13)
sh task-9-curl-acceptance.sh → PASS=31 FAIL=0 (лог task-9-curl-acceptance.log)
```