Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage5-projects.md
|
||||
|
||||
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
|
||||
|
||||
## Todos
|
||||
- Task 1: complete (review clean; миграция TenantProjects применена, 535 PASS). Отчёт: task-1-report.md.
|
||||
- [x] Task 1: Миграция TenantProjects/ProjectCards
|
||||
- Task 2: complete (review clean; 535 PASS; стадии 1:1, порт по Self-Review 3). Отчёт: task-2-report.md.
|
||||
- [x] Task 2: Модуль Projects — стадии + DTO + IProjectStore
|
||||
- Task 2: complete (стадии 1:1, DTO-модели §4.3, IProjectStore, реестр-каркас; build 0/0, 535 PASS). Отчёт: task-2-report.md.
|
||||
- Task 3: complete (build 0/0; 535 PASS; ProjectStore — 13 методов порта на TenantDbContext, DI в AddDealPersistence; dev-харнесс 48/48 на дефолтном тенанте + psql, схема очищена). Отчёт: task-3-report.md.
|
||||
- Task 3: complete (review clean; 535 PASS; ProjectStore 13/13). Отчёт: task-3-report.md.
|
||||
- [x] Task 3: EF-адаптер ProjectStore
|
||||
- Task 4: complete (1 fix round: presence-aware PATCH budget:null + append-тест истории; 556 PASS). Отчёт: task-4-report.md.
|
||||
- [x] Task 4: take + ProjectsService
|
||||
- Task 5: complete (review clean; 567 PASS). Отчёт: task-5-report.md.
|
||||
- [x] Task 5: Комментарии/ссылки
|
||||
- Task 6: complete (build 0/0, 584 PASS — 567 + 17 новых; IFileStorage + Local/MinIO + FileKindDetector + compose minio; MinIO live-check пройден, deal-minio поднят). Отчёт: task-6-report.md.
|
||||
- Task 6: complete (review clean с 1 Important-заметкой). Отчёт: task-6-report.md. **Ruling**: FileMeta остаётся и задействуется в T9 (download: Content-Length/Type через StatObject/FileInfo); адаптеры выровнять (Put с позиции 0; Minio GetAsync dispose при ошибке ≠ NotFound); цифры тестов в отчёте — косметика.
|
||||
- [x] Task 6: IFileStorage + Local/MinIO + FileKindDetector + compose-minio
|
||||
- Task 7: complete (review clean; 599 PASS). Отчёт: task-7-report.md.
|
||||
- [x] Task 7: ProjectFilesService
|
||||
- Task 8: complete (build 0/0; 599 PASS; curl 50/50; boot-заглушка /projects снята, /tg/status остаётся). Отчёт: task-8-report.md.
|
||||
- Task 8: complete (review clean; 599 PASS; curl 50/50). Отчёт: task-8-report.md. Ruling: ProjectPatchRequest не создан (presence-aware Dictionary — прецедент settings), зафиксировано.
|
||||
- [x] Task 8: Эндпоинты карточек + замена boot-заглушки /projects
|
||||
- Task 9: complete (build 0/0; 602 PASS; curl 41/41 FAIL=0, локальный режим). Отчёт: task-9-report.md. Ruling T6 закрыт: IFileStorage.StatAsync → FileMeta (MinIO StatObject / Local FileInfo); download отдаёт Content-Length/Content-Type из дескриптора (локально MIME пуст → octet-stream 1:1 прототип).
|
||||
- Task 9: complete (review clean; 602 PASS; curl 41/41; Ruling T6 закрыт). Отчёт: task-9-report.md.
|
||||
- [x] Task 9: Файл-эндпоинты upload/download/delete
|
||||
- Task 10: complete (build 0/0; 614 PASS — 602 + 12 ProjectReminderServiceTests; curl 32/32). Отчёт: task-10-report.md.
|
||||
- [x] Task 10: Напоминания (сервис + эндпоинты)
|
||||
- Task 10: complete (review clean; 614 PASS; curl 32/32). Отчёт: task-10-report.md.
|
||||
- [x] Task 10: Напоминания (сервис + эндпоинты)
|
||||
- Task 11: complete (build 0/0; 617 PASS — 614 + 3 AdminTickOrchestratorTests; curl 23/23; reminders + SSE reminder_due, Ruling 8 — toast не шлём). Отчёт: task-11-report.md.
|
||||
- Task 11: complete (review clean; 617 PASS; curl 23/23). Отчёт: task-11-report.md.
|
||||
- [x] Task 11: admin/tick reminders + SSE
|
||||
- Task 12: complete (review clean; 620 PASS; curl 19/19). Отчёт: task-12-report.md.
|
||||
- [x] Task 12: Фоновая проверка напоминаний (30 с)
|
||||
- Task 12: complete (build 0/0; 620 PASS — 617 + 3 StorageTickSchedulerTests; curl 19/19: reminder_due фоновым 30-с циклом БЕЗ ручного tick, ReminderFired=t, disabled → очистка без событий). Отчёт: task-12-report.md.
|
||||
- Task 13: complete (review pending; build 0/0; 620 PASS; curl-приёмка :5080 PASS=75 FAIL=0, финал этапа; код не менялся — доки/ledger обновлены, dev-БД и вложения очищены, deal-minio оставлен поднятым). Отчёт: task-13-report.md.
|
||||
- [x] Task 13: Финал/сквозная приёмка
|
||||
|
||||
## Pre-flight scan (краткий)
|
||||
| Пара | Производит/потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T1 → T3 | миграция → EF-адаптер | Чисто |
|
||||
| T2 → T3/T4 | DTO/порт → адаптер/сервис | Чисто |
|
||||
| T4 → T8 | ProjectsService → эндпоинты; замена boot-заглушки /projects | Чисто (T8 правит BootStubEndpoints) |
|
||||
| T4 | take: IKanjStore.GetCardAsync + MarkTakenAsync (Kanban) | Projects→Kanban порт — разрешено; обратного пути нет |
|
||||
| T6 → T7 | IFileStorage → ProjectFilesService | Чисто |
|
||||
| T5 → T8/T9 | комментарии/ссылки → эндпоинты | Чисто |
|
||||
| T10 → T11/T12 | напоминания → tick + фон 30 с | Чисто |
|
||||
| T11/T12 | StorageTickScheduler расширяется (reminders+SSE reminder_due) | Осторожно: не сломать этап-3/4 поведение |
|
||||
| T12 | SSE reminder_due — фронт слушает | Форма {id,title,stage} |
|
||||
|
||||
## Task status
|
||||
@@ -0,0 +1,48 @@
|
||||
# Task 1 — «Миграция TenantProjects: таблица ProjectCards» — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 535/535 PASS, миграция применена к dev-схеме дефолтного тенанта, psql-приёмка зелёная).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — сущность (`src/core/Deal.Infrastructure/Persistence/Entities/`, 1 тип = 1 файл)
|
||||
|
||||
| Файл | Таблица | Ключевые поля (Ruling 1(а), db.py L103–125) |
|
||||
|---|---|---|
|
||||
| `ProjectCardEntity.cs` | `ProjectCards` (= projects) | Id (text PK, `pr_`), Stage (`planned`), Local (bool), LeadId (text?, БЕЗ FK — «мягкая» ссылка на `Cards.Id`, конвенция DedupEntries Ruling 1 этапа 4), Title, Summary, StackJson (text), BudgetFrom/BudgetTo (double?), BudgetCur (пусто — бюджета нет), Contact, CommentsJson/LinksJson/FilesJson/HistoryJson (text; wire-формы элементов), TzText (text), ReminderAt (timestamptz?), ReminderFired (bool), CreatedAt, UpdatedAt |
|
||||
|
||||
Времена — `DateTimeOffset` → `timestamptz`. Nullable только по Ruling 1: `LeadId`, `ReminderAt`. CLR-инициализаторы переносят прототипные дефолты (`Stage="planned"`, JSON-массивы `"[]"`, `BudgetCur=""`, `ReminderFired=false`) — DB-дефолты `HasDefaultValue` НЕ заданы (конвенция этапа 3: EF опускает колонку при CLR-дефолте). Одна таблица, история/комментарии/файлы НЕ выносятся — план (Ruling 1) предписывает JSON-массивы в карточке, отдельных таблиц этап не добавляет.
|
||||
|
||||
### Создана — конфигурация (`src/core/Deal.Infrastructure/Persistence/`)
|
||||
|
||||
| Файл | Содержание |
|
||||
|---|---|
|
||||
| `ProjectCardConfiguration.cs` | `ToTable("ProjectCards")`, HasKey(Id), пять JSON-колонок `.HasColumnType("text")` (как `Cards.StackJson`), индексы: `IX_ProjectCards_Stage`, `IX_ProjectCards_UpdatedAt` `.IsDescending()` (DESC — сортировка списка), частичный UNIQUE `IX_ProjectCards_LeadId` `.HasFilter("\"LeadId\" IS NOT NULL")` (гонка take) |
|
||||
|
||||
### Изменены
|
||||
|
||||
- `Persistence/TenantDbContext.cs` — DbSet `ProjectCards` + `ApplyConfiguration(new ProjectCardConfiguration())` (без `ApplyConfigurationsFromAssembly`, паттерн этапов 1–4).
|
||||
- `Migrations/TenantDb/20260906200342_TenantProjects.cs` (+ `.Designer.cs`, обновлён `TenantDbContextModelSnapshot.cs`) — миграция.
|
||||
|
||||
## Миграция и psql-приёмка
|
||||
|
||||
- Создана: `dotnet ef migrations add TenantProjects --context TenantDbContext --output-dir Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (из `src/core`; dotnet-ef 10.0.11).
|
||||
- DDL без схемы (search_path): `CreateTable ProjectCards` + 3 индекса; PK `PK_ProjectCards (Id)`. SQL-скрипт подтвердил: `CREATE UNIQUE INDEX IX_ProjectCards_LeadId … WHERE "LeadId" IS NOT NULL`, `IX_ProjectCards_UpdatedAt … DESC`.
|
||||
- Применение: краткий старт `Deal.Api` — `TenantProvisioningService` применил миграцию к схеме дефолтного тенанта (фоновые воркеры после старта тикали нормально).
|
||||
|
||||
psql (`tenant_00000000000000000000000000000001`):
|
||||
- Таблица `ProjectCards` создана (+ существующие Boards/Cards/…; итого 11 таблиц).
|
||||
- Индексы: `IX_ProjectCards_Stage` (btree), `IX_ProjectCards_UpdatedAt` (btree, `DESC`), UNIQUE `IX_ProjectCards_LeadId` (partial `WHERE ("LeadId" IS NOT NULL)`).
|
||||
- Семантика частичного UNIQUE проверена транзакциями с самоочисткой: два NULL LeadId — вставка OK (INSERT 0 2); два одинаковых LeadId — `duplicate key value violates unique constraint "IX_ProjectCards_LeadId"`; остаточных строк нет.
|
||||
- `__TenantMigrationsHistory` содержит `20260906200342_TenantProjects` (после InitialTenant/TenantKanban/TenantPipeline).
|
||||
|
||||
## Валидация
|
||||
|
||||
- `dotnet build Deal.sln`: Предупреждений 0, Ошибок 0.
|
||||
- `dotnet test tests/Deal.Tests.Unit`: 535/535 PASS (MarkerTests в составе).
|
||||
- Диагностики изменённых файлов — без ошибок/предупреждений.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
- `descending: new bool[0]` в `CreateIndex` миграции — штатная сериализация `.IsDescending()` без аргументов; сгенерированный SQL содержит `DESC` (проверено `ef migrations script` и pg_indexes).
|
||||
- DB-дефолты не заданы — конвенция этапа 3 (как Task 1 этапа 4); сущность хранит JSON как `text`, парсинг — на уровне адаптера (Task 3).
|
||||
- Модуль `Deal.Modules.Projects` в Task 1 не задействован: сущность/конфигурация живут в Infrastructure (эталон CardEntity/CardConfiguration), миграция принадлежит `TenantDbContext`.
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 10 curl-приёмка напоминаний /api/projects/{cardId}/reminder[/snooze] на :5080 (план Task 10
|
||||
# L411-432, Ruling 3; projects_routes.py L189-211; projects.py L236-282; api-map L172-174, §4.6 L328).
|
||||
# Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск Deal.Api (Development, DEAL_DEMO=1)
|
||||
# -> 401 без куки (set/delete/snooze) -> login admin/admin -> 404 на несуществующей карточке (set/delete/
|
||||
# snooze), 400 set без поля at -> локальная карточка -> move hold -> POST reminder {at: now+1 мин} ->
|
||||
# карточка с reminder.at -> DELETE reminder {ok:true}, reminder null -> снова set -> snooze -> reminder.at ~
|
||||
# now+24 ч -> move ready: reminder сброшен -> move hold + set -> PATCH settings remindersEnabled=false ->
|
||||
# set -> 400 «Напоминания об отложенных выключены в настройках» -> DELETE ok (выключатель не проверяет) ->
|
||||
# PATCH settings restore true -> logout -> 401. Очистка строк/настроек после приёмки.
|
||||
|
||||
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"
|
||||
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
|
||||
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
|
||||
TMPB=$(cygpath -m /tmp)/task10
|
||||
JAR="$TMPB/jar.txt"
|
||||
OUT="$TMPB/out.txt"
|
||||
HDR="$TMPB/hdr.txt"
|
||||
HDRN="$TMPB/hdrn.txt"
|
||||
LOG="$TMPB/api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
DAY_MS=86400000
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
APP_PID=""
|
||||
PRJ=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Числовая проверка: значение поля reminder.at в диапазоне [min..max] ($OUT — GET карточки).
|
||||
check_reminder_at_range() {
|
||||
desc=$1
|
||||
min=$2
|
||||
max=$3
|
||||
at=$(sed -n '1{s/.*"reminder":{"at":\([0-9][0-9]*\)}.*/\1/p}' "$OUT")
|
||||
if [ -n "$at" ] && [ "$at" -ge "$min" ] 2>/dev/null && [ "$at" -le "$max" ] 2>/dev/null; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] $desc ($at в [$min..$max])"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] $desc — reminder.at=$at вне [$min..$max] или не найден"
|
||||
echo "--- ответ:"
|
||||
cat "$OUT"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_) из JSON-тела ответа.
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
|
||||
stop_app "$APP_PID"
|
||||
if [ -n "$PRJ" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
|
||||
fi
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
|
||||
rm -rf "$TMPB"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$LOG"
|
||||
mkdir -p "$TMPB"
|
||||
|
||||
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
grep -q 'LocalFileStorage' "$LOG"
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
|
||||
head -n 3 "$LOG"
|
||||
fi
|
||||
echo " health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. 401 без сессии на reminder-эндпоинтах =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"at":1760000000000}' "$BASE_URL/api/projects/pr_x/reminder" > "$OUT"
|
||||
check "POST reminder без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_x/reminder" > "$OUT"
|
||||
check "DELETE reminder без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/pr_x/reminder/snooze" > "$OUT"
|
||||
check "POST snooze без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== 3. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 4. Несуществующая карточка: 404 на set/delete/snooze, 400 set без at =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"at":1760000000000}' "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
|
||||
check "POST reminder на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
|
||||
check "DELETE reminder на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/pr_dead/reminder/snooze" > "$OUT"
|
||||
check "POST snooze на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{}' "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
|
||||
check "POST reminder {} → 400 (нет at)" '[HTTP:400]' 'Поле at (epoch-ms) обязательно'
|
||||
|
||||
echo
|
||||
echo "== 5. Локальная карточка и перенос в hold =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
|
||||
PRJ=$(extract_id)
|
||||
echo " -> PRJ: $PRJ"
|
||||
if [ -z "$PRJ" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
|
||||
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
|
||||
|
||||
echo
|
||||
echo "== 6. POST reminder {at: now+1 мин} → карточка с reminder.at =="
|
||||
AT1=$(( $(date +%s) * 1000 + 60000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder 200 — карточка" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки — reminder на месте" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}' '"stage":"hold"'
|
||||
|
||||
echo
|
||||
echo "== 7. DELETE reminder → {ok:true}, карточка без reminder =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "DELETE reminder 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки — reminder null" '[HTTP:200]' '"reminder":null'
|
||||
|
||||
echo
|
||||
echo "== 8. Повторный set и snooze → reminder.at ~ now+24 ч =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder снова 200" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}'
|
||||
SNOOZE_BEFORE=$(( $(date +%s) * 1000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/$PRJ/reminder/snooze" > "$OUT"
|
||||
check "POST snooze 200" '[HTTP:200]' '"ok":true'
|
||||
SNOOZE_AFTER=$(( $(date +%s) * 1000 + 2000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки после snooze 200" '[HTTP:200]' '"reminder":{'
|
||||
check_reminder_at_range "reminder.at после snooze = now+24 ч (±2 с)" $((SNOOZE_BEFORE + DAY_MS)) $((SNOOZE_AFTER + DAY_MS))
|
||||
|
||||
echo
|
||||
echo "== 9. Move hold → ready: напоминание сброшено (Ruling 3) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"ready"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
|
||||
check "move ready 200" '[HTTP:200]' '"stage":"ready"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки после move — reminder null" '[HTTP:200]' '"reminder":null'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
|
||||
check "move обратно в hold 200" '[HTTP:200]' '"stage":"hold"'
|
||||
|
||||
echo
|
||||
echo "== 10. Выключенные напоминания: set → 400, delete работает =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH settings remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
check "GET settings — remindersEnabled false" '[HTTP:200]' '"remindersEnabled":false'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder при выключенных → 400" '[HTTP:400]' 'Напоминания об отложенных выключены в настройках'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/$PRJ/reminder/snooze" > "$OUT"
|
||||
check "snooze при выключенных → 200 (выключатель не проверяет)" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "DELETE reminder при выключенных → 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH settings restore true 200" '[HTTP:200]' '"remindersEnabled":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
|
||||
check "GET settings — remindersEnabled true" '[HTTP:200]' '"remindersEnabled":true'
|
||||
|
||||
echo
|
||||
echo "== 11. Logout → 401 на reminder =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,88 @@
|
||||
# Task 10 — Напоминания «Отложено»: ProjectReminderService + эндпоинты reminder/reminder/snooze — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0; 614/614 PASS — 602 этапов 1–9 + 12 новых ProjectReminderServiceTests;
|
||||
curl-приёмка :5080 — **32/32 PASS**). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 10 (L411–432), Ruling 3 (напоминания:
|
||||
окно — фронт HoldReminderDialog; бэкенд хранит ReminderAt; любой move сбрасывает; remindersEnabled —
|
||||
общий публичный ключ SettingsKeys.RemindersEnabled, дефолт true; set → 400 при выключенном, clear/snooze
|
||||
выключатель не проверяют; CheckDueAsync — disabled→очистка протухших / enabled→due-fired); источники
|
||||
`backend/app/services/projects.py` L236–282, `backend/app/routers/projects_routes.py` L189–211, api-map
|
||||
L172–174/§4.6 L328, §3.5 L172; фронт store.js L2060–2145 (`{at}` epoch-ms; snooze без тела; ответ set —
|
||||
карточка), HoldReminderDialog.vue.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан
|
||||
- `Deal.Modules.Projects/Application/ProjectReminderService.cs` — чистый сервис модуля (Ruling 2/3), DI —
|
||||
IProjectStore + ISettingsStore:
|
||||
- `SetAsync(cardId, atMs, ct) → ProjectCardResultDto` — карточки нет → 404-результат (ДО выключателя:
|
||||
роутер `_card_or_404` L193 до вызова set_reminder); `remindersEnabled=false` → 400-результат
|
||||
`<RemindersDisabledDetail>` «Напоминания об отложенных выключены в настройках» (L237–238);
|
||||
иначе `store.SetReminderAsync` (reminder_at + fired=false + bump UpdatedAt, L239–242) и возврат полной
|
||||
карточки (return get_card L243). Стадия и время НЕ проверяются (1:1: фронт шлёт только для hold; at в
|
||||
прошлом допустим — приёмка Tasks 11/13 «выстреливает» его ручным тиком).
|
||||
- `ClearAsync(cardId, ct) → bool` — карточки нет → false (404); снятие через store (fired=false, без
|
||||
bump — 1:1 L246–247); выключатель не проверяется.
|
||||
- `SnoozeAsync(cardId, ct) → bool` — now + 24 ч (константа `ReminderSnoozeMs`, не магия; snooze L257–261);
|
||||
выключатель не проверяется; карточки нет → false (404).
|
||||
- `CheckDueAsync(ct) → IReadOnlyList<ProjectReminderDueDto>` — disabled → `store.ClearExpiredAsync` +
|
||||
пустой список (L266–269: протухшие не храним, при включении старые не «выстрелят»); enabled →
|
||||
`ListDueAsync(now)` (hold, ≤now, не-fired, ORDER BY at) + `MarkFiredAsync` (L277–278) + возврат due
|
||||
{id,title,stage}. SSE reminder_due публикует Api-слой (Tasks 11/12, Ruling 8) — сервис события не шлёт.
|
||||
- Приватный `ReadRemindersEnabledAsync` — эталон ReadBoolAsync модулей (Kanban/Pipeline): отсутствие
|
||||
строки/повреждённый JSON → дефолт `SettingsDefaults.RemindersEnabled` (true).
|
||||
- `Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs` — тело `{at}`: `long? At` (camelCase; nullable —
|
||||
прецедент TakeLeadRequest/ProjectCommentRequest); отсутствующий/JSON-null at = клиентский баг (pydantic —
|
||||
422) → эндпоинт отвечает 400.
|
||||
- `tests/Deal.Tests.Unit/ProjectReminderServiceTests.cs` — 12 тестов (ниже).
|
||||
- `.superpowers/sdd/deal-stage5-projects/task-10-curl-acceptance.sh` (+ лог `task-10-curl-acceptance.log`).
|
||||
|
||||
### Изменён
|
||||
- `Deal.Modules.Projects/Application/ProjectsModuleRegistrar.cs` — `AddScoped<ProjectReminderService>()`
|
||||
(комментарий-каркас уже анонсировал Task 10).
|
||||
- `Deal.Api/Endpoints/ProjectsEndpoints.cs` — группа 13 → **16 эндпоинтов**, 3 reminder-маршрута
|
||||
(Ruling 9: `POST /{cardId}/reminder` и `DELETE /{cardId}/reminder` до `POST /{cardId}/reminder/snooze`):
|
||||
- POST → карточка | 400 (напоминания выключены) | 404 «Карточка не найдена»; тело без at → 400
|
||||
`ReminderAtMissingDetail` «Поле at (epoch-ms) обязательно» (недостижимо фронтом; новый не-прототипный
|
||||
текст на месте FastAPI-422 — прецедент FormExpectedDetail Task 9/InvalidBodyDetail Task 8). Все —
|
||||
после 401-гейта HasUser + RequestServices-резолва (паттерн группы).
|
||||
- DELETE → `{ok:true}` | 404; POST /snooze → `{ok:true}` | 404. Класс-док группы обновлён (Tasks 8/9/10).
|
||||
|
||||
## Решения и замечания
|
||||
- **Настройка — общий `remindersEnabled`**: отдельный ключ «напоминания об отложенных» НЕ заводится —
|
||||
Ruling 3 L112–113: уже готовый публичный ключ SettingsKeys.RemindersEnabled (дефолт true,
|
||||
SettingsDefaults L117; PATCH /api/settings работает с этапа 2), фронт открывает HoldReminderDialog по
|
||||
`state.remindersEnabled` (store.js L1976–1980) — тот же флаг. Зависимость на доработку Settings НЕ
|
||||
требуется.
|
||||
- **Валидации времени в будущем НЕТ** (отклонение от формулировки в ТЗ задачи): Ruling 3/прототип
|
||||
set_reminder L236–243 at не валидируют, а приёмка Tasks 11/13 требует set на hold-карточку с at в
|
||||
ПРОШЛОМ (now−1 мин) для «выстреливания» ручным тиком — future-валидация сломала бы её. Прошлое at = при
|
||||
ближайшем тике сработает (1:1 прототип).
|
||||
- **Порядок 404/400 у set**: карточка раньше выключателя (404 раньше 400) — 1:1 с роутером
|
||||
`_card_or_404` (L193) до вызова set_reminder (L194–197); комбинация «карточки нет + выключено»
|
||||
недостижима фронтом, зафиксирована тестом `Set_MissingCard_Returns404EvenWhenDisabled` (404, как
|
||||
прототип).
|
||||
- **Snooze через SetReminderAsync хранилища**: отдельного порт-метода snooze нет (порт — ровно задачи
|
||||
3–12, YAGNI); SetReminderAsync сбрасывает fired (как snooze L259) и бампит UpdatedAt — отличие от
|
||||
python snooze (updated_at не трогает) зафиксировано в XML-doc сервиса; фронт (store.js L2122–2131)
|
||||
счётчик после snooze не перечитывает — влияния нет.
|
||||
- Прошлые set/сработавшие → tick Tasks 11/12; due-«фired»-признак в DTO не выходит (держит строка БД) —
|
||||
тесты проверяют через повторный ListDueAsync (пуст), как в FakeProjectStore.
|
||||
|
||||
## Проверка
|
||||
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
|
||||
2. `dotnet test Deal.sln` — **614/614 PASS** (602 + 12 ProjectReminderServiceTests; таргетный фильтр
|
||||
ProjectReminderServiceTests — 12/12 зелёные).
|
||||
3. Curl-приёмка :5080 (`task-10-curl-acceptance.sh` → `task-10-curl-acceptance.log`) — **PASS=32 FAIL=0**:
|
||||
очистка ProjectCards/settings → запуск (LocalFileStorage) → 401 без куки (set/delete/snooze) → login →
|
||||
404 на pr_dead (set/delete/snooze), 400 set `{}` → локальная карточка → move hold → set {at:+1 мин} →
|
||||
карточка с `reminder:{at}`, GET подтверждает → DELETE {ok:true}, reminder null → повторный set → snooze
|
||||
→ reminder.at = now+24 ч (±2 с) → move ready: reminder сброшен → move hold → PATCH settings
|
||||
remindersEnabled=false → set → 400 «Напоминания об отложенных выключены в настройках» → snooze/DELETE
|
||||
200 (выключатель не проверяет) → PATCH restore true → logout → 401. После приёмки строки ProjectCards и
|
||||
строка настройки очищены (psql 0/0), порт :5080 свободен.
|
||||
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderSnoozeMs,
|
||||
RemindersDisabledDetail); комментарии на русском; без регионов.
|
||||
|
||||
## Отчёт
|
||||
`.superpowers/sdd/deal-stage5-projects/task-10-report.md`; ledger progress.md обновлён (Task 10 complete).
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 11 curl-приёмка: POST /api/admin/tick → reminders [{id,title,stage}] + SSE reminder_due на :5080
|
||||
# (план Task 11 L434-452, Ruling 3/8; dashboard_routes.py admin_tick L327-337; projects.py check_reminders
|
||||
# L264-282; api-map §3.2 L103-112). Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск
|
||||
# Deal.Api (Development, DEAL_DEMO=1) -> 401 без куки на /admin/tick -> login admin/admin -> локальная
|
||||
# карточка + move hold + PATCH title -> SSE-подписка (curl -N в фон) -> POST reminder {at: now-1 мин}
|
||||
# (прошлое допустимо, Ruling 3) -> POST /admin/tick: ответ reminders:[{id,title,stage:'hold'}] 1:1 с SSE
|
||||
# reminder_due, psql ReminderFired=true (MarkFired) -> второй tick: reminders:[] (повторно не «выстреливает»)
|
||||
# -> выключенные напоминания (remindersEnabled=false) + протухшая строка -> tick: reminders:[] и очистка
|
||||
# ReminderAt (check_reminders L266-269) -> restore true -> logout -> 401. Очистка строк/настроек после приёмки.
|
||||
|
||||
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"
|
||||
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
|
||||
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
|
||||
TMPB=$(cygpath -m /tmp)/task11
|
||||
JAR="$TMPB/jar.txt"
|
||||
OUT="$TMPB/out.txt"
|
||||
HDR="$TMPB/hdr.txt"
|
||||
LOG="$TMPB/api.log"
|
||||
SSE_LOG="$TMPB/sse.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
APP_PID=""
|
||||
SSE_PID=""
|
||||
PRJ=""
|
||||
PRJ2=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка по содержимому файла ($1), не $OUT.
|
||||
check_file() {
|
||||
desc=$1
|
||||
file=$2
|
||||
shift 2
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$file"; 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 — не найдено в $file: $*"
|
||||
echo "--- содержимое:"
|
||||
cat "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_) из JSON-тела ответа.
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
|
||||
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
|
||||
kill "$SSE_PID" 2>/dev/null
|
||||
fi
|
||||
stop_app "$APP_PID"
|
||||
if [ -n "$PRJ" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
|
||||
fi
|
||||
if [ -n "$PRJ2" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
|
||||
fi
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
|
||||
rm -rf "$TMPB"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$HDR" "$LOG" "$SSE_LOG"
|
||||
mkdir -p "$TMPB"
|
||||
|
||||
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
grep -q 'LocalFileStorage' "$LOG"
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
|
||||
head -n 3 "$LOG"
|
||||
fi
|
||||
echo " health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. POST /admin/tick без сессии → 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "tick без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== 3. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 4. Локальная карточка → move hold → заголовок для SSE/ответа =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
|
||||
PRJ=$(extract_id)
|
||||
echo " -> PRJ: $PRJ"
|
||||
if [ -z "$PRJ" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
|
||||
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"title":"Bot hold"}' "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "PATCH title 200" '[HTTP:200]' '"title":"Bot hold"'
|
||||
|
||||
echo
|
||||
echo "== 5. SSE-подписка на /api/events (фон) + reminder в прошлом (at = now-1 мин) =="
|
||||
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
|
||||
SSE_PID=$!
|
||||
sleep 2
|
||||
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
|
||||
|
||||
echo
|
||||
echo "== 6. POST /admin/tick → reminders:[{id,title,stage:'hold'}] (после MarkFired) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "tick 200 — форма {storage,reminders,pipeline,queue}" '[HTTP:200]' '"storage":{' '"reminders":[' '"pipeline":{' '"queue":'
|
||||
check "reminders ответа = due {id,title,stage:'hold'}" '"reminders":[{"id":"'"$PRJ"'","title":"Bot hold","stage":"hold"}]'
|
||||
echo "--- tick-ответ:"
|
||||
cat "$OUT"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 7. SSE-подписка получила reminder_due {id,title,stage}; psql ReminderFired=true =="
|
||||
sleep 1
|
||||
kill "$SSE_PID" 2>/dev/null
|
||||
SSE_PID=""
|
||||
check_file "SSE: событие reminder_due пришло" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ"'"' '"title":"Bot hold"' '"stage":"hold"'
|
||||
echo "--- sse.log:"
|
||||
cat "$SSE_LOG"
|
||||
echo
|
||||
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';")
|
||||
if [ "$FIRED" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: ReminderFired=true (MarkFired после tick)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: ReminderFired=$FIRED (ожидался t)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 8. Второй tick → reminders:[] (повторно не «выстреливает») =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "второй tick 200, reminders пуст" '[HTTP:200]' '"reminders":[]'
|
||||
|
||||
echo
|
||||
echo "== 9. Выключенные напоминания: tick чистит протухшие, reminders:[] =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "вторая карточка создана" '[HTTP:200]' '"local":true'
|
||||
PRJ2=$(extract_id)
|
||||
echo " -> PRJ2: $PRJ2"
|
||||
if [ -z "$PRJ2" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ2/move" > "$OUT"
|
||||
check "move hold PRJ2 200" '[HTTP:200]' '"stage":"hold"'
|
||||
AT_FUTURE=$(( $(date +%s) * 1000 + 120000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_FUTURE}" "$BASE_URL/api/projects/$PRJ2/reminder" > "$OUT"
|
||||
check "POST reminder PRJ2 (будущее) 200" '[HTTP:200]' '"reminder":{"at":'"$AT_FUTURE"'}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
|
||||
# Протухшая строка при выключенной настройке (имитация «осталась от включённого режима»).
|
||||
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"ReminderAt\" = now() - interval '1 minute' WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "tick при выключенных → reminders:[]" '[HTTP:200]' '"reminders":[]'
|
||||
CLEARED=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;")
|
||||
if [ "$CLEARED" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: протухшее напоминание PRJ2 очищено (ReminderAt NULL, ReminderFired false)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: протухшее напоминание PRJ2 не очищено (строк с ReminderAt NULL: $CLEARED)"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH restore remindersEnabled=true 200" '[HTTP:200]' '"remindersEnabled":true'
|
||||
|
||||
echo
|
||||
echo "== 10. Logout → tick → 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,71 @@
|
||||
# Task 11 — POST /api/admin/tick: reminders [{id,title,stage}] + SSE reminder_due — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0; **617/617 PASS** — 614 этапов 1–10 + 3 новых AdminTickOrchestratorTests;
|
||||
curl-приёмка :5080 — **23/23 PASS**). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 11 (L434–452), Ruling 3 (напоминания
|
||||
«Отложено»: CheckDueAsync disabled→очистка протухших+[] / enabled→due-fired {id,title,stage}) и Ruling 8
|
||||
(SSE `reminder_due` несёт {id,title,stage}, публикации только из Api, **дополнительный toast НЕ шлём** — у
|
||||
фронта модалка ReminderNotice); источники `backend/app/routers/dashboard_routes.py` admin_tick L327–337,
|
||||
`backend/app/services/projects.py` check_reminders L264–282, api-map §3.2 L103–112.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Изменён — `Deal.Api/AdminTickOrchestrator.cs`
|
||||
- Новая зависимость конструктора `ProjectReminderService reminders` (scoped, регистрация уже была через
|
||||
`AddProjectsModule` — Task 10; в unit собирается на FakeProjectStore/FakeSettingsStore).
|
||||
- Константа `ReminderDueEventType = "reminder_due"` (рядом с `NewLeadEventType`).
|
||||
- В `TickAsync` между тостами и pump — шаг (4) «проверка напоминаний» (1:1 с admin_tick L332–337: тик →
|
||||
purge → тосты → **check_reminders** → pump → …): `reminders.CheckDueAsync(ct)` → по каждому due —
|
||||
`broker.Publish(tenantId, "reminder_due", due)` (после MarkFired внутри сервиса — как прототип L277–281).
|
||||
Ответ тика возвращает те же записи в `reminders` (после SSE). Сбой проверки НЕ роняет тик: `catch` →
|
||||
лог-предупреждение + `reminders:[]` (очередь/хранение продолжают); `OperationCanceledException`
|
||||
пробрасывается (запрос прерван) — паттерн ветки pump. Класс-док: порядок теперь (1)–(7).
|
||||
|
||||
### Изменён — `Deal.Api/AdminTickResultDto.cs`
|
||||
- `Reminders: IReadOnlyList<object>` → типизированный `IReadOnlyList<ProjectReminderDueDto>` (+using моделей
|
||||
модуля Projects). XML-doc: reminders — «выстрелившие» напоминания {id,title,stage} = список SSE
|
||||
reminder_due тика (этап 5); пусто — сработавших нет либо проверка недоступна.
|
||||
|
||||
### Изменён — `Deal.Api/Endpoints/StorageEndpoints.cs`
|
||||
- Только документация (класс + AdminTickAsync): контракт теперь {storage, reminders, pipeline, queue},
|
||||
шаг напоминаний в составе тика; сбой проверки напоминаний/pump не роняет тик.
|
||||
|
||||
### Изменён — тесты `tests/Deal.Tests.Unit/`
|
||||
- `FakeProjectStore.cs` — `sealed` снят + `ListDueAsync` → `virtual` (прецедент FakePipelineStore.ListAsync:
|
||||
тестовый подкласс со сбоем). Поведение не менялось.
|
||||
- `AdminTickOrchestratorTests.cs` — +3 теста (ниже); Context дополнен `ProjectStore`/`Settings`;
|
||||
`CreateContext` собирает реальный `ProjectReminderService` на общих фейках; подкласс
|
||||
`ThrowingDueProjectStore` (ListDueAsync бросает) для сценария сбоя.
|
||||
|
||||
## Решения и замечания
|
||||
- **Toast НЕ публикуется** (проверено по прототипу/плану): admin_tick L334 → check_reminders L280–281 шлёт
|
||||
только `reminder_due`; Ruling 8 L167 — «дополнительный toast НЕ шлём». В tick-ответе и SSE — только
|
||||
{id,title,stage}.
|
||||
- **Fake «ProjectReminderService»** (по Acceptance): сервис конкретный и sealed, порт-интерфейса у
|
||||
оркестратора нет — по конвенции AdminTickOrchestratorTests тесты собирают реальный сервис модуля на
|
||||
фейках; сбой проверки имитируется на слое хранилища (`ThrowingDueProjectStore`), как pump-сбой через
|
||||
`ThrowingQueueReadPipelineStore`. Продакшн-код ради тестов не абстрагировался.
|
||||
- **Порядок в тике**: reminders-проверка ДО pump и ДО new_lead (1:1 с admin_tick L334–336); reminder_due
|
||||
уходят до событий pump; reminders ответа — после SSE (api-map §3.2 L103–112).
|
||||
- **Титул в curl-приёмке — ASCII** («Bot hold»): русский текст в теле `curl -d` на Windows-native curl
|
||||
уходит в ANSI-кодировке (сервер отвечал 500 «Cannot transcode invalid UTF-8») — это ограничение харнесса
|
||||
приёмки, не API (SSE/reminders с русским заголовком покрыты unit-тестами: title «Отложенный бот»).
|
||||
|
||||
## Проверка
|
||||
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — **617/617 PASS** (614 + 3 новых AdminTickOrchestratorTests:
|
||||
due-reminder → reminders ответа + SSE reminder_due + MarkFired; remindersEnabled=false → пусто и очистка
|
||||
протухших; сбой ListDueAsync → reminders:[] без падения тика, pump продолжает).
|
||||
3. Curl-приёмка :5080 (`task-11-curl-acceptance.sh` → `task-11-curl-acceptance.log`) — **PASS=23 FAIL=0**:
|
||||
очистка ProjectCards/settings → запуск (LocalFileStorage) → 401 без куки на tick → login → локальная
|
||||
карточка → move hold → SSE-подписка (curl -N) → reminder {at: now−1 мин} (прошлое допустимо, Ruling 3) →
|
||||
POST /admin/tick → ответ `reminders:[{id,title,stage:'hold'}]`, SSE-подписчику пришло `event: reminder_due`
|
||||
`data:{"id":…,"title":"Bot hold","stage":"hold"}`, psql `ReminderFired=t` (MarkFired) → второй tick:
|
||||
`reminders:[]` (повторно не «выстреливает») → remindersEnabled=false + протухшая строка (psql aging) →
|
||||
tick: `reminders:[]`, psql ReminderAt=NULL (очистка L266–269) → restore true → logout → 401. Строки/
|
||||
настройки очищены после приёмки, порт :5080 свободен.
|
||||
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderDueEventType);
|
||||
комментарии на русском; без регионов.
|
||||
|
||||
## Отчёт
|
||||
`.superpowers/sdd/deal-stage5-projects/task-11-report.md`; ledger progress.md обновлён (Task 11 complete).
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 12 curl-приёмка: фоновый 30-с цикл StorageTickScheduler проверяет due-напоминания БЕЗ ручного tick
|
||||
# на :5080 (план Task 12 L454-472; Rulings 3/8; _storage_loop main.py L47-53: тик -> тосты -> check_reminders;
|
||||
# projects.py check_reminders L264-282). Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск
|
||||
# Deal.Api (Development, DEAL_DEMO=1) -> login -> локальная карточка + move hold + PATCH title -> SSE-подписка
|
||||
# (curl -N в фон) -> POST reminder {at: now-1 мин} -> НЕ вызывая POST /admin/tick дожидаемся фонового прохода
|
||||
# (поллинг psql ReminderFired до ~45 с): SSE-подписчику приходит reminder_due {id,title,stage:'hold'}, psql
|
||||
# ReminderFired=true (MarkFired внутри фоновой ветки) -> вторая карточка + reminder в будущем + aging в прошлое
|
||||
# + remindersEnabled=false -> следующий фоновый проход: reminders НЕ «выстреливают» (событий reminder_due больше
|
||||
# нет), протухшая строка PRJ2 очищена (ReminderAt NULL, L266-269) -> restore true -> logout -> 401. Очистка
|
||||
# строк/настроек после приёмки. Окно прохода — 30 с: приёмка терпит ожидание (поллинг, не sleep на фикс. 30).
|
||||
|
||||
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"
|
||||
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
|
||||
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
|
||||
TMPB=$(cygpath -m /tmp)/task12
|
||||
JAR="$TMPB/jar.txt"
|
||||
OUT="$TMPB/out.txt"
|
||||
HDR="$TMPB/hdr.txt"
|
||||
LOG="$TMPB/api.log"
|
||||
SSE_LOG="$TMPB/sse.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
APP_PID=""
|
||||
SSE_PID=""
|
||||
PRJ=""
|
||||
PRJ2=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка по содержимому файла ($1), не $OUT.
|
||||
check_file() {
|
||||
desc=$1
|
||||
file=$2
|
||||
shift 2
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$file"; 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 — не найдено в $file: $*"
|
||||
echo "--- содержимое:"
|
||||
cat "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_) из JSON-тела ответа.
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
|
||||
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
|
||||
kill "$SSE_PID" 2>/dev/null
|
||||
fi
|
||||
stop_app "$APP_PID"
|
||||
if [ -n "$PRJ" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
|
||||
fi
|
||||
if [ -n "$PRJ2" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
|
||||
fi
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
|
||||
rm -rf "$TMPB"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$HDR" "$LOG" "$SSE_LOG"
|
||||
mkdir -p "$TMPB"
|
||||
|
||||
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
grep -q 'LocalFileStorage' "$LOG"
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
|
||||
head -n 3 "$LOG"
|
||||
fi
|
||||
echo " health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 3. Локальная карточка → move hold → заголовок (SSE/psql будут проверять по нему) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
|
||||
PRJ=$(extract_id)
|
||||
echo " -> PRJ: $PRJ"
|
||||
if [ -z "$PRJ" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
|
||||
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"title":"Bot bg"}' "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "PATCH title 200" '[HTTP:200]' '"title":"Bot bg"'
|
||||
|
||||
echo
|
||||
echo "== 4. SSE-подписка на /api/events (фон) + reminder в прошлом (at = now-1 мин) =="
|
||||
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
|
||||
SSE_PID=$!
|
||||
sleep 2
|
||||
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
|
||||
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
|
||||
|
||||
echo
|
||||
echo "== 5. Ждём фоновый 30-с проход БЕЗ ручного tick: поллинг psql ReminderFired (до ~45 с) =="
|
||||
echo " (ручной POST /admin/tick в этой приёмке НЕ вызывается — сработать должен фоновый цикл)"
|
||||
FIRED=""
|
||||
i=0
|
||||
while [ "$i" -lt 45 ]; do
|
||||
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" 2>/dev/null)
|
||||
if [ "$FIRED" = "t" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 2))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$FIRED" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] фоновый проход сработал за ~$((i + 2)) с: psql ReminderFired=true (MarkFired)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] за 45 с фоновый цикл не пометил напоминание fired (ReminderFired=$FIRED)"
|
||||
echo "--- лог Api (хвост):"
|
||||
tail -n 20 "$LOG"
|
||||
fi
|
||||
echo "--- события SSE на момент срабатывания:"
|
||||
cat "$SSE_LOG"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 6. SSE-подписка получила reminder_due {id,title,stage:'hold'} =="
|
||||
check_file "SSE: событие reminder_due пришло (фоновый цикл, без tick)" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ"'"' '"title":"Bot bg"' '"stage":"hold"'
|
||||
|
||||
echo
|
||||
echo "== 7. Выключенные напоминания: следующий проход НЕ «выстреливает», протухшее очищается =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "вторая карточка создана" '[HTTP:200]' '"local":true'
|
||||
PRJ2=$(extract_id)
|
||||
echo " -> PRJ2: $PRJ2"
|
||||
if [ -z "$PRJ2" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ2/move" > "$OUT"
|
||||
check "move hold PRJ2 200" '[HTTP:200]' '"stage":"hold"'
|
||||
AT_FUTURE=$(( $(date +%s) * 1000 + 120000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_FUTURE}" "$BASE_URL/api/projects/$PRJ2/reminder" > "$OUT"
|
||||
check "POST reminder PRJ2 (будущее) 200" '[HTTP:200]' '"reminder":{"at":'"$AT_FUTURE"'}'
|
||||
# Выключаем ДО aging: между отключением и следующим проходом комбинации enabled+due не будет — PRJ2 не
|
||||
# «выстрелит» (гонки с идущим проходом нет: до aging ReminderAt в будущем, после aging цикл уже disabled).
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
|
||||
# Протухшая строка при выключенной настройке (имитация «осталась от включённого режима», как T11-приёмка).
|
||||
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"ReminderAt\" = now() - interval '1 minute', \"ReminderFired\" = false WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
|
||||
|
||||
echo " Ждём следующий фоновый проход (поллинг psql ReminderAt PRJ2 до ~45 с)..."
|
||||
CLEARED=""
|
||||
i=0
|
||||
while [ "$i" -lt 45 ]; do
|
||||
CLEARED=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;" 2>/dev/null)
|
||||
if [ "$CLEARED" = "1" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 2))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$CLEARED" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] фоновый проход при выключенных очистил протухшее PRJ2 (ReminderAt NULL, ReminderFired false)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] протухшее напоминание PRJ2 не очищено за 45 с (строк ReminderAt NULL: $CLEARED)"
|
||||
fi
|
||||
|
||||
EVENTS_TOTAL=$(grep -c 'event: reminder_due' "$SSE_LOG")
|
||||
if [ "$EVENTS_TOTAL" = "1" ] && ! grep -q '"id":"'"$PRJ2"'"' "$SSE_LOG"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] SSE: событие reminder_due за приёмку ровно одно (PRJ2 не «выстрелил» при выключенных)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] SSE: reminder_due событий = $EVENTS_TOTAL (ожидалось 1), PRJ2 в логе: $(grep -c '"id":"'"$PRJ2"'"' "$SSE_LOG")"
|
||||
echo "--- sse.log:"
|
||||
cat "$SSE_LOG"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
|
||||
check "PATCH restore remindersEnabled=true 200" '[HTTP:200]' '"remindersEnabled":true'
|
||||
|
||||
echo
|
||||
echo "== 8. Logout → tick → 401 (ручной тик не использовался в сценарии выше) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
|
||||
check "tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 12 — Фоновая проверка напоминаний в StorageTickScheduler (30 с) — отчёт
|
||||
|
||||
Статус: **complete** (build 0/0; **620/620 PASS** — 617 этапов 1–11 + 3 новых StorageTickSchedulerTests;
|
||||
curl-приёмка :5080 — **19/19 PASS**). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 12 (L454–472), Rulings 3/8; источники
|
||||
`backend/app/main.py` _storage_loop L43–53 (порядок: тик → тосты → check_reminders), `backend/app/services/projects.py`
|
||||
check_reminders L264–282, AdminTickOrchestrator (ручной аналог, Task 11).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Изменён — `Deal.Api/Hosting/StorageTickScheduler.cs`
|
||||
- Новая singleton-зависимость конструктора `SseBroker broker` (эталон StorageToastPublisher L36–44: guard
|
||||
`ArgumentNullException.ThrowIfNull` + private readonly) и константа `ReminderDueEventType = "reminder_due"`.
|
||||
- В `TickTenantAsync` после Kanban-тика/purge/тостов — шаг проверки напоминаний (1:1 с _storage_loop main.py
|
||||
L47–53): `ProjectReminderService` резолвится из tenant-scope ПОСЛЕ `SetTenant` (как остальные адаптеры) →
|
||||
`CheckDueAsync(ct)` (помечает due-строки hold fired, возвращает {id,title,stage}) → SSE `reminder_due` по
|
||||
каждой записи в канал тенанта (`broker.Publish(tenant.Id, …)`, Ruling 8: toast НЕ шлём, без подписчиков —
|
||||
no-op). Сбой ветки НЕ роняет тик тенанта/проход: `OperationCanceledException` пробрасывается (остановка
|
||||
хоста), прочие — `LogWarning` «проверка напоминаний тенанта не удалась» и пустой список (паттерн ветки
|
||||
AdminTickOrchestrator). Класс-док и док `TickTenantAsync` обновлены (задачи 11–12, порядок тик → тосты → напоминания).
|
||||
|
||||
### Изменён — тесты `tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs`
|
||||
- DI-провайдер дополнен: `IProjectStore` по тенанту (FakeProjectStore, паттерн IKanjStore/ISettingsStore) +
|
||||
scoped `ProjectReminderService` (реальный сервис на фейках, как AdminTickOrchestratorTests); `CreateScheduler`
|
||||
передаёт SseBroker; класс-док обновлён.
|
||||
- +3 теста: (1) due-reminder hold в прошлом → ровно одно SSE `reminder_due` {id,title,stage} в канал тенанта A
|
||||
(у B событий нет), MarkFired (повторный ListDueAsync пуст), контекст сброшен; (2) remindersEnabled=false →
|
||||
событий нет, протухшее очищено (Reminder null, L266–269); (3) сбой ветки тенанта A (подкласс
|
||||
`ThrowingDueProjectStore`, ListDueAsync бросает — прецедент T11) → тик A не падает, тенант B обрабатывается
|
||||
(автоархив + тост), проход жив. Хелперы: `HoldCard`/`NowMs`/`ReadEvents`.
|
||||
|
||||
## Решения и замечания
|
||||
- **Оркестратор фоном НЕ переиспользуется** (в его составе pump/new_lead/queue — это отдельный 2-с цикл
|
||||
PipelineWorkerScheduler): per-tenant шаг добавлен в StorageTickScheduler, как и предписывает Task 12. Логика
|
||||
проверки НЕ дублируется — переиспользуется модульный `ProjectReminderService.CheckDueAsync` (фоновый тик и
|
||||
ручной тик T11 зовут один и тот же сервис); переиспользованы существующие StorageTickService/
|
||||
PipelineProcessingService/StorageToastPublisher. Публикация SSE — тот же 2-строчный идиоматический вызов
|
||||
брокера, что в AdminTickOrchestrator (прецедент new_lead/toast: публикации из Api-классов без общего хелпера
|
||||
для событий с одной точкой цикла).
|
||||
- **Порядок**: тик → purge → тосты → напоминания (1:1 с _storage_loop main.py L47–53: тик → тосты →
|
||||
check_reminders); purge остаётся сразу после тика, как в Task 11.
|
||||
- **Заголовок curl-приёмки — ASCII** («Bot bg»): ограничение Windows-native curl (ANSI-тело → 500), как в
|
||||
T11-приёмке; русский title покрыт unit-тестом («Отложенный бот»).
|
||||
|
||||
## Проверка
|
||||
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — **620/620 PASS** (617 + 3 новых).
|
||||
3. Curl-приёмка :5080 (`task-12-curl-acceptance.sh` → `task-12-curl-acceptance.log`) — **PASS=19 FAIL=0**:
|
||||
очистка ProjectCards/settings → запуск (LocalFileStorage) → login → локальная карточка → move hold → PATCH
|
||||
title → SSE-подписка → reminder {at: now−1 мин} → **БЕЗ ручного POST /admin/tick** фоновый 30-с проход за
|
||||
~24 с: psql ReminderFired=t (MarkFired), SSE-подписчику пришло `event: reminder_due`
|
||||
`data:{"id":…,"title":"Bot bg","stage":"hold"}` → PRJ2 (reminder будущее → aging в прошлое + выключенные) →
|
||||
следующий проход: события reminder_due БОЛЬШЕ нет (ровно 1 за приёмку), psql ReminderAt=NULL/Fired=false
|
||||
(очистка L266–269) → restore true → logout → tick 401. Строки/настройки очищены после приёмки, порт :5080
|
||||
свободен, процесс остановлен.
|
||||
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderDueEventType);
|
||||
комментарии на русском; без регионов.
|
||||
|
||||
## Отчёт
|
||||
`.superpowers/sdd/deal-stage5-projects/task-12-report.md`; ledger progress.md обновлён (Task 12 complete).
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 13 — ФИНАЛ этапа 5: сквозная curl-приёмка «Выбранных» на :5080 (план Task 13 L474-494,
|
||||
# Rulings 3/5/6/7/9/11; projects_routes.py целиком; api-map §3.5 L153-174, §2 SSE L33-43, §4.3 L280-304).
|
||||
# Один сквозной сценарий: очистка канбана/проектных таблиц/очереди/отсева + вложений -> запуск Deal.Api
|
||||
# (Development, DEAL_DEMO=1, LocalFileStorage) -> 401 без куки -> login -> GET /projects {items:[]} ->
|
||||
# demo-лид (simulate) -> take {leadId}: лид col=taken + is_new=false (psql), исчез из /leads и /api/search,
|
||||
# проектная local=false/planned/leadId/история created/комментарий «Взял в работу из лида.», поля Title/
|
||||
# Summary скопированы (psql), повторный take -> та же карточка -> PATCH карточки (title/stack/budget/contact/
|
||||
# tzText) -> локальная карточка POST /projects (поля) -> PATCH (в т.ч. budget:null) -> move reply->work->hold
|
||||
# (история: 4 записи) -> комментарий -> ссылки add/remove -> hold-стадия + POST reminder {at: now-1 мин} ->
|
||||
# БЕЗ ручного tick фоновый 30-с проход StorageTickScheduler: SSE reminder_due {id,title:'T13 Hold Card',
|
||||
# stage:'hold'} + psql ReminderFired=t -> move hold->ready (reminder null) -> файлы: upload 2 (tz.pdf
|
||||
# document/Документ, photo.png image/Изображение) -> мета в карточке (files) + объекты на диске ->
|
||||
# download (байты совпадают, attachment, Content-Length) -> DELETE photo (файл ушёл с диска и из меты) ->
|
||||
# GET /projects (UpdatedAt DESC: локальная первая) -> move локальной в rejected -> clear-rejected
|
||||
# {ok,cleared:1} (повторный -> cleared:0) -> psql: UNIQUE LeadId (вставка дубля -> ошибка индекса) ->
|
||||
# GET /projects/reminders и DELETE /{id} — 404 маршрута нет (Ruling 9) -> logout -> 401. После приёмки —
|
||||
# остановка Api, очистка созданных строк/вложений (dev-БД чиста для этапа 6).
|
||||
# Примечания: title карточки для SSE/reminder — ASCII (ограничение Windows-native curl, как T11/T12);
|
||||
# тела запросов — ASCII; русские строки проверяются в ОТВЕТАХ сервера (UTF-8).
|
||||
|
||||
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"
|
||||
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' и пути -F/@
|
||||
# не конвертируются MSYS-рантаймом корректно; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
|
||||
TMPB=$(cygpath -m /tmp)/task13
|
||||
SRC="$TMPB/files"
|
||||
JAR="$TMPB/jar.txt"
|
||||
OUT="$TMPB/out.txt"
|
||||
HDR="$TMPB/hdr.txt"
|
||||
HDRN="$TMPB/hdrn.txt"
|
||||
DL="$TMPB/dl.bin"
|
||||
LOG="$TMPB/api.log"
|
||||
SSE_LOG="$TMPB/sse.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
ATTACH="$API_DIR/data/attachments"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
APP_PID=""
|
||||
SSE_PID=""
|
||||
PRJ_A=""
|
||||
PRJ_L=""
|
||||
LEAD_ID=""
|
||||
LEAD_TOKEN=""
|
||||
LINK_A=""
|
||||
FID_PDF=""
|
||||
FID_PNG=""
|
||||
KEY_PDF=""
|
||||
KEY_PNG=""
|
||||
SZ_PDF=""
|
||||
SZ_PNG=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка по содержимому файла ($1), не $OUT.
|
||||
check_file() {
|
||||
desc=$1
|
||||
file=$2
|
||||
shift 2
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$file"; 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 — не найдено в $file: $*"
|
||||
echo "--- содержимое:"
|
||||
cat "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка по заголовкам ответа (нормализованы в $HDRN: lowercase, без \r).
|
||||
header_check() {
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$HDRN"; 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 "$HDRN"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_/l_) из JSON-тела ответа (тело — первая строка $OUT, вторая — служебный [HTTP:...]).
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\|l_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# Первый id карточки списка GET /api/projects (UpdatedAt DESC — первая строка items).
|
||||
extract_first_list_id() {
|
||||
sed -n '1{s/.*"items":\[{"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# updatedAt (epoch-ms) из тела ответа.
|
||||
extract_updated_at() {
|
||||
sed -n '1{s/.*"updatedAt":\([0-9][0-9]*\).*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# Токен поиска из поля contact лида («@crm_head» → crm_head): для проверки «лид исчез из /api/search».
|
||||
extract_contact_token() {
|
||||
sed -n '1{s/.*"contact":"@\([A-Za-z0-9_]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# n-й файловый id (pf_) из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
|
||||
file_id_at() {
|
||||
grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$1" | sed -n "${2}s/.*\"id\":\"\(pf_[0-9a-f][0-9a-f]*\)\"/\1/p"
|
||||
}
|
||||
|
||||
# n-й objectKey из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
|
||||
object_key_at() {
|
||||
grep -o '"objectKey":"[^"]*"' "$1" | sed -n "${2}s/.*\"objectKey\":\"\([^\"]*\)\"/\1/p"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк/настроек/вложений =="
|
||||
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
|
||||
kill "$SSE_PID" 2>/dev/null
|
||||
fi
|
||||
stop_app "$APP_PID"
|
||||
if [ -n "$LEAD_ID" ]; then
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$LEAD_ID';" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';" >/dev/null 2>&1
|
||||
fi
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" IN ('$PRJ_A','$PRJ_L');" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
|
||||
rm -rf "$ATTACH" 2>/dev/null
|
||||
rm -rf "$TMPB"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$DL" "$LOG" "$SSE_LOG"
|
||||
mkdir -p "$TMPB" "$SRC"
|
||||
printf '%s' '%PDF-1.4 Task13 tz document bytes 1234567890' > "$SRC/tz.pdf"
|
||||
printf '%s' 'Task13 photo bytes png 0987654321 xyz' > "$SRC/photo.png"
|
||||
SZ_PDF=$(wc -c < "$SRC/tz.pdf")
|
||||
SZ_PNG=$(wc -c < "$SRC/photo.png")
|
||||
|
||||
echo "== 0. Очистка канбана/проектных таблиц/очереди/отсева и вложений дефолтного тенанта (повторяемость) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
|
||||
rm -rf "$ATTACH" 2>/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\") + (SELECT count(*) FROM \"$SCHEMA\".\"QueueItems\") + (SELECT count(*) FROM \"$SCHEMA\".\"RejectedItems\");")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] Cards/ProjectCards/QueueItems/RejectedItems пусты, вложения удалены, remindersEnabled — дефолт (true)"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
grep -q 'LocalFileStorage' "$LOG"
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
|
||||
head -n 3 "$LOG"
|
||||
fi
|
||||
echo " health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. 401 без сессии на /api/projects* =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"leadId":"l_x"}' "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "POST /take без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== 3. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 4. GET /api/projects пуст (заглушка снята: реальный список); 404 маршрутов нет (Ruling 9) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects → 200 {items:[]}" '[HTTP:200]' '{"items":[]}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/reminders" > "$OUT"
|
||||
check "GET /api/projects/reminders → 404 (список напоминаний НЕ реализован, Ruling 9)" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
|
||||
check "DELETE /api/projects/{id} → 405 (маршрут DELETE не реализован, Ruling 9; .NET: 405 по пути GET/PATCH)" '[HTTP:405]'
|
||||
|
||||
echo
|
||||
echo "== 5. Demo-лид (simulate) → виден в inbox и в /api/search =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
|
||||
check "simulate-lead 200 (demo)" '[HTTP:200]' '"id":"l_' '"col":"inbox"'
|
||||
LEAD_ID=$(extract_id)
|
||||
LEAD_TOKEN=$(extract_contact_token)
|
||||
echo " -> LEAD_ID: $LEAD_ID, LEAD_TOKEN: $LEAD_TOKEN"
|
||||
if [ -z "$LEAD_ID" ] || [ -z "$LEAD_TOKEN" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
|
||||
check "лид виден в inbox до take" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/search?q=$LEAD_TOKEN" > "$OUT"
|
||||
check "поиск находит лида до take (контроль механизма)" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
|
||||
|
||||
echo
|
||||
echo "== 6. POST /api/projects/take {leadId}: лид уходит в taken, проектная создана =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "take лида 200: карточка из лида" '[HTTP:200]' '"local":false' "\"leadId\":\"$LEAD_ID\"" '"stage":"planned"'
|
||||
check "комментарий «Взял в работу из лида.»" '"text":"Взял в работу из лида."'
|
||||
check "история created" '"type":"created"'
|
||||
PRJ_A=$(extract_id)
|
||||
echo " -> PRJ_A: $PRJ_A"
|
||||
if [ -z "$PRJ_A" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
|
||||
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] лид остался виден в inbox после take"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] GET /leads?col=inbox больше не видит лида"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/search?q=$LEAD_TOKEN" > "$OUT"
|
||||
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] лид остался в /api/search после take (col=taken исключается из поиска)"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] /api/search больше не видит лида (taken)"
|
||||
fi
|
||||
LEAD_COL=$($PSQL_BASE -t -A -c "SELECT \"Col\" || '|' || \"IsNew\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';")
|
||||
if [ "$LEAD_COL" = "taken|false" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: лид col=taken, is_new=false"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] лид = $LEAD_COL (ожидалось taken|false)"
|
||||
fi
|
||||
PRJ_ROW=$($PSQL_BASE -t -A -c "SELECT \"Stage\" || '|' || \"Local\" || '|' || \"LeadId\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_A';")
|
||||
if [ "$PRJ_ROW" = "planned|false|$LEAD_ID" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: проектная карточка planned/local=false/leadId"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: строка ProjectCards = $PRJ_ROW"
|
||||
fi
|
||||
COPY_OK=$($PSQL_BASE -t -A -c "SELECT (c.\"Title\" = p.\"Title\") AND (c.\"Summary\" = p.\"Summary\") FROM \"$SCHEMA\".\"Cards\" c JOIN \"$SCHEMA\".\"ProjectCards\" p ON p.\"LeadId\" = c.\"Id\" WHERE c.\"Id\" = '$LEAD_ID';")
|
||||
if [ "$COPY_OK" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: Title/Summary скопированы из лида в проектную карточку"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: копирование полей лида нарушено (Title/Summary не равны)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 7. Повторный take того же лида — идемпотентность (та же карточка) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "повторный take → та же карточка" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"leadId\":\"$LEAD_ID\""
|
||||
|
||||
echo
|
||||
echo "== 8. PATCH карточки из лида (title/stack/budget/contact/tzText) → поля обновлены, updatedAt вырос =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_A" > "$OUT"
|
||||
T_BEFORE=$(extract_updated_at)
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"title":"T13 Patched Card","stack":["Go","Redis"],"budget":{"from":2000,"to":4000,"cur":"EUR"},"contact":"@t13patch","tzText":"patched tz"}' \
|
||||
"$BASE_URL/api/projects/$PRJ_A" > "$OUT"
|
||||
check "PATCH 200: изменения на месте" '[HTTP:200]' '"title":"T13 Patched Card"' '"stack":["Go","Redis"]' '"budget":{"from":2000,"to":4000,"cur":"EUR"}' '"contact":"@t13patch"' '"tzText":"patched tz"'
|
||||
T_AFTER=$(extract_updated_at)
|
||||
if [ -n "$T_BEFORE" ] && [ "$T_AFTER" -gt "$T_BEFORE" ] 2>/dev/null; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] updatedAt вырос ($T_BEFORE → $T_AFTER)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] updatedAt не вырос: $T_BEFORE → $T_AFTER"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 9. Локальная карточка POST /api/projects (поля) → local=true, createdLocal =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"title":"T13 Hold Card","summary":"local card summary","stack":["CSharp","SqlServer"],"budget":{"from":3000,"to":5000,"cur":"EUR"},"contact":"@t13local","tzText":"asap","stage":"planned"}' \
|
||||
"$BASE_URL/api/projects" > "$OUT"
|
||||
check "локальная карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"' '"title":"T13 Hold Card"' '"contact":"@t13local"' '"stack":["CSharp","SqlServer"]' '"budget":{"from":3000,"to":5000,"cur":"EUR"}'
|
||||
check "история createdLocal" '"type":"createdLocal"'
|
||||
PRJ_L=$(extract_id)
|
||||
echo " -> PRJ_L: $PRJ_L"
|
||||
if [ -z "$PRJ_L" ]; then exit 1; fi
|
||||
|
||||
echo
|
||||
echo "== 10. PATCH локальной карточки: budget → set, затем budget:null (presence-aware очистка) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"budget":{"from":1000,"to":2000,"cur":"USD"}}' "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
|
||||
check "PATCH budget 200" '[HTTP:200]' '"budget":{"from":1000,"to":2000,"cur":"USD"}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"budget":null}' "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
|
||||
check "PATCH budget:null → бюджет очищен" '[HTTP:200]' '"budget":null'
|
||||
|
||||
echo
|
||||
echo "== 11. Move по стадиям reply → work → hold: история растёт (4 записи: createdLocal + 3 stage) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"reply"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
|
||||
check "move reply 200" '[HTTP:200]' '"stage":"reply"' '"reminder":null'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"work"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
|
||||
check "move work 200" '[HTTP:200]' '"stage":"work"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
|
||||
check "move hold 200" '[HTTP:200]' '"stage":"hold"' '"reminder":null'
|
||||
H_TOTAL=$(grep -o '"id":"h_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
|
||||
if [ "$H_TOTAL" = "4" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] история на hold: 4 записи (1 createdLocal + 3 move со stage-ключами) — движение по стадиям дописывается"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] история на hold: h_=$H_TOTAL (ожидалось 4)"
|
||||
fi
|
||||
check "в истории есть move-записи reply и work (append при move)" '"stage":"reply"' '"stage":"work"'
|
||||
|
||||
echo
|
||||
echo "== 12. Комментарии и ссылки на локальной карточке =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"text":" "}' "$BASE_URL/api/projects/$PRJ_L/comments" > "$OUT"
|
||||
check "пустой комментарий → 400 «Пустой комментарий»" '[HTTP:400]' 'Пустой комментарий'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"text":"t13 comment one"}' "$BASE_URL/api/projects/$PRJ_L/comments" > "$OUT"
|
||||
check "комментарий → {comments:[...]}" '[HTTP:200]' '"comments":[{"id":"cm_' '"text":"t13 comment one"' '"by":"Вы"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"url":"example.com"}' "$BASE_URL/api/projects/$PRJ_L/links" > "$OUT"
|
||||
check "ссылка без схемы → https://, name = url" '[HTTP:200]' '"links":[{"id":"pl_' '"name":"https://example.com"' '"url":"https://example.com"'
|
||||
LINK_A=$(sed -n '1{s/.*"links":\[{"id":"\(pl_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT")
|
||||
echo " -> LINK_A: $LINK_A"
|
||||
if [ -z "$LINK_A" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"name":"Site","url":"http://x.ru"}' "$BASE_URL/api/projects/$PRJ_L/links" > "$OUT"
|
||||
check "вторая ссылка: http:// сохранён" '[HTTP:200]' '"name":"Site"' '"url":"http://x.ru"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_L/links/$LINK_A" > "$OUT"
|
||||
check "DELETE ссылки 200 — карточка без удалённой" '[HTTP:200]' "\"id\":\"$PRJ_L\""
|
||||
if grep -qF -- "$LINK_A" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] удалённая ссылка осталась в карточке"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] удалённой ссылки в ответе нет"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 13. SSE-подписка (фон) + POST reminder {at: now-1 мин} на hold-карточке =="
|
||||
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
|
||||
SSE_PID=$!
|
||||
sleep 2
|
||||
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ_L/reminder" > "$OUT"
|
||||
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}' '"stage":"hold"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
|
||||
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
|
||||
|
||||
echo
|
||||
echo "== 14. БЕЗ ручного tick ждём фоновый 30-с проход: psql ReminderFired (поллинг до ~60 с) =="
|
||||
echo " (ручной POST /admin/tick в сценарии НЕ вызывается — напоминание должен снять фоновый StorageTickScheduler)"
|
||||
FIRED=""
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L';" 2>/dev/null)
|
||||
if [ "$FIRED" = "t" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$FIRED" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] фоновый проход сработал за ~$((i * 2 + 2)) с: psql ReminderFired=true (MarkFired, без ручного tick)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] за ~60 с фоновый цикл не пометил напоминание fired (ReminderFired=$FIRED)"
|
||||
echo "--- лог Api (хвост):"
|
||||
tail -n 20 "$LOG"
|
||||
fi
|
||||
echo "--- события SSE на момент срабатывания:"
|
||||
cat "$SSE_LOG"
|
||||
echo
|
||||
check_file "SSE: событие reminder_due {id,title:'T13 Hold Card',stage:'hold'} пришло фоновым циклом" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ_L"'"' '"title":"T13 Hold Card"' '"stage":"hold"'
|
||||
EVENTS_TOTAL=$(grep -c 'event: reminder_due' "$SSE_LOG")
|
||||
if [ "$EVENTS_TOTAL" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] событий reminder_due за приёмку ровно одно"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] reminder_due событий = $EVENTS_TOTAL (ожидалось 1)"
|
||||
fi
|
||||
kill "$SSE_PID" 2>/dev/null
|
||||
SSE_PID=""
|
||||
|
||||
echo
|
||||
echo "== 15. Move hold → ready: напоминание снято (reminder null), история 5 записей =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"ready"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
|
||||
check "move ready 200" '[HTTP:200]' '"stage":"ready"' '"reminder":null'
|
||||
REMN=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;")
|
||||
if [ "$REMN" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: после move с hold напоминание очищено (ReminderAt NULL, ReminderFired false)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: напоминание не очищено move (строк ReminderAt NULL: $REMN)"
|
||||
fi
|
||||
H_TOTAL=$(grep -o '"id":"h_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
|
||||
if [ "$H_TOTAL" = "5" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] история на ready: 5 записей (append при каждом move)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] история на ready: h_=$H_TOTAL (ожидалось 5)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 16. Файлы: upload 2 (tz.pdf document, photo.png image) → мета в карточке + объекты на диске =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
|
||||
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
|
||||
-F "files=@$SRC/photo.png;type=image/png;filename=photo.png" \
|
||||
"$BASE_URL/api/projects/$PRJ_L/files" > "$OUT"
|
||||
check "upload 200 {items:[2]}" '[HTTP:200]' '"items":[' '"id":"pf_'
|
||||
check "tz.pdf → document/Документ" '"name":"tz.pdf"' '"kind":"document"' '"label":"Документ"'
|
||||
check "photo.png → image/Изображение" '"name":"photo.png"' '"kind":"image"' '"label":"Изображение"'
|
||||
check "size записей = размеры файлов" "\"size\":$SZ_PDF" "\"size\":$SZ_PNG"
|
||||
FID_PDF=$(file_id_at "$OUT" 1)
|
||||
FID_PNG=$(file_id_at "$OUT" 2)
|
||||
KEY_PDF=$(object_key_at "$OUT" 1)
|
||||
KEY_PNG=$(object_key_at "$OUT" 2)
|
||||
echo " -> FID_PDF: $FID_PDF, FID_PNG: $FID_PNG"
|
||||
echo " -> KEY_PDF: $KEY_PDF"
|
||||
if [ -z "$FID_PDF" ] || [ -z "$FID_PNG" ] || [ -z "$KEY_PDF" ] || [ -z "$KEY_PNG" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
|
||||
check "карточка с files: оба файла в массиве" '[HTTP:200]' '"files":[' '"name":"tz.pdf"' '"name":"photo.png"'
|
||||
FILES_COUNT=$(grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
|
||||
if [ "$FILES_COUNT" = "2" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] files содержит 2 записи (счётчики карточки)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] files содержит записей: $FILES_COUNT"
|
||||
fi
|
||||
if [ -f "$ATTACH/$KEY_PDF" ] && [ -f "$ATTACH/$KEY_PNG" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql/диск: объекты лежат по objectKey в data/attachments"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] объект(ы) не найдены на диске: $ATTACH/$KEY_PDF, $ATTACH/$KEY_PNG"
|
||||
fi
|
||||
FJ_OK=$($PSQL_BASE -t -A -c "SELECT position('$KEY_PDF' in \"FilesJson\") > 0 FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L';")
|
||||
if [ "$FJ_OK" = "t" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: FilesJson карточки содержит objectKey (мета ↔ объект)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: objectKey не найден в FilesJson ($FJ_OK)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 17. Download tz.pdf: 200, attachment, octet-stream, Content-Length, байты совпадают =="
|
||||
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L/files/$FID_PDF/download" > "$OUT"
|
||||
check "download 200" '[HTTP:200]'
|
||||
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
|
||||
header_check "Content-Disposition attachment + имя" 'content-disposition:' 'attachment' 'tz.pdf'
|
||||
header_check "Content-Type octet-stream (local-режим)" 'content-type: application/octet-stream'
|
||||
header_check "Content-Length = размер файла" "content-length: $SZ_PDF"
|
||||
if cmp -s "$SRC/tz.pdf" "$DL"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] байты download совпадают с загруженным tz.pdf"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] байты download НЕ совпадают с tz.pdf"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 18. DELETE photo.png: {ok:true}; мета и диск без файла; download удалённого → 404 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_L/files/$FID_PNG" > "$OUT"
|
||||
check "DELETE файла → 200 {ok:true}" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
|
||||
check "карточка без photo.png, tz.pdf жив" '"name":"tz.pdf"'
|
||||
if grep -qF -- '"name":"photo.png"' "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] photo.png остался в files карточки"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] photo.png удалён из files карточки"
|
||||
fi
|
||||
if [ -f "$ATTACH/$KEY_PNG" ]; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] объект photo.png остался на диске"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] объект photo.png удалён из data/attachments"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L/files/$FID_PNG/download" > "$OUT"
|
||||
check "download удалённого → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
|
||||
|
||||
echo
|
||||
echo "== 19. GET /api/projects — список из 2 карточек, первая = локальная (UpdatedAt DESC) ==";
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "обе карточки в списке" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"id\":\"$PRJ_L\""
|
||||
FIRST=$(extract_first_list_id)
|
||||
if [ "$FIRST" = "$PRJ_L" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] сортировка UpdatedAt DESC: первой идёт локальная (последнее изменение)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] первая карточка списка = $FIRST (ожидалась $PRJ_L)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 20. Локальная в rejected → clear-rejected {ok,cleared:1}; карточка из лида цела =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"rejected"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
|
||||
check "move PRJ_L в rejected 200" '[HTTP:200]' '"stage":"rejected"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
|
||||
check "clear-rejected → {ok:true, cleared:1}" '[HTTP:200]' '{"ok":true,"cleared":1}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
|
||||
check "повторный clear-rejected → cleared:0" '[HTTP:200]' '{"ok":true,"cleared":0}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects: карточка из лида жива, очищенной нет" '[HTTP:200]' "\"id\":\"$PRJ_A\""
|
||||
if grep -qF -- "\"id\":\"$PRJ_L\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] очищенная rejected-карточка осталась в списке"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] rejected-карточка удалена из списка"
|
||||
fi
|
||||
PRJ_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
|
||||
if [ "$PRJ_COUNT" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: в ProjectCards осталась только карточка из лида (строки всех сценариев отработаны)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: строк ProjectCards после clear-rejected = $PRJ_COUNT (ожидалось 1)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 21. psql: UNIQUE-индекс LeadId — вставка второго проекта с тем же лидом → ошибка =="
|
||||
DUP_ERR=$($PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"ProjectCards\" (\"Id\",\"Stage\",\"Local\",\"LeadId\",\"Title\",\"Summary\",\"StackJson\",\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"Contact\",\"CommentsJson\",\"LinksJson\",\"FilesJson\",\"HistoryJson\",\"TzText\",\"ReminderAt\",\"ReminderFired\",\"CreatedAt\",\"UpdatedAt\") SELECT 'pr_unique_dup000',\"Stage\",\"Local\",\"LeadId\",\"Title\",\"Summary\",\"StackJson\",\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"Contact\",\"CommentsJson\",\"LinksJson\",\"FilesJson\",\"HistoryJson\",\"TzText\",\"ReminderAt\",\"ReminderFired\",\"CreatedAt\",\"UpdatedAt\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_A';" 2>&1)
|
||||
echo "$DUP_ERR" | grep -q 'IX_ProjectCards_LeadId'
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: duplicate key по IX_ProjectCards_LeadId (partial UNIQUE LeadId работает)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: дубль LeadId не отклонён уникальным индексом:"
|
||||
echo "$DUP_ERR"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 22. Logout → 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Проверка лога Api: исключений нет =="
|
||||
if grep -qE 'Exception|\[ERR\]|Unhandled' "$LOG"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] в логе Api есть исключения:"
|
||||
grep -E 'Exception|\[ERR\]|Unhandled' "$LOG" | head -n 5
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] лог Api чист (без исключений)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" != 0 ]; then
|
||||
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] этап 5 Projects: сквозная приёмка пройдена"
|
||||
exit 0
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 13 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
|
||||
|
||||
Статус: **complete (review pending)**. Сборка 0 warnings / 0 errors (`dotnet build Deal.sln`,
|
||||
`sh scripts/build.sh`); unit-тесты **620/620 PASS** (`dotnet test tests/Deal.Tests.Unit`,
|
||||
`sh scripts/test.sh`); сквозная curl-приёмка на :5080 (DEAL_DEMO=1, admin/admin, LocalFileStorage) —
|
||||
**75/75 PASS** (скрипт `task-13-curl-acceptance.sh`, лог `task-13-curl-acceptance.log`, exit 0). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 13 (L474–494) + Self-Review; Rulings
|
||||
3/5/6/7/9/11. **Код и конфиги не менялись** — только доки/ledger и артефакты приёмки.
|
||||
|
||||
## Артефакты приёмки
|
||||
|
||||
- `task-13-curl-acceptance.sh` — один сквозной сценарий (все шаги ниже, PASS/FAIL каждого шага);
|
||||
- `task-13-curl-acceptance.log` — прогон: `== Итог: PASS=75 FAIL=0 ==` (exit 0).
|
||||
|
||||
## Сценарий (что реально проверено на :5080 одним прогоном)
|
||||
|
||||
1. Очистка канбана/проектных таблиц/очереди/отсева + вложений → старт Deal.Api (DEAL_DEMO=1,
|
||||
стартовый лог `LocalFileStorage`) → 401 без куки (GET /projects, POST /take) → login admin/admin.
|
||||
2. `GET /api/projects` → `{items:[]}` (boot-заглушка снята — реальный список); `GET
|
||||
/api/projects/reminders` → 404 «Карточка не найдена»; `DELETE /api/projects/{id}` → 405
|
||||
(маршрута DELETE нет — Ruling 9; детали ниже).
|
||||
3. **demo-лид (simulate)** → inbox и `/api/search?q=<contact-токен>` находят лида → **take {leadId}**:
|
||||
проектная карточка `local=false/planned/leadId`, история `created`, комментарий «Взял в работу из
|
||||
лида.»; psql: лид `Col='taken'` + `IsNew=false` в `Cards`, строка `ProjectCards` planned|false|leadId,
|
||||
Title/Summary скопированы из лида (join psql); лид исчез из `/api/leads?col=inbox` и `/api/search`;
|
||||
**повторный take → та же карточка** (идемпотентность).
|
||||
4. PATCH карточки из лида (title/stack/budget/contact/tzText) → поля обновлены, `updatedAt` вырос.
|
||||
5. **Локальное создание** POST /projects (title/summary/stack/budget/contact/tzText/stage) →
|
||||
`local=true`, `createdLocal`; PATCH `budget` → set, затем **`budget:null`** (presence-aware очистка);
|
||||
**move reply→work→hold** → история 4 записи (createdLocal + 3 move-записи со stage-ключами reply/work);
|
||||
комментарий (пустой 400 «Пустой комментарий» + текст `{comments}`); ссылки add (https-префикс/name=url,
|
||||
http:// сохранён) + DELETE одной.
|
||||
6. **hold + POST reminder {at: now−1 мин}** → **БЕЗ ручного POST /admin/tick** фоновый 30-с проход
|
||||
`StorageTickScheduler` за ~20 с: psql `ReminderFired=true`, SSE-подписчику пришло ровно одно
|
||||
`event: reminder_due` `data:{"id":…,"title":"T13 Hold Card","stage":"hold"}` → move hold→ready:
|
||||
напоминание снято (reminder null в ответе, psql ReminderAt NULL/Fired=false), история 5 записей.
|
||||
7. **Файлы**: upload 2 (tz.pdf → document/Документ, photo.png → image/Изображение, size = байты) → мета
|
||||
в карточке (`files` 2 записи), объекты на диске по objectKey (`data/attachments/projects/<id>/…`),
|
||||
`FilesJson` содержит objectKey → download tz.pdf (200, `attachment`, octet-stream, Content-Length,
|
||||
байты совпадают `cmp`) → DELETE photo.png ({ok}, мета без файла, объект удалён с диска, download
|
||||
удалённого → 404).
|
||||
8. `GET /api/projects` — список 2 карточек, первая = локальная (**UpdatedAt DESC**); локальную в
|
||||
rejected → `clear-rejected` `{ok,cleared:1}` (повторный → cleared:0); карточка из лида цела;
|
||||
psql: в ProjectCards осталась 1 строка.
|
||||
9. **psql partial UNIQUE**: вставка второго проекта с тем же `LeadId` → duplicate key
|
||||
`IX_ProjectCards_LeadId` (ошибка). logout → GET /projects 401. Лог Api без исключений.
|
||||
10. Финал: приложение остановлено; dev-БД очищена (Cards/ProjectCards/LeadComments/CardMoves/
|
||||
DedupEntries/QueueItems/RejectedItems = 0, настройки не тронуты), `data/attachments` пуст,
|
||||
схемы/таблицы/индексы/настройки на месте, `deal-minio` оставлен поднятым (dev-стек этапа 6).
|
||||
|
||||
## Что сделано (кроме приёмки — код/конфиги не менялись)
|
||||
|
||||
- Техдок `docs/technical/Техническая-документация-Дейл.md`: §13 — заголовок/интро на этап 5,
|
||||
§13.1 Postgres — deal-minio (:9000/:9001, бакет deal-files лениво, DEAL_MINIO_*); новый §4e
|
||||
«Эндпоинты этапа 5 (Projects/„Выбранные“)» (таблица ProjectCards + partial UNIQUE LeadId, стадии,
|
||||
take-семантика, 16 эндпоинтов, комментарии/ссылки/файлы/напоминания, SSE reminder_due, Local/MinIO,
|
||||
исключённые GET /reminders и DELETE /{id}); §4c — пометка о снятой boot-заглушке /projects (остался
|
||||
/tg/status); §5 (psql-ожидания: ProjectCards и ключевые колонки); §6 (620 PASS, финальная приёмка
|
||||
75/75); §11 — блок «Выполнено на этапе 5» + актуализированы TODO (осталась только /tg/status;
|
||||
список активных напоминаний и DELETE карточки — сознательно не реализованы, Ruling 9).
|
||||
- Roadmap `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: этап 5 перенесён в «Выполнено»
|
||||
(задачи 1–13, 620 PASS, curl 75/75; ограничения: реальные ai/telegram/ml и discovery — этап 6,
|
||||
оператор/лимиты/админка и мульти-аренда MinIO — этап 7), заголовок «актуально на конец этапа 5»,
|
||||
из «Оставшихся этапов» блок этапа 5 удалён.
|
||||
- Ledger `.superpowers/sdd/deal-stage5-projects/progress.md`: Task 13 complete + `[x]`.
|
||||
|
||||
## Находки/решения приёмки
|
||||
|
||||
- **`DELETE /api/projects/{id}` → 405, а не 404**: путь совпадает с зарегистрированными GET/PATCH
|
||||
`/{cardId}`, поэтому ASP.NET Core отвечает Method Not Allowed. «Маршрута DELETE нет» подтверждено
|
||||
(Ruling 9); в отчёте и техдоке зафиксировано фактическое поведение 405 (план допускал формулировку
|
||||
«404 маршрута нет» — семантика та же: эндпоинт не реализован). `GET /api/projects/reminders`
|
||||
(путь `{cardId}=reminders`) → 404 «Карточка не найдена».
|
||||
- **Записи истории смены стадии** несут ключ `stage` (не `type:"stage"`): wire {id, at, stage}, 1:1 с
|
||||
прототипом; в сценарии история проверяется подсчётом h_-записей и наличием move-записей reply/work.
|
||||
- **psql-конкатенация boolean** даёт `taken|false` (не `taken|f`) — проверка адаптирована.
|
||||
- Title карточки напоминания — ASCII «T13 Hold Card» (ограничение Windows-native curl, как T11/T12);
|
||||
русские строки проверялись в ответах сервера (take-комментарий «Взял в работу из лида.», 404-детали).
|
||||
- Напоминание сработало фоновым циклом за ~20 с (поллинг psql, ручной tick не вызывался); событий
|
||||
`reminder_due` за приёмку ровно одно.
|
||||
|
||||
## Concerns для следующих этапов
|
||||
|
||||
- Приём входящих — только demo-источники (simulate-lead/ingest) до gRPC-ингресса telegram-service
|
||||
(этап 6); контракт take/Projects стабилен (Ruling 5).
|
||||
- Файлы в сквозной приёмке проверены в Local-режиме (дефолт); MinIO-режим (deal-minio) проверен
|
||||
live-проверкой Task 6 — MinIO-ветки download (Content-Type из объекта) ждут этап-7 контура.
|
||||
- Dev-БД оставлена пустой (карточки/лиды/очередь/отсев = 0; схемы/таблицы/индексы/настройки на
|
||||
месте) — этап 6 может начинаться с чистого состояния; deal-minio поднят.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Task 2 — «Модуль Projects: стадии, DTO карточки, порт IProjectStore, реестр» — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 535/535 PASS, стадии 1:1 с constants.py PIPELINE_STAGES, модуль чист — без EF/HTTP, реверс-зависимостей нет).
|
||||
|
||||
## Файлы
|
||||
|
||||
Все — `src/core/Deal.Modules.Projects/` (namespace `Deal.Modules.Projects.Application[.Models]`; 1 тип = 1 файл, XML-doc, комментарии на русском).
|
||||
|
||||
### Каталог стадий (Application)
|
||||
|
||||
| Файл | Содержание |
|
||||
|---|---|
|
||||
| `Application/ProjectStage.cs` | `sealed record ProjectStage(string Id, string Name, string Color, bool Terminal)` — стадия канбана «Выбранных» (не сущность). |
|
||||
| `Application/ProjectStages.cs` | Каталог 9 стадий в порядке planned→rejected (1:1 constants.py PIPELINE_STAGES L17–27 / api-map §4.4): planned Запланировано `#818cf8`, reply Отклик `#38bdf8`, agree Согласование `#a78bfa`, work В работе `#fbbf24`, review Проверка `#f97316`, ready Готово `#4ade80`, hold Отложено `#94a3b8`, finished Выполнено `#2bd576` (terminal), rejected Отклонено `#ff6b6b` (terminal) + `Contains(stage)`. |
|
||||
| `Application/ProjectIdPrefixes.cs` | Префиксы id (Ruling 11): Card `pr_`, Link `pl_`, File `pf_`, History `h_`; комментарий — общий `KanbanIdPrefixes.Comment` (`cm_`) — закомментирован cref-ом, отдельной константы нет. |
|
||||
|
||||
### DTO (Application/Models, record — camelCase наружу)
|
||||
|
||||
| Файл | Форма |
|
||||
|---|---|
|
||||
| `ProjectFileDto.cs` | {id `pf_`, name, size (long), kind, label, objectKey} — §4.3 L292, Ruling 4. |
|
||||
| `ProjectLinkDto.cs` | {id `pl_`, name, url}. |
|
||||
| `ProjectHistoryEntryDto.cs` | {id `h_`, at (epoch-ms), type\|stage}; nullable-поля Type/Stage с `JsonIgnore(WhenWritingNull)` (wire — {id,at,type} либо {id,at,stage}, Ruling 7) + фабрики `Created(at, local)`/`Moved(at, stage)` (id генерирует модуль — Kanban `PrefixId`). |
|
||||
| `ProjectReminderDto.cs` | {at} — объект reminder карточки (Ruling 3). |
|
||||
| `ProjectCardDto.cs` | §4.3: id/stage/local/leadId/title/summary/stack/budget (`CardBudgetDto?` Kanban)/contact/comments (`CardCommentDto[]` Kanban)/links/files/tzText/history/reminder/createdAt/updatedAt (CreatedAtMs/UpdatedAtMs → epoch-ms, JsonPropertyName createdAt/updatedAt). |
|
||||
| `ProjectCardRow.cs` | Полная запись для `CreateAsync` (write-модель: id готов, JSON-массивы типизированы — сериализует адаптер; CreatedAt/UpdatedAt проставляет хранилище UTC-now; ReminderAt=null/fired=false — 1:1 _insert L71–100). |
|
||||
| `ProjectCardPatch.cs` | Частичная правка (title/summary/contact/tzText/stack/budget/comments/links/files); null = «не менять», JSON-поля — полная замена (конвенция BoardPatchDto). |
|
||||
| `ProjectReminderDueDto.cs` | Мини-DTO {id,title,stage} — возврат `ListDueAsync`/SSE reminder_due (Ruling 3/8). **Добавлен сверх списка файлов Task 2**: тип нужен сигнатуре порта (`ListDueAsync` → мини-DTO), в плане фигурирует в Task 10. |
|
||||
|
||||
### Порт и реестр
|
||||
|
||||
| Файл | Содержание |
|
||||
|---|---|
|
||||
| `Application/IProjectStore.cs` | Порт (эталон IKanjStore): методы 1:1 с планом и Self-Review 3 в том же порядке — `ListAsync(stage?)`, `GetAsync`, `GetByLeadAsync`, `CreateAsync(row)`, `PatchAsync(cardId, patch) → bool`, `MoveStageAsync(cardId, stage, historyEntry, atMs) → bool` (стадия + история + сброс reminder + bump UpdatedAt), `SetReminderAsync(cardId, atMs)`, `ClearReminderAsync(cardId)`, `ClearStageAsync(stage) → int`, `ListDueAsync(now) → IReadOnlyList<ProjectReminderDueDto>`, `MarkFiredAsync(ids)`, `ClearExpiredAsync(now) → int`, `RemoveAsync(cardId)`; все с `CancellationToken`, XML-doc со ссылками на projects.py. |
|
||||
| `Application/ProjectsModuleRegistrar.cs` | Каркас `AddProjectsModule()` — пустая цепочка (сервисы T4/T5/T7 добавляются по мере появления). |
|
||||
|
||||
### Изменён
|
||||
|
||||
- `Deal.Modules.Projects.csproj` — ProjectReference на `Deal.Modules.Settings` и `Deal.Modules.Kanban` (Contracts/SharedKernel уже были) + `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11 (как Kanban/Settings). Циклов нет: Kanban/Settings/Contracts о Projects не знают (проверено).
|
||||
|
||||
## Валидация
|
||||
|
||||
- `dotnet build Deal.sln` из `src/core`: Предупреждений 0, Ошибок 0.
|
||||
- `dotnet test tests/Deal.Tests.Unit --no-build`: 535/535 PASS (MarkerTests в составе).
|
||||
- Модуль чист: скан модуля на EF/Npgsql/Http (EntityFrameworkCore|Npgsql|AspNetCore|HttpClient|System.Net.Http) по *.cs — 0 в коде; единственные вхождения — XML-doc упоминания `Deal.Infrastructure` в IProjectStore.cs/ProjectsModuleRegistrar.cs (эталон: такие же doc-упоминания в KanbanModuleRegistrar) + сгенерированный `obj/.../GlobalUsings.g.cs` (артефакт сборки, не исходник).
|
||||
- Реверс-проверка: скан Kanban/Settings/Contracts (*.cs + *.csproj) на «Deal.Modules.Projects» — 0 совпадений.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
- `ProjectReminderDueDto.cs` создан в Task 2 (порт ссылается на тип; в плане файл не перечислен, но появляется в Task 10 — порт без него не компилируется). Тривиальный, без логики.
|
||||
- Id записи истории генерируется прямо в фабриках `Created/Moved` через Kanban `PrefixId.New(ProjectIdPrefixes.History)` — переиспользование публичного генератора владельца (эталон PipelineIdPrefixes), случайную часть даёт Kanban. Тесты при желании могут переопределить id через `with { Id = ... }`.
|
||||
- `ProjectCardPatch.Budget` = null означает «не менять» (конвенция BoardPatchDto); явная очистка бюджета телом PATCH (в прототипе budget не словарь → обнуление, patch_card L174–179) — вопрос слоя эндпоинта/сервиса Task 8 (отражено в XML-doc патча).
|
||||
- Имена методов порта — ровно по Self-Review 3 (список для Task 3 совпадает без расхождений).
|
||||
@@ -0,0 +1,81 @@
|
||||
# Task 3 — «EF-адаптер ProjectStore + DI» — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 535/535 PASS, функциональная dev-проверка адаптера на дефолтном тенанте 48/48, psql-проверка строки/JSON — зелёная, таблица ProjectCards возвращена в пустое состояние).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 3 (L250–269), Rulings 1/2/3/7/11;
|
||||
эталоны `KanbanStore.cs`/`PipelineStore.cs`/`SettingsStore.cs`; фактические сигнатуры порта — `Deal.Modules.Projects/Application/IProjectStore.cs` (Task 2) 1:1 со Self-Review 3.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан
|
||||
- `src/core/Deal.Infrastructure/Persistence/Repositories/ProjectStore.cs` — реализация `IProjectStore` на
|
||||
`TenantDbContext` (primary constructor, как `SettingsStore`). Все 13 методов порта 1:1 с
|
||||
`projects.py`: `ListAsync(stage?)` / `GetAsync` / `GetByLeadAsync` / `CreateAsync` / `PatchAsync` →
|
||||
bool / `MoveStageAsync` → bool / `SetReminderAsync` / `ClearReminderAsync` / `ClearStageAsync` → int /
|
||||
`ListDueAsync` → `{id,title,stage}` / `MarkFiredAsync` / `ClearExpiredAsync` → int / `RemoveAsync`.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs` — `AddDealPersistence()`: добавлен
|
||||
`AddScoped<IProjectStore, ProjectStore>()` (+ using модуля Projects; XML-doc списка адаптеров дополнен).
|
||||
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — ProjectReference → `Deal.Modules.Projects`
|
||||
(циклов нет: Projects зависит от Settings/Kanban/Contracts, о Infrastructure не знает).
|
||||
|
||||
## Реализация
|
||||
|
||||
- **Чтения** — `AsNoTracking()`; сортировка списка — `OrderByDescending(UpdatedAt)` (1:1 list_cards
|
||||
L58–63; индекс IX_ProjectCards_UpdatedAt DESC Task 1).
|
||||
- **Маппинг** вручную (порт не видит EF-сущности): `ToCardDto`/`ToCardEntity` + `ApplyPatch`.
|
||||
JSON-поля (StackJson/CommentsJson/LinksJson/FilesJson/HistoryJson) — text c JSON camelCase
|
||||
(конвенция value_json, эталон KanbanStore JsonOptions): запись `ToJson<T>`, чтение `ToJsonList<T>`
|
||||
(пустая/битая строка → пустой список, как `json.loads(... or "[]")`). Wire-формы 1:1 с §4.3:
|
||||
комментарий `{id,by,text,time}`, ссылка `{id,name,url}`, файл `{id,name,size,kind,label,objectKey}`,
|
||||
история `{id,at,type|stage}` (JsonIgnore WhenWritingNull — тип/стадия не смешиваются), бюджет —
|
||||
`CardBudgetDto|null` из пары (BudgetFrom, BudgetTo, BudgetCur): `BudgetCur == ""` → null (Ruling 11).
|
||||
- **Времена** — timestamptz (`DateTimeOffset`); наружу epoch-ms (`ToUnixTimeMilliseconds`), на запись —
|
||||
`FromUnixTimeMilliseconds`. `MoveStageAsync` пишет `updated_at = atMs` переноса (move_stage L210–215),
|
||||
`SetReminderAsync` бампает UpdatedAt (L236–243), `ClearReminderAsync` — без бампа (L246–247, 1:1).
|
||||
- **Патч** (`PatchAsync`): null-поле не меняется, JSON-поля — полная замена, в конце bump UpdatedAt
|
||||
(patch_card L159–187); bool = «строка обновлена» (404-семантика сервиса).
|
||||
- **Move** (`MoveStageAsync`): чтение AsNoTracking (нужна текущая история) → ОДИН `ExecuteUpdate`
|
||||
(stage + reminder_at=NULL + reminder_fired=false + updated_at + history с добавленной записью).
|
||||
- **Напоминания**: `ListDueAsync` — `Stage='hold' AND ReminderAt ≤ now AND ReminderFired=false`
|
||||
ORDER BY ReminderAt (check_reminders L270–275); `MarkFiredAsync` — ReminderFired=true по списку id;
|
||||
`ClearExpiredAsync` — ReminderAt=NULL+ReminderFired=false по всем протухшим (L266–269, fired не важен).
|
||||
- **Удаления** — одним statement'ом: `ClearStageAsync`/`RemoveAsync` — `ExecuteDeleteAsync` (счётчик =
|
||||
затронутые строки); частичный UNIQUE по LeadId (гонка take) страхует БД (Ruling 1) — адаптер её не дублирует.
|
||||
- Транзакции не потребовались: каждая операция — одиночный statement/SaveChanges (конвенция этапа 3–4).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. **Build**: `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — **535/535 PASS** (юниты на EF-адаптерах не пишем —
|
||||
конвенция этапа 4; проверка функциональная).
|
||||
3. **Dev-харнесс** (временный проект вне sln на реальном Postgres deal-postgres :5433, схема дефолтного
|
||||
тенанта, удалён после прогона): через `IProjectStore` (DI `AddDealPersistence` + scoped
|
||||
`TenantDbContext`) созданы локальная карточка и карточка из лида (все JSON-поля, бюджет, LeadId),
|
||||
проверены чтения/списки (UpdatedAt DESC, фильтр stage), патч всех полей (включая полную замену
|
||||
JSON-массивов и обнуление from бюджета), move + запись истории + updated_at=atMs + сброс reminder,
|
||||
напоминания (set/clear/due/fired/clear-expired/сброс при move), clear-stage, remove — **48/48 ok**.
|
||||
4. **psql** (схема `tenant_00000000000000000000000000000001`): строка `ProjectCards` подтверждена —
|
||||
`Stage='hold'`, camelCase-JSON в text-полях (кириллица хранится `\u`-эскейпами .NET — см. ниже),
|
||||
декодирование операторами json (`->>`: текст/автор/метки читаются как «Комментарий после патча.»/
|
||||
«Вы»/«Документы»), бюджет EUR to=300, ReminderAt NULL/ReminderFired=f, `UpdatedAt > CreatedAt` (bump).
|
||||
После проверки dev-строки удалены — таблица пуста (count 0).
|
||||
5. Диагностики изменённых файлов — без ошибок/предупреждений.
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **PatchAsync — отслеживаемая сущность + SaveChanges** (эталон `KanbanStore.UpdateColumnAsync`), а не
|
||||
условный `ExecuteUpdate`: в EF Core 10 публичный тип `SetPropertyCalls<TEntity>` (EF 7–9) отсутствует
|
||||
в сборке Relational (проверено по DLL/компилятору) — условный builder-сеттер не собрать без имени
|
||||
типа. SaveChanges пишет один UPDATE только изменённых колонок — семантика patch_card 1:1.
|
||||
- **MoveStageAsync — AsNoTracking + один ExecuteUpdate, независимо от change-трекера.** Первый прогон
|
||||
харнесса поймал ловушку: `CreateAsync` оставляет строку отслеживаемой, а `SetReminderAsync`/
|
||||
`ClearExpiredAsync` пишут через `ExecuteUpdate` (мимо трекера); последующий tracked-move видел
|
||||
устаревший `ReminderAt` (null из создания) и НЕ включал колонку в UPDATE — напоминание «оживало».
|
||||
Текущая реализация читает AsNoTracking и пишет одним statement'ом — от трекера не зависит (баг был
|
||||
только в харнессе/адаптере, до эндпоинтов не доходил; зафиксировано как решение).
|
||||
- **JSON в БД**: не-ASCII хранится `\uXXXX`-эскейпами (дефолтный encoder System.Text.Json, как в
|
||||
существующем KanbanStore) — семантически 1:1 с wire-формой, на чтении разбирается в исходный текст
|
||||
(проверено в харнессе 48/48 и psql `->>`); python-прототип писал ensure_ascii=False (косметика байт,
|
||||
на контракт не влияет — решили не отступать от эталона хранилищ).
|
||||
- Модуль Projects не тронут (кроме csproj-ссылки Infrastructure); циклов зависимостей нет.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Task 4 — «Взять в работу»: порт Kanban MarkTakenAsync + ProjectsService — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 552/552 PASS — 535 этапа 4 + 17 новых ProjectsServiceTests).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 4 (L271–299), Rulings 1/3/5/6/7/9/10/11;
|
||||
источники `projects.py` L103–156/L159–231, `projects_routes.py` L78–121, api-map §3.5/§4.3/§4.4;
|
||||
эталоны `CardsService`/`IKanjStore`/`LeadMoveResultDto`/`KanbanStore.UpdateSeenAsync`.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Modules.Kanban/Application/IKanjStore.cs` — метод порта `MarkTakenAsync(string cardId,
|
||||
CancellationToken ct) → Task<bool>` (секция «Карточки», после GetCardAsync): UPDATE Cards SET Col='taken',
|
||||
IsNew=false WHERE Id=? — «взять в работу» (take_lead_to_projects L155, Ruling 5); XML-doc: журнал
|
||||
CardMoves/архивные поля/matchHits не трогает; потребитель — модуль Projects, реверс-зависимостей нет.
|
||||
- `src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs` — реализация `MarkTakenAsync`
|
||||
одним `ExecuteUpdate` (col=taken, is_new=false), возврат `affected == 1` (эталон UpdateSeenAsync).
|
||||
- `src/core/Deal.Modules.Projects/Application/ProjectsModuleRegistrar.cs` — `AddProjectsModule()`
|
||||
регистрирует `AddScoped<ProjectsService>()` (вызов из Program.cs — Task 8, по плану).
|
||||
- `src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs` — расширение: `MarkTakenAsync` (1:1 с KanbanStore:
|
||||
col='taken' + is_new=false), счётчик `MarkTakenCalls`, флаг `FailMarkTaken` (гонка «лид исчез»); класс-док дополнен.
|
||||
|
||||
### Созданы
|
||||
- `src/core/Deal.Modules.Projects/Application/Models/ProjectCardResultDto.cs` — тонкий record-результат
|
||||
`(string? Error, ProjectCardDto? Card)` (эталон LeadMoveResultDto): Error — 400-строка, Card=null без
|
||||
Error — 404-семантика; переиспользуется мутациями Tasks 5/7.
|
||||
- `src/core/Deal.Modules.Projects/Application/Models/ProjectLocalCreateDto.cs` — начальные поля ручного
|
||||
создания: {title, summary, stack?, budget?, contact, tzText?, stage?} (Ruling 6; «ProjectCardPatch-начальные
|
||||
поля» + stage — в ProjectCardPatch поля stage нет, PATCH его не принимает, projects_routes L30–36).
|
||||
- `src/core/Deal.Modules.Projects/Application/ProjectsService.cs` — чистый сервис (зависимости
|
||||
IProjectStore + IKanjStore): `ListAsync(stage?)`, `GetAsync`, `CreateLocalAsync`, `TakeLeadAsync`,
|
||||
`PatchAsync`, `MoveAsync`, `ClearRejectedAsync`.
|
||||
- `src/core/tests/Deal.Tests.Unit/FakeProjectStore.cs` — in-memory `IProjectStore` (все 13 методов;
|
||||
поведение 1:1 с EF-адаптером ProjectStore: сортировка/фильтр, часы для CreatedAt/UpdatedAt, patch-семантика
|
||||
null-полей, move+история+сброс reminder+updated_at=atMs, «выстрелившие» напоминания множеством firedById).
|
||||
- `src/core/tests/Deal.Tests.Unit/ProjectsServiceTests.cs` — 17 тестов (см. ниже).
|
||||
|
||||
## Реализация
|
||||
|
||||
- **TakeLeadAsync** (1:1 take_lead_to_projects L127–156, Ruling 5): GetCardAsync → null-результат (404 «Лид не
|
||||
найден» у эндпоинта Task 8); GetByLeadAsync — есть карточка → возврат её (идемпотентность, без вставки);
|
||||
иначе CreateAsync: stage=planned, local=false, leadId, title/summary/stack/budget/contact из CardDto,
|
||||
comments=[{id `cm_`, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{type:"created"}],
|
||||
tzText=""; затем MarkTakenAsync; false (лид исчез в гонке) → RemoveAsync-откат + null. Журналов/ML/SSE нет.
|
||||
- **CreateLocalAsync** (Ruling 6): local=true, LeadId=null, title Trim(), stage из тела, если в ProjectStages,
|
||||
иначе planned; история — `createdLocal` (Ruling 7); пустой title допустим.
|
||||
- **MoveAsync**: валидация ProjectStages → 400 «Неизвестная стадия» (константа `UnknownStageDetail`);
|
||||
запись истории {id `h_`, at, stage} + сброс напоминания (Ruling 3: ЛЮБОЙ move) + updated_at=время переноса —
|
||||
всё в MoveStageAsync хранилища (Task 3); 404 — Card=null.
|
||||
- **PatchAsync/ClearRejectedAsync**: тонкое делегирование; clear — только стадия rejected (Ruling 9), счётчик.
|
||||
- Чтение (List/Get) — pass-through; стадии/времена/id по Rulings 1/11 (PrefixId; `pr_`/`h_` из
|
||||
ProjectIdPrefixes, комментарий — KanbanIdPrefixes.Comment).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — **552/552 PASS** (535 этапа 4 + 17 новых: 2 чтение, 3 локальное
|
||||
создание (trim/stage-fallback/пустой title), 4 take (лид не найден; копия полей + col=taken + is_new=false +
|
||||
MarkTaken вызван; дубль → существующая без вставки; сбой MarkTaken → откат), 2 patch (поля+budget+stack и
|
||||
null-не-меняет+bump; 404), 3 move (история+сброс reminder+updated_at=atMs; 400 неизвестная стадия без
|
||||
изменений; 404), 2 clear-rejected (только rejected + счётчик; пустая стадия → 0)). MarkerTests PASS.
|
||||
3. Диагностики изменённых файлов — без ошибок/предупреждений.
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **Параллельная гонка двух take (Ruling 5)**: данные страхует частичный UNIQUE ProjectCards.LeadId
|
||||
(Task 1) — вторая вставка падает (DbUpdateException). План (Task 4 L282) предлагает «поймать DbUpdateException
|
||||
и перечитать GetByLeadAsync» — в чистом модуле Projects (без EF-ссылки, Global Constraints) EF-исключение
|
||||
поймать невозможно; прецедент кодовой базы — KanbanStore.AddCommentAsync (task-3-report этапа 3: гонка с
|
||||
удалением → DbUpdateException наружу, целостность держит БД, 404-семантику даёт сервис пред-чтением). Поэтому
|
||||
в сервисе реализована идемпотентность последовательного дубля (GetByLeadAsync, покрыта тестом), а гонка
|
||||
оставлена на UNIQUE-индекс (состояние консистентно: одна карточка, лид помечен); зафиксировано в XML-doc
|
||||
TakeLeadAsync. Если понадобится «graceful» параллельный take — кандидат: перевод конфликта в адаптере
|
||||
ProjectStore в модульное исключение (решение за ревью, вне файл-листа Task 4).
|
||||
- **CreateLocalAsync принимает отдельный `ProjectLocalCreateDto`** (а не ProjectCardPatch): тело создания
|
||||
(Ruling 6) несёт `stage`, которого в ProjectCardPatch нет (PATCH стадию не принимает — projects_routes L30–36),
|
||||
а поля comments/links/files в тело POST /api/projects не входят.
|
||||
- **Результаты мутаций** — по конвенции CardsService/LeadsEndpoints: 400-тексты — константы сервиса и Error
|
||||
record-результата; 404 («Карточка не найдена»/«Лид не найден») — null-результат, текст у эндпоинта (Task 8).
|
||||
- `AddProjectsModule` теперь регистрирует ProjectsService, но в Program.cs вызовется в Task 8 (Api → модуль
|
||||
Projects по Ruling 2) — сейчас модуль никто не резолвит, изменений DI-графа рантайма нет.
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; константы вместо литералов (planned/rejected/
|
||||
«Взял в работу из лида.»/«Вы»/«только что»); без регионов; комментарии на русском.
|
||||
|
||||
## Fix после ревью (Findings 1–2)
|
||||
|
||||
### Finding 1 (Important) — PATCH с явным budget:null (очистка бюджета)
|
||||
|
||||
**Проблема:** типизированный `ProjectCardPatch.Budget==null` не отличает «ключ отсутствует» от «null» —
|
||||
явная очистка бюджета телом (фронт реально шлёт `{budget: null}`: ProjectDrawer.vue saveBudget, когда оба поля
|
||||
пусты) молча не применялась. Типизированный биндинг эндпоинта (Task 8) потерял бы presence.
|
||||
|
||||
**Исправление (уровень сервиса, эталон SettingsService.ApplyPatchAsync / SettingsEndpoints PATCH):**
|
||||
- `ProjectsService.PatchAsync` переведён на presence-aware сигнатуру `PatchAsync(string cardId,
|
||||
IReadOnlyDictionary<string, JsonElement> body, CancellationToken ct)`: учитывается ПРИСУТСТВИЕ ключа;
|
||||
эндпоинт Task 8 десериализует тело в `Dictionary<string, JsonElement>` и передаёт как есть (готовый метод).
|
||||
- Ключи и семантика значений — 1:1 с прототипом patch_card L159–187 и pydantic PatchBody (projects_routes L30–36):
|
||||
title/summary/contact/tzText — JSON-строка (пустая строка очищает текст; null/не-строка → ключ игнорируется —
|
||||
эталон SettingsService.TryReadText: JSON-null трактуется как отсутствие, а не «None»-строка прототипа);
|
||||
stack — массив строк или null/не-массив → пустой стек (`_json(value or [])` L172–173); budget — объект
|
||||
{from,to,cur} либо null/не-объект → ОЧИСТКА (`budget не словарь → обнуление` L174–179). Неизвестные ключи
|
||||
отбрасываются (pydantic PatchBody режет тело до шести ключей).
|
||||
- Очистка бюджета передаётся хранилищу объектом с пустой Cur (`CardBudgetDto(null,null,"")` = «бюджета нет»,
|
||||
Ruling 11) — семантика хранилища `Budget != null → писать from/to/cur` сохранена; XML-doc ProjectCardPatch
|
||||
уточнён (раздел «слоя сервиса» больше не откладывается на Task 8).
|
||||
- Тесты переписаны под тело PATCH: присутствующие ключи меняют поля (+bump UpdatedAt, отсутствующий ключ не
|
||||
трогает поле, пустая summary очищает текст); `budget:null` очищает бюджет (карточка и хранилище — null);
|
||||
`stack:null` очищает стек; неизвестные ключи (stage) игнорируются; null текстового ключа не применяется;
|
||||
404 на отсутствующей карточке. Хелпер `PatchBody((key, value)...)` сериализует пары в JsonElement
|
||||
(эталон PATCH /settings).
|
||||
- `FakeProjectStore.PatchAsync` приведён к семантике адаптера: патч бюджета с пустой Cur хранит строку
|
||||
«без бюджета» → наружу `Budget=null` (раньше фейк оставлял непустой объект CardBudgetDto(null,null,"")).
|
||||
|
||||
### Finding 2 (Minor) — история move: append, а не replace
|
||||
|
||||
**Проблема:** тест move сидел на карточке с пустой историей — `Assert.Single` не отличал добавление от замены.
|
||||
|
||||
**Исправление:** тест `Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt`: карточка посеяна с записью
|
||||
создания + напоминанием; два move подряд (hold → work → review). Проверяется, что история НЕ заменяется, а
|
||||
КОПИТСЯ: 3 записи [created, work, review] в порядке переносов (Ruling 7), id всех записей `h_`, reminder снят,
|
||||
updated_at = время последнего переноса (At последней записи), хранилище хранит ту же накопленную историю.
|
||||
|
||||
**Проверка после фикса:** build 0/0; ProjectsServiceTests 21/21 PASS; полный `dotnet test` — 556/556 PASS
|
||||
(535 этапа 4 + 21 новых); диагностики изменённых файлов чистые.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 5 — Комментарии и ссылки (ProjectsService) + тесты — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 567/567 PASS — 556 этапа 4/5(T4) + 11 новых ProjectsServiceTests).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 5 (L299–312), Rulings 7/11;
|
||||
источники `projects.py` add_comment L194–199, `projects_routes.py` L124–150, api-map §3.5 L166–168/§4.3;
|
||||
эталон `CardsService.AddCommentAsync`/`AddCommentResultDto`/`ProjectCardResultDto`.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Modules.Projects/Application/ProjectsService.cs` — методы Task 5:
|
||||
`AddCommentAsync(cardId, text, ct)` → `ProjectCommentResultDto`, `AddLinkAsync(cardId, name, url, ct)` и
|
||||
`RemoveLinkAsync(cardId, linkId, ct)` → `ProjectCardResultDto`; константы 400 `EmptyCommentDetail`
|
||||
(«Пустой комментарий») и `EmptyLinkDetail` («Пустая ссылка»); шапка класса дополнена Task 5.
|
||||
- `src/core/tests/Deal.Tests.Unit/ProjectsServiceTests.cs` — +11 тестов (комментарии 4, ссылки 7).
|
||||
|
||||
### Создан
|
||||
- `src/core/Deal.Modules.Projects/Application/Models/ProjectCommentResultDto.cs` — тонкий record-результат
|
||||
`(string? Error, IReadOnlyList<CardCommentDto>? Comments)` (эталон AddCommentResultDto Kanban): Error — 400
|
||||
«Пустой комментарий»; Comments=null без Error — 404-семантика; ответ эндпоинта оборачивает список в
|
||||
`{"comments": [...]}` (api-map §3.5 L166). Новый файл вне файл-листа плана: результат «список comments» не
|
||||
выражается существующим `ProjectCardResultDto` (он несёт карточку), а тип Kanban завязан на семантику
|
||||
журнала LeadComments — по код-стайлу (1 тип = 1 файл) заведён свой record модуля.
|
||||
|
||||
## Реализация
|
||||
|
||||
- **AddCommentAsync** (1:1 add_comment L194–199 + route L124–128): текст Trim; пустой → 400 (как route,
|
||||
валидация ДО сервиса/карточки); карточки нет → Error=null/Comments=null (404 — прототип на этом пути падает
|
||||
500, .NET отвечает корректным 404); новая запись {id `cm_` — KanbanIdPrefixes.Comment, by «Вы», text после
|
||||
Trim, time «только что»}; запись — `IProjectStore.PatchAsync` полной заменой CommentsJson (append в конец —
|
||||
порядок сохраняется), хранилище бампает UpdatedAt (patch_card L186). В историю не пишется (Ruling 7).
|
||||
- **AddLinkAsync** (1:1 route L133–143): порядок прототипа — сначала _card_or_404 (404-результат), затем url
|
||||
Trim → пустой → 400 «Пустая ссылка»; без префикса http:// или https:// → «https://» + url (L139–140);
|
||||
запись {id `pl_` — PrefixId (Ruling 11) вместо `pl_{card}_{n}` прототипа L142, name: name.Trim() или url,
|
||||
url}; ответ — карточка после мутации.
|
||||
- **RemoveLinkAsync** (1:1 route L146–150): 404 карточки; фильтрация массива по id — неизвестный id не ошибка
|
||||
(список без изменений); запись PatchAsync; ответ — карточка.
|
||||
- Счётчик-значок links на карточке — длина массива `links` ProjectCardDto (Ruling 4/11) — уже обеспечен
|
||||
формой ProjectCardDto Task 2/4; новые методы только мутируют массив.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — **567/567 PASS** (556 + 11 новых: комментарий — форма {id `cm_`, by
|
||||
«Вы», text Trim, time «только что»} + bump UpdatedAt; append двух комментариев сохраняет порядок; пустой/
|
||||
пробельный текст → 400 «Пустой комментарий» без записи; 404 карточки; ссылка — url без схемы → https:// и
|
||||
name=url; http-схема сохраняется + name Trim; пустой url → 400 «Пустая ссылка» без записи; 404 карточки
|
||||
раньше валидации url; удаление по id (только целевая, bump UpdatedAt); неизвестный id → без ошибки;
|
||||
404 карточки). MarkerTests PASS.
|
||||
3. Диагностики изменённых файлов — без ошибок/предупреждений.
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **Форма хранения/возврата — по Rulings 1/11**: комментарии/ссылки живут JSON-массивами в колонках
|
||||
CommentsJson/LinksJson карточки ProjectCards (отдельных таблиц нет); в ProjectCardDto — массивы `comments`
|
||||
(форма {id,by,text,time}, DTO Kanban CardCommentDto) и `links` (форма {id,name,url}); эндпоинты
|
||||
POST `/{cardId}/comments`, POST/DELETE `/{cardId}/links` — в Task 8 (там же обёртки `{comments: [...]}` и
|
||||
маппинг 404/400); сервисные методы готовы к 1:1-биндингу.
|
||||
- **Порядок проверок**: для ссылок повторён прототип (404 карточки раньше 400 пустого url — route L135 перед
|
||||
L137–138); для комментариев — 400 пустого текста раньше 404 (route L126–127 перед вызовом сервиса; эталон
|
||||
CardsService.AddCommentAsync).
|
||||
- **Рейс «карточка удалена между чтением и записью»**: PatchAsync=false → 404-результат (как MoveAsync);
|
||||
после успешной записи карточка перечитывается (эталон TakeLeadAsync/MoveAsync) — исчезновение между
|
||||
патчем и чтением — InvalidOperationException (недостижимо без параллельного удаления).
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; константы вместо литералов
|
||||
(«Пустой комментарий»/«Пустая ссылка»/http/https-схемы); без регионов; комментарии на русском.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Task 6 — Файлы: порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0, тесты 584/584 PASS — 567 этапа 1–5 + 17 новых: FileKindDetectorTests 8,
|
||||
LocalFileStorageTests 9; compose config валиден; запуск Api — LocalFileStorage; live-check MinIO пройден).
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 6 (L314–340), Ruling 4;
|
||||
источники `backend/app/services/object_store.py` L26–108, `backend/app/services/files.py` L13–45;
|
||||
эталоны `IAiClassifier` (Contracts-порт), `CbrRateSource`/`EncryptionKeyProvider` (Infrastructure-стиль),
|
||||
`ProjectsServiceTests`/`LocalFileStorageTests` (тесты).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы (Contracts)
|
||||
- `src/core/Deal.Contracts/Integrations/IFileStorage.cs` — внешний порт файлового хранилища ровно по
|
||||
Ruling 4 (object_store.py L61–107): `PutAsync(objectKey, Stream, contentType, ct) → objectKey`,
|
||||
`GetAsync(objectKey, ct) → Stream?` (null — объекта нет), `DeleteAsync(objectKey, ct)`. XML-doc: objectKey —
|
||||
opaque, формат `projects/{cardId}/{unixMs}_{safeName}`; единственный бакет и отсутствие tenant-префикса —
|
||||
как в прототипе (мульти-аренда объектного хранилища — этап 7 SaaS).
|
||||
- `src/core/Deal.Contracts/Integrations/Models/FileMeta.cs` — record `FileMeta(Key, Size, ContentType)`
|
||||
(тип-описатель объекта порта, по требованию задачи; см. «Решения и замечания»).
|
||||
|
||||
### Созданы (модуль Projects)
|
||||
- `src/core/Deal.Modules.Projects/Application/ProjectFileKind.cs` — record `ProjectFileKind(Kind, Label)`
|
||||
(эталон ProjectStage): результат детектора, wire-поля ProjectFileDto.kind/label.
|
||||
- `src/core/Deal.Modules.Projects/Application/FileKindDetector.cs` — чистый детектор
|
||||
`Detect(name, mime) → ProjectFileKind`: MIME-префиксы image/|video/|audio/ → kind, иначе расширение по
|
||||
наборам 1:1 files.py KIND_BY_EXT L13–19; неизвестное → other/«Файл»; метки 1:1 KIND_LABELS L21–28.
|
||||
Константы категорий (ImageKind=«image» … OtherKind=«other») публичные. Сравнение регистронезависимо
|
||||
(HTTP content-type), расширение — часть имени после последней точки в нижнем регистре (как python `.lower()`).
|
||||
|
||||
### Созданы (Infrastructure, `Integrations/Storage/`)
|
||||
- `StorageOptions.cs` / `LocalStorageOptions.cs` (Local: Root?) / `MinioStorageOptions.cs`
|
||||
(Minio: Endpoint/AccessKey/SecretKey/Bucket=deal-files/Secure; 1 тип = 1 файл).
|
||||
- `LocalFileStorage.cs` — root-каталог (дефолт `data/attachments` под ContentRoot резолвит регистратор):
|
||||
Put — mkdir родителя + `CopyToAsync` (поток не буферизуем — длина не нужна); Get — `FileStream|null`;
|
||||
Delete — удаление файла, отсутствующий — no-op. Путь из objectKey строится безопасно (object_store.py
|
||||
`_local_path` L54–79): сегменты по `/` (`\` нормализуется — защита не зависит от ОС), сегменты `.`/`..`
|
||||
запрещены, полный путь обязан лежать внутри root (контроль после `GetFullPath`) — тест «`..` не выходит
|
||||
за root».
|
||||
- `MinioFileStorage.cs` — Minio .NET SDK **7.0.0** (NuGet, единственный новый пакет этапа): клиент строится
|
||||
в ctor без сети (`WithEndpoint/WithCredentials/WithSSL(false)/Build`); бакет проверяется/создаётся ЛЕНИВО
|
||||
при первом put под `SemaphoreSlim`-gate (object_store.py L26–51; сбой проверки — warning, put упадёт);
|
||||
Put буферизует поток в MemoryStream (MinIO нужна длина; прототип и так держит байты в памяти L67–73);
|
||||
Get — `GetObjectAsync` с `WithCallbackStream` → MemoryStream (в 7.0 API содержимое приходит в callback,
|
||||
метод возвращает ObjectStat); `ObjectNotFoundException` → null; Delete гасит `MinioException` warning-логом
|
||||
(remove L96–108). Оба адаптера — `ToString()`-описание для стартового лога Api.
|
||||
- `FileStorageRegistrar.cs` — `AddDealFileStorage(IConfiguration, contentRootPath)`: читает секцию
|
||||
`Storage`; Minio заполнена (Endpoint + AccessKey/SecretKey) → `MinioFileStorage` (singleton, фабрика с
|
||||
`ILogger<MinioFileStorage>`), иначе → `LocalFileStorage` (root: `Storage:Local:Root` относительный — под
|
||||
ContentRoot, абсолютный — как есть, пусто — `data/attachments`). Секция (appsettings/env
|
||||
`Storage__Minio__*`) приоритетнее; незаданные поля Minio заполняются env-алиасами `DEAL_MINIO_ENDPOINT`/
|
||||
`_ACCESS_KEY`/`_SECRET_KEY`/`_BUCKET`/`_SECURE` (аналог LEADRADAR_MINIO_* config.py).
|
||||
|
||||
### Созданы (тесты, `tests/Deal.Tests.Unit/`)
|
||||
- `FileKindDetectorTests.cs` — 8 тестов: расширения по всем пяти наборам KIND_BY_EXT (kind+метка),
|
||||
mime-image поверх неизвестного расширения, video/audio mime, неизвестное → other/«Файл» (в т.ч. без точки
|
||||
и «trailing-dot.»), регистронезависимость.
|
||||
- `LocalFileStorageTests.cs` — 9 тестов (временный каталог, удаляется в Dispose): put/get round-trip байт,
|
||||
вложенные каталоги из objectKey, get отсутствующего → null, delete + повторный delete no-op,
|
||||
traversal `../..` (в т.ч. через `\` и сегмент `.`) → ArgumentException и ничего не записано вне root
|
||||
(проверяется реальный путь наивного `Path.Combine`), пустой/«///» ключ → ArgumentException.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — `<PackageReference Include="Minio" Version="7.0.0"/>`
|
||||
(комментарий: единственный новый пакет этапа файлов; Ruling 4/план Task 6).
|
||||
- `src/core/Deal.Api/Program.cs` — вызов `AddDealFileStorage(builder.Configuration,
|
||||
builder.Environment.ContentRootPath)` (после AddDealIntegrations) + стартовый лог режима
|
||||
(`app.Logger.LogInformation("Файловое хранилище: {FileStorage}", …)` после `Build()`) — приёмка Task 6.
|
||||
- `deploy/compose.dev.yml` — сервис `minio` (container_name `deal-minio`, image minio/minio, порты
|
||||
`9000:9000`/`9001:9001` — проверено, что свободны, комментарий «если заняты LeadRadar-minio — 9100/9101»,
|
||||
MINIO_ROOT_USER/PASSWORD=deal_minio/deal_minio_secret, volume `deal_minio_data`, command
|
||||
`server /data --console-address ":9001"`) + volume; комментарий: бакет deal-files создаёт приложение лениво
|
||||
при первом put (Ruling 4), init-контейнер не нужен.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `dotnet test Deal.sln` — **584/584 PASS** (567 + 17 новых; таймаута/флейков нет). MarkerTests PASS.
|
||||
3. Запуск Api (`dotnet run --project Deal.Api`, Development) — стартовый лог:
|
||||
`Файловое хранилище: LocalFileStorage (root: C:\telbase\src\core\Deal.Api\data/attachments)` — Local-режим
|
||||
по умолчанию подтверждён; приложение стартовало (слушает :5191), процесс остановлен по таймауту, порт свободен.
|
||||
4. `docker compose -f deploy/compose.dev.yml config --quiet` — OK (конфиг валиден).
|
||||
5. **Live-check MinIO** (порты 9000/9001 свободны: `docker ps` — только deal-postgres; netstat пуст):
|
||||
`docker compose up -d minio` → deal-minio поднят; временный скрипт против реального `MinioFileStorage`
|
||||
(endpoint localhost:9000, креды deal_minio/deal_minio_secret, бакет deal-files): put → get round-trip байт
|
||||
→ get отсутствующего = null → delete → get null — **OK** (бакет создан адаптером лениво при первом put).
|
||||
Контейнер оставлен поднятым (как deal-postgres) для ручной MinIO-приёмки Tasks 7–9; временные скрипты удалены.
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **Порт — минимальный по Ruling 4** (Put/Get/Delete; `GetAsync → Stream?` = «объекта нет», без ExistsAsync —
|
||||
в плане Exists нет, отсутствие выражается null). `FileMeta` (Key, Size, ContentType) добавлен как тип-описатель
|
||||
по явному требованию задачи, но потребителя в задачах этапа не имеет: метаданные вложений живут в
|
||||
`ProjectCards.FilesJson` (запись {id,name,size,kind,label,objectKey}, Ruling 1), download отдаёт фиксированный
|
||||
`application/octet-stream` (Ruling 4). При ревью: либо удалить, либо задействовать в Task 9 (например,
|
||||
Content-Length/Content-Type ответа через StatObject/FileInfo).
|
||||
- **objectKey — НЕ с tenant-префиксом**: формат `projects/{cardId}/{unixMs}_{safeName}` 1:1 с
|
||||
object_store.put L65 и Ruling 4 («единственный бакет и отсутствие tenant-префикса — как в прототипе;
|
||||
мульти-аренда объектного хранилища — этап 7 SaaS»). Tenant-префикс из общего прототипа LeadRadar к этому
|
||||
плану не применяется; key строит ProjectFilesService (Task 7), хранилище ключ только безопасно резолвит.
|
||||
- **Minio SDK 7.0.0** (в NuGet-кэше; версия не зафиксирована планом). API 7-го SDK отличается от 6.x:
|
||||
`GetObjectAsync` отдаёт содержимое через `WithCallbackStream` (async-перегрузка) и возвращает `ObjectStat`;
|
||||
fluent-конфигурация — расширения `MinioClientExtensions` (`WithEndpoint/WithCredentials/WithSSL/Build`).
|
||||
Учтено в адаптере; зеркалится в report для Task 7/9.
|
||||
- **Выбор режима и именование env**: план Ruling 4 говорит о секции `Storage:Minio` (env `Storage__Minio__*`);
|
||||
формулировка задачи упоминала `DEAL_MINIO_*`. Поддержаны оба механизма: секция приоритетнее, алиасы
|
||||
`DEAL_MINIO_*` заполняют незаданные поля (аналог `LEADRADAR_MINIO_*` прототипа). `Secure` из алиаса парсится
|
||||
как «true/1».
|
||||
- **FileKindDetector «по магии» не читает содержимое** — 1:1 с files.py detect L31–45 (тип даёт браузерный
|
||||
content-type + имя файла; в .NET — MIME из multipart и fileName), тесты плана покрывают именно mime/extension.
|
||||
Категории — wire-kind прототипа image/video/audio/archive/document/other + метки («Изображение»…«Файл»),
|
||||
на них фронт вешает иконки (§4.3 L292).
|
||||
- **compose**: сервис по плану (9000/9001, т.к. свободны — LeadRadar-minio в этом docker-контексте не поднят);
|
||||
fallback 9100/9101 задокументирован комментарием в compose. Бакет создаётся в коде (EnsureBucket на первом
|
||||
put, Ruling 4) — отдельный mc/init-контейнер не заводили. Обновление README/техдока — в финале этапа (T13), по плану.
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах и классах; именованные константы (никаких
|
||||
магических строк: дефолтный бакет, путь data/attachments, env-имена, application/octet-stream); без регионов;
|
||||
комментарии на русском.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 7 — ProjectFilesService — добавить/удалить файл (мета + объект) — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0; 599/599 PASS — 584 этапов 1–6 + 15 новых: ProjectFilesServiceTests 14,
|
||||
LocalFileStorageTests +1 по Ruling T6). План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md`
|
||||
Task 7 (L342–358), Ruling 4/11; источники `backend/app/services/files.py` L57–94, `object_store.py` L61–108,
|
||||
`projects_routes.py` L155–186; эталоны ProjectsService/ProjectsServiceTests (Tasks 4/5), LocalFileStorageTests (T6).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан
|
||||
- `src/core/Deal.Modules.Projects/Application/ProjectFilesService.cs` — чистый сервис файлов карточек (без
|
||||
EF/HTTP), зависимости IProjectStore + IFileStorage. API по плану Task 7 (его же потребляет Task 9):
|
||||
- `AddAsync(cardId, fileName, contentType, Stream content, long size, ct) → ProjectFileDto?` — карточка
|
||||
читается ДО записи объекта (null → 404 «Карточка не найдена», объект НЕ пишется — приёмка Task 7);
|
||||
kind через FileKindDetector.Detect (mime → расширение); objectKey = `projects/{cardId}/{unixMs}_{safeName}`
|
||||
(safeName: path-разделители `/\` и кавычки `"` → `_`, имя в мета — как прислано, Ruling 4); PutAsync затем
|
||||
PatchAsync полной заменой FilesJson (дописывание в конец — порядок сохраняется; bump UpdatedAt). Пустое имя
|
||||
→ «file» (1:1 `f.filename or "file"` L161). Гонка «карточка исчезла после Put» → DeleteAsync объекта +
|
||||
null (без объектов-сирот).
|
||||
- `GetEntryAsync(cardId, fileId, ct) → ProjectFileDto?` — мета-запись {id,name,size,kind,label,objectKey}
|
||||
для download-эндпоинта (Task 9 резолвит поток сам через IFileStorage.GetAsync и мапит 410/404); карточка/
|
||||
запись не найдены → null (Ruling 4: 404 «Карточка не найдена»).
|
||||
- `RemoveAsync(cardId, fileId, ct) → ProjectCardDto?` — объект удаляется только когда запись есть и у неё
|
||||
непустой objectKey (remove_file L92); запись убирается полной заменой FilesJson (не найдена — no-op без
|
||||
ошибки, эталон RemoveLinkAsync); карточки нет → null (404).
|
||||
- Лимитов размера/количества НЕ вводим: в прототипе их нет (files.py L57–94, routes L155–162); ограничение
|
||||
тела multipart — зона HTTP-слоя (Kestrel/FormOptions, Task 9). Константы именованные (ObjectRootSegment,
|
||||
KeyNameUnsafeCharacters, KeyNameReplacement, DefaultAttachmentName).
|
||||
- `src/core/tests/Deal.Tests.Unit/FakeFileStorage.cs` — in-memory IFileStorage (Put с позиции 0 — Ruling T6;
|
||||
Get→null на отсутствии; Delete идемпотентный; StoredObjectKeys/DeletedKeys/ContentOf для проверок).
|
||||
- `src/core/tests/Deal.Tests.Unit/ProjectFilesServiceTests.cs` — 14 тестов: add по mime (image/«Изображение») и
|
||||
по расширению без mime (document/«Документ»); мета/objectKey-форма/Size; порядок двух файлов; add на
|
||||
несуществующей карточке → null и объект не пишется; пустое имя → «file»; санитизация имени в objectKey при
|
||||
сохранении raw-имени в мете; поток с ненулевой позицией → пишется всё содержимое (Ruling T6); GetEntry
|
||||
(мета / null карточки / null записи); Remove (объект+мета, чужой объект цел, bump UpdatedAt / unknown-id
|
||||
no-op без delete / пустой objectKey — skip delete / 404 карточки).
|
||||
|
||||
### Изменён
|
||||
- `Application/ProjectsModuleRegistrar.cs` — `AddScoped<ProjectFilesService>()` (каркас-комментарий это
|
||||
предписывал: «ProjectFilesService (Task 7)»).
|
||||
- Ruling T6 (выравнивание адаптеров, указано в контексте задачи):
|
||||
- `I/Integrations/Storage/LocalFileStorage.cs` — PutAsync сбрасывает перемотаемый поток в 0 перед записью
|
||||
(выравнивание с Minio-адаптером; XML-doc обновлён).
|
||||
- `I/Integrations/Storage/MinioFileStorage.cs` — GetAsync: при ошибке ≠ ObjectNotFoundException буфер
|
||||
Dispose + rethrow (не течёт частично заполненный MemoryStream); NotFound по-прежнему → null.
|
||||
- `Deal.Contracts/Integrations/IFileStorage.cs` — XML-doc PutAsync: содержимое читается с позиции 0 (Ruling T6).
|
||||
- `LocalFileStorageTests.cs` — +1 тест «Put потока с ненулевой позиции пишет всё содержимое» (10 тестов).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `dotnet test Deal.sln` — **599/599 PASS** (таргетно ProjectFilesServiceTests + LocalFileStorageTests +
|
||||
FileKindDetectorTests — 32/32). MarkerTests PASS.
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **Имена методов — по плану Task 7** (AddAsync/GetEntryAsync/RemoveAsync), а не по формулировке брифа
|
||||
(Attach/Download/Delete): Task 9 (L393–401) вызывает именно `AddAsync` и `GetEntryAsync`; download-поток
|
||||
резолвит эндпоинт (410/404-различение требует objectKey в эндпоинте), сервис отдаёт мету (entry). «Download
|
||||
мета+поток» из брифа покрыто на уровне связки GetEntryAsync (мета) + IFileStorage.GetAsync (поток), что
|
||||
проверит curl-приёмка Task 9; FileMeta (Task 6) задействуется там же.
|
||||
- **400/лимиты**: 1:1 с прототипом — в files.py/object_store.py/routes 400 на «пустое имя/нет файла/размер» и
|
||||
лимитов размера/количества НЕТ; вопрос брифа «лимит размера?» снят сверкой (см. class-doc). Пустое имя —
|
||||
дефолт «file», как прототип. Null-возврат = 404 «Карточка не найдена» (текст у эндпоинта Task 9).
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; русские комментарии; без регионов; именованные
|
||||
константы; сортировка/порядок не менялись.
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 8 curl-приёмка /api/projects на :5080 (план Task 8 L383–388, Rulings 5/6/7/9/11;
|
||||
# projects_routes.py L15–150; api-map §3.5 L155–168). Сценарий: сброс kanban/проектных таблиц →
|
||||
# запуск Deal.Api с DEAL_DEMO=1 (Development) → 401 без куки → login admin/admin → GET /api/projects
|
||||
# пусто {items:[]} → POST /projects {title:''} (local/planned/createdLocal) → POST полной карточки →
|
||||
# GET/{id} → PATCH (title/stack/budget, рост updatedAt; budget:null — очистка) → GET?stage= → move work
|
||||
# (история + reminder null) → move невалидной стадии 400 → comments (пустой 400 / текст {comments}) →
|
||||
# links (https-префикс, name=url по умолчанию, http остаётся) → DELETE links/{id} → 404 карточки на
|
||||
# GET/PATCH/move/comment/link → take несуществующего лида 404 «Лид не найден» → demo-лид (simulate) →
|
||||
# take {leadId} → лид col=taken (psql, GET /leads?col=inbox его не видит), проектная local=false с
|
||||
# комментарием «Взял в работу из лида.» → move rejected → clear-rejected {ok,cleared:1} (другие целы) →
|
||||
# logout → 401. Очистка созданных строк после приёмки.
|
||||
|
||||
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
|
||||
APP_PID=""
|
||||
PRJ_A=""
|
||||
PRJ_B=""
|
||||
PRJ_C=""
|
||||
LEAD_ID=""
|
||||
LINK_A=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_/l_) из JSON-тела ответа: тело — первая строка $OUT (вторая — служебный [HTTP:...]).
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\|l_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# updatedAt (epoch-ms) из тела ответа.
|
||||
extract_updated_at() {
|
||||
sed -n '1{s/.*"updatedAt":\([0-9][0-9]*\).*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк =="
|
||||
stop_app "$APP_PID"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$LEAD_ID';" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';" >/dev/null 2>&1
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" IN ('$PRJ_A','$PRJ_B','$PRJ_C');" >/dev/null 2>&1
|
||||
rm -f "$JAR" "$OUT"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$LOG"
|
||||
|
||||
echo "== 0. Очистка kanban/проектных таблиц дефолтного тенанта (повторяемость приёмки) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\");")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] kanban/проектные таблицы пусты"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. 401 без сессии на /api/projects* =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"title":"x"}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "POST /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"stage":"work"}' "$BASE_URL/api/projects/pr_x/move" > "$OUT"
|
||||
check "POST /move без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== 3. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 4. GET /api/projects пуст (заглушка снята: реальный список) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects → 200 {items:[]}" '[HTTP:200]' '{"items":[]}'
|
||||
|
||||
echo
|
||||
echo "== 5. POST /api/projects {title:''} — локальная карточка =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
|
||||
check "история createdLocal" '"type":"createdLocal"'
|
||||
PRJ_A=$(extract_id)
|
||||
echo " -> PRJ_A: $PRJ_A"
|
||||
if [ -z "$PRJ_A" ]; then exit 1; fi
|
||||
|
||||
echo
|
||||
echo "== 6. POST /api/projects — полная карточка (title/stack/budget/contact/tzText/stage) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"title":"Client React","summary":"Corp portal","stack":["React","Node"],"budget":{"from":1000,"to":2500,"cur":"USD"},"contact":"@client","tzText":"MVP in 3 months","stage":"reply"}' \
|
||||
"$BASE_URL/api/projects" > "$OUT"
|
||||
check "полная карточка 200: поля и стадия reply" '[HTTP:200]' '"local":true' '"stage":"reply"' '"contact":"@client"'
|
||||
check "стек и бюджет сохранены" '"stack":["React","Node"]' '"budget":{"from":1000,"to":2500,"cur":"USD"}'
|
||||
PRJ_B=$(extract_id)
|
||||
echo " -> PRJ_B: $PRJ_B"
|
||||
if [ -z "$PRJ_B" ]; then exit 1; fi
|
||||
T_CREATE=$(extract_updated_at)
|
||||
|
||||
echo
|
||||
echo "== 7. GET /api/projects/{id} и фильтр ?stage= =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
|
||||
check "GET /{id} → карточка 200" '[HTTP:200]' "\"id\":\"$PRJ_B\"" '"title":"Client React"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects?stage=reply" > "$OUT"
|
||||
check "GET ?stage=reply → только PRJ_B" '[HTTP:200]' "\"id\":\"$PRJ_B\""
|
||||
if grep -qF -- "\"id\":\"$PRJ_A\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] в ?stage=reply попала карточка не той стадии (PRJ_A)"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] фильтр стадии исключил planned-карточку"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 8. PATCH — title/stack/budget, рост updatedAt; budget:null — очистка =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"title":"Client React v2","stack":["Vue","Go"],"budget":{"from":2000,"to":4000,"cur":"EUR"},"tzText":"New spec"}' \
|
||||
"$BASE_URL/api/projects/$PRJ_B" > "$OUT"
|
||||
check "PATCH 200: изменения на месте" '[HTTP:200]' '"title":"Client React v2"' '"stack":["Vue","Go"]' '"budget":{"from":2000,"to":4000,"cur":"EUR"}'
|
||||
T_PATCH=$(extract_updated_at)
|
||||
if [ "$T_PATCH" -gt "$T_CREATE" ] 2>/dev/null; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] updatedAt вырос ($T_CREATE → $T_PATCH)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] updatedAt не вырос: $T_CREATE → $T_PATCH"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"budget":null}' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
|
||||
check "PATCH budget:null → бюджет очищен" '[HTTP:200]' '"budget":null'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"stack":null}' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
|
||||
check "PATCH stack:null → стек пуст" '[HTTP:200]' '"stack":[]'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d 'not-json' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
|
||||
check "PATCH не-JSON → 400 {detail}" '[HTTP:400]' 'Тело запроса должно быть JSON-объектом'
|
||||
|
||||
echo
|
||||
echo "== 9. POST /{id}/move: work (история + reminder null); невалидная стадия 400 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"stage":"work"}' "$BASE_URL/api/projects/$PRJ_B/move" > "$OUT"
|
||||
check "move work 200" '[HTTP:200]' '"stage":"work"' '"reminder":null'
|
||||
H_MOVE=$(grep -o '"id":"h_' "$OUT" | wc -l | tr -d ' ')
|
||||
if [ "$H_MOVE" = "2" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] история движения пополнена (2 записи: createdLocal + move)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] история не пополнена: записей h_ = $H_MOVE"
|
||||
fi
|
||||
check "запись move в истории" '"stage":"work"}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"stage":"bogus"}' "$BASE_URL/api/projects/$PRJ_B/move" > "$OUT"
|
||||
check "move невалидной стадии → 400 «Неизвестная стадия»" '[HTTP:400]' 'Неизвестная стадия'
|
||||
|
||||
echo
|
||||
echo "== 10. POST /{id}/comments: пустой 400, текст → {comments:[...]} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"text":" "}' "$BASE_URL/api/projects/$PRJ_B/comments" > "$OUT"
|
||||
check "пустой комментарий → 400 «Пустой комментарий»" '[HTTP:400]' 'Пустой комментарий'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"text":"first-comment"}' "$BASE_URL/api/projects/$PRJ_B/comments" > "$OUT"
|
||||
check "комментарий → {comments:[...]}" '[HTTP:200]' '"comments":[{"id":"cm_' '"by":"Вы"' '"text":"first-comment"' '"time":"только что"'
|
||||
|
||||
echo
|
||||
echo "== 11. POST /{id}/links: https-префикс, name=url по умолчанию, http остаётся =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"url":"example.com"}' "$BASE_URL/api/projects/$PRJ_B/links" > "$OUT"
|
||||
check "ссылка без схемы → https://, name = url" '[HTTP:200]' '"links":[{"id":"pl_' '"name":"https://example.com"' '"url":"https://example.com"'
|
||||
LINK_A=$(sed -n '1{s/.*"links":\[{"id":"\(pl_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT")
|
||||
echo " -> LINK_A: $LINK_A"
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"name":"Site","url":"http://x.ru"}' "$BASE_URL/api/projects/$PRJ_B/links" > "$OUT"
|
||||
check "вторая ссылка: http:// сохранён" '[HTTP:200]' '"name":"Site"' '"url":"http://x.ru"'
|
||||
|
||||
echo
|
||||
echo "== 12. DELETE /{id}/links/{linkId} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_B/links/$LINK_A" > "$OUT"
|
||||
check "ссылка удалена → карточка без неё" '[HTTP:200]' "\"id\":\"$PRJ_B\""
|
||||
if grep -qF -- "$LINK_A" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] удалённая ссылка осталась в карточке"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] удалённой ссылки в ответе нет"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 13. 404 «Карточка не найдена» на несуществующей карточке =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
|
||||
check "GET /{id} несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
|
||||
-d '{"title":"x"}' "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
|
||||
check "PATCH несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"stage":"work"}' "$BASE_URL/api/projects/pr_dead00000000/move" > "$OUT"
|
||||
check "move несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"text":"some-text"}' "$BASE_URL/api/projects/pr_dead00000000/comments" > "$OUT"
|
||||
check "comment несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"url":"example.com"}' "$BASE_URL/api/projects/pr_dead00000000/links" > "$OUT"
|
||||
check "link несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
|
||||
echo
|
||||
echo "== 14. POST /take несуществующего лида → 404 «Лид не найден» =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"leadId":"l_nonexistent0000"}' "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "take → 404 «Лид не найден»" '[HTTP:404]' 'Лид не найден'
|
||||
|
||||
echo
|
||||
echo "== 15. Demo-лид (simulate) → take {leadId}: лид уходит в taken, проектная создана =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
|
||||
check "simulate-lead 200 (demo)" '[HTTP:200]' '"id":"l_' '"col":"inbox"'
|
||||
LEAD_ID=$(extract_id)
|
||||
echo " -> LEAD_ID: $LEAD_ID"
|
||||
if [ -z "$LEAD_ID" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
|
||||
check "лид виден в inbox до take" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "take лида 200: карточка из лида" '[HTTP:200]' '"local":false' "\"leadId\":\"$LEAD_ID\"" '"stage":"planned"'
|
||||
check "комментарий «Взял в работу из лида.»" '"text":"Взял в работу из лида."'
|
||||
check "история created" '"type":"created"'
|
||||
PRJ_C=$(extract_id)
|
||||
echo " -> PRJ_C: $PRJ_C"
|
||||
if [ -z "$PRJ_C" ]; then exit 1; fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
|
||||
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] лид остался виден в inbox после take"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] GET /leads?col=inbox больше не видит лида"
|
||||
fi
|
||||
LEAD_COL=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';")
|
||||
if [ "$LEAD_COL" = "taken" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: лид col=taken"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: col лида = $LEAD_COL (ожидалось taken)"
|
||||
fi
|
||||
PRJ_ROW=$($PSQL_BASE -t -A -c "SELECT \"Stage\" || '|' || \"Local\" || '|' || \"LeadId\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_C';")
|
||||
if [ "$PRJ_ROW" = "planned|false|$LEAD_ID" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] psql: проектная карточка planned/local=false/leadId"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] psql: строка ProjectCards = $PRJ_ROW"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 16. Повторный take того же лида — идемпотентность (та же карточка) =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
|
||||
check "повторный take → та же карточка" '[HTTP:200]' "\"id\":\"$PRJ_C\"" "\"leadId\":\"$LEAD_ID\""
|
||||
|
||||
echo
|
||||
echo "== 17. rejected → clear-rejected {ok,cleared:1}; остальные карточки целы =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
|
||||
-d '{"stage":"rejected"}' "$BASE_URL/api/projects/$PRJ_C/move" > "$OUT"
|
||||
check "move PRJ_C в rejected 200" '[HTTP:200]' '"stage":"rejected"'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
|
||||
check "clear-rejected → {ok:true, cleared:1}" '[HTTP:200]' '{"ok":true,"cleared":1}'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects: PRJ_A и PRJ_B живы" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"id\":\"$PRJ_B\""
|
||||
if grep -qF -- "\"id\":\"$PRJ_C\"" "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] очищенная rejected-карточка осталась в списке"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] rejected-карточка удалена из списка"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
|
||||
check "clear-rejected на пустой стадии → cleared:0" '[HTTP:200]' '{"ok":true,"cleared":0}'
|
||||
|
||||
echo
|
||||
echo "== 18. logout → 401 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
|
||||
check "GET /api/projects после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,78 @@
|
||||
# Task 8 — Эндпоинты /api/projects: карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0; 599/599 PASS; curl-приёмка :5080 — **50/50 PASS**). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 8 (L360–388), Rulings 5/6/7/9/11;
|
||||
источники `backend/app/routers/projects_routes.py` L15–150, `projects.py` L58–231, api-map §3.5 L155–168,
|
||||
§4.3 L282–300; фронт store.js L1896–2131 (createLocalProject/clearRejectedProjects/startProject/
|
||||
moveProject/patchProject/addProjectComment/addProjectLink/removeProjectLink).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан
|
||||
- `Deal.Api/Endpoints/ProjectsEndpoints.cs` — `MapProjectsEndpoints()`: 10 эндпоинтов (файл- и
|
||||
reminder-эндпоинты — задачи 9/10, Ruling 9). Контракт 1:1 с роутером: GET `/api/projects?stage=` →
|
||||
`{items:[…]}` (обёртка списка); GET `/{cardId}` → карточка | 404 «Карточка не найдена»; POST `""`
|
||||
(CreateLocalProjectRequest) → карточка (Ruling 6); POST `/take` `{leadId}` → карточка | 404 «Лид не
|
||||
найден» (Ruling 5); POST `/clear-rejected` → `{ok:true, cleared}`; PATCH `/{cardId}` — presence-aware
|
||||
тело `Dictionary<string, JsonElement>` (сигнатура ProjectsService.PatchAsync из Task 4; чтение как в
|
||||
PATCH /api/settings SettingsEndpoints, не-JSON-объект → 400) → карточка | 404; POST `/{cardId}/move`
|
||||
`{stage}` → карточка | 400 «Неизвестная стадия» | 404; POST `/{cardId}/comments` `{text}` →
|
||||
`{comments:[…]}` | 400 «Пустой комментарий» | 404; POST `/{cardId}/links` `{name?,url}` → карточка |
|
||||
400 «Пустая ссылка» | 404; DELETE `/{cardId}/links/{linkId}` → карточка. Статические `/take` +
|
||||
`/clear-rejected` зарегистрированы до `/{cardId}`; вложенные — за `/{cardId}` (Ruling 9, api-map L19–24).
|
||||
Сессия 401-гейтом (HasUser → EndpointResults.Unauthorized), сервис из RequestServices ПОСЛЕ гейта
|
||||
(эталон LeadsEndpoints/SettingsEndpoints — scoped-зависимости на tenant-контексте). 400-строки —
|
||||
константы ProjectsService (UnknownStageDetail/EmptyCommentDetail/EmptyLinkDetail) и свои
|
||||
CardNotFoundDetail/LeadNotFoundDetail/InvalidBodyDetail.
|
||||
- `Deal.Api/Endpoints/RequestModels/CreateLocalProjectRequest.cs` (title/summary/stack/budget/contact/
|
||||
tzText/stage — 1:1 CreateBody routes L20–28; дефолты pydantic), `TakeLeadRequest.cs` (`{leadId}`),
|
||||
`MoveStageRequest.cs` (`{stage}`), `ProjectCommentRequest.cs` (`{text}`), `ProjectLinkRequest.cs`
|
||||
(`{name="",url}`).
|
||||
|
||||
### Изменён
|
||||
- `Deal.Api/Endpoints/BootStubEndpoints.cs` — GET /api/projects-заглушка и константа ProjectsPath удалены,
|
||||
остаётся GET /api/tg/status (этап 6); комментарий класса обновлён (Ruling 9).
|
||||
- `Deal.Api/Program.cs` — `builder.Services.AddProjectsModule()` (регистрация была отложена до этого шага,
|
||||
Ruling 2) + `app.MapProjectsEndpoints()`; `Deal.Api/Deal.Api.csproj` — ProjectReference на
|
||||
`Deal.Modules.Projects`.
|
||||
- `.superpowers/sdd/deal-stage5-projects/task-8-curl-acceptance.sh` — приёмочный сценарий :5080 (ниже).
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **ProjectPatchRequest.cs НЕ создан** (отклонение от списка файлов Task 8 L374): PATCH-тело обязано быть
|
||||
presence-aware `Dictionary<string, JsonElement>` — таков контракт сервиса (Task 4: «PATCH-тело как
|
||||
Dictionary» в брифе), типизированный record с all-optional полями неотличим от «поля нет» (бюджет
|
||||
чистится явным `budget:null`, Ruling 11). Прецедент — PATCH /api/settings (SettingsEndpoints) без
|
||||
request-модели; создавать неиспользуемый тип против конвенции «1 тип = 1 файл, без dead-кода» не стал.
|
||||
- **take-тело сверено: `{leadId}`** (TakeBody routes L16–17) — как в брифе. Отсутствующий leadId/пустой →
|
||||
строка не находится → 404 «Лид не найден» (pydantic-422 сводим к прототипному 404, прецедент null-тел
|
||||
LeadsEndpoints). Аналогично `{text}`/`{stage}`/`{url}` null трактуются валидациями сервисов
|
||||
(400 «Пустой комментарий»/«Неизвестная стадия»/«Пустая ссылка»).
|
||||
- Порядок проверок 1:1 с роутером: comment — пустой текст 400 раньше 404 карточки; link — 404 карточки
|
||||
раньше 400 пустого url (routes L124–128, L133–143; сервисы Tasks 4/5 это уже зафиксировали).
|
||||
- Curl-сценарий на Windows: кириллица в **телах запросов** curl искажает (argv → локальная кодовая
|
||||
страница, не UTF-8) — в `-d` используются ASCII-значения, ответы сервера проверяются кириллическими
|
||||
подстроками (это и есть источник UTF-8 с сервера). Ограничение скрипта, не API.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `scripts/build.sh` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `scripts/test.sh` — **599/599 PASS** (новых unit-тестов Task 8 не требует: endpoint-слои — curl;
|
||||
MarkerTests остаются).
|
||||
3. Curl-приёмка :5080 (`task-8-curl-acceptance.sh` → `task-8-curl-acceptance.log`) — **PASS=50 FAIL=0**:
|
||||
сброс таблиц → запуск Deal.Api (Development, DEAL_DEMO=1) → 401 без куки (GET/POST/move) → login
|
||||
admin/admin → GET /projects `{items:[]}` (заглушка снята) → POST `{title:''}` (local=true, planned,
|
||||
createdLocal-история) → POST полной карточки (stage reply, стек/бюджет) → GET/{id} + `?stage=`-фильтр →
|
||||
PATCH (title/stack/budget; updatedAt вырос; `budget:null` → бюджет null; `stack:null` → []; не-JSON →
|
||||
400) → move work (история +1, reminder null) / move bogus → 400 «Неизвестная стадия» → comments
|
||||
(пустой 400 «Пустой комментарий»; текст → `{comments:[…]}` cm_/«Вы»/«только что») → links (без схемы →
|
||||
https://, name=url; http:// сохранён) → DELETE links/{id} (карточка без ссылки) → 404 «Карточка не
|
||||
найдена» на GET/PATCH/move/comment/link несуществующей → take несуществующего лида → 404 «Лид не
|
||||
найден» → simulate-lead → take {leadId} (карточка local=false, leadId, planned, «Взял в работу из
|
||||
лида.», history created; psql Cards col=taken; GET /leads?col=inbox лида не видит; ProjectCards
|
||||
planned|false|leadId) → повторный take — та же карточка → move rejected → clear-rejected `{cleared:1}`
|
||||
(PRJ_A/PRJ_B целы, PRJ_C удалена) → повторный clear-rejected `{cleared:0}` → logout → 401. После
|
||||
приёмки: строки очищены (0|0), в логе Api исключений нет, порт :5080 свободен.
|
||||
|
||||
## Отчёт
|
||||
`.superpowers/sdd/deal-stage5-projects/task-8-report.md`; ledger progress.md обновлён (Task 8 complete).
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 9 curl-приёмка файл-эндпоинтов /api/projects/{cardId}/files* на :5080 (план Task 9 L390-409,
|
||||
# Rulings 4/11 + Ruling T6; projects_routes.py L155-186; files.py L57-94; api-map L7-19, L168-172).
|
||||
# Сценарий: очистка ProjectCards/вложений -> запуск Deal.Api (Development, DEAL_DEMO=1, LocalFileStorage)
|
||||
# -> 401 без куки (upload/download/delete) -> login admin/admin -> локальная карточка -> upload 2 файлов
|
||||
# (tz.pdf document/Документ, photo.png image/Изображение) -> GET карточки (files с kind/label/size) ->
|
||||
# download (200, attachment, octet-stream, Content-Length, байты совпадают) -> download чужого/нет записи
|
||||
# 404 -> upload на несуществующую карточку 404 -> upload не-multipart 400 -> DELETE файла {ok:true},
|
||||
# карточка без файла, объект удалён из data/attachments, download удалённого 404 -> запись с пустым
|
||||
# objectKey -> 410 (через psql) -> объект удалён напрямую из хранилища -> download 404 «Файл не найден
|
||||
# в MinIO» -> logout -> 401. Очистка созданных строк/вложений после приёмки.
|
||||
|
||||
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"
|
||||
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe не понимает bash-путь /tmp:
|
||||
# аргументы с '=' (files=@путь) не конвертируются MSYS-рантаймом). Все файлы (мультипарт-источники, jar,
|
||||
# body, headers) живут здесь — bash и curl видят один и тот же путь.
|
||||
TMPB=$(cygpath -m /tmp)/task9
|
||||
SRC="$TMPB/files"
|
||||
JAR="$TMPB/jar.txt"
|
||||
OUT="$TMPB/out.txt"
|
||||
HDR="$TMPB/hdr.txt"
|
||||
HDRN="$TMPB/hdrn.txt"
|
||||
DL="$TMPB/dl.bin"
|
||||
LOG="$TMPB/api.log"
|
||||
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
ATTACH="$API_DIR/data/attachments"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
APP_PID=""
|
||||
PRJ=""
|
||||
FID_PDF=""
|
||||
FID_PNG=""
|
||||
FID_410=""
|
||||
KEY_PDF=""
|
||||
SZ_PDF=""
|
||||
SZ_PNG=""
|
||||
|
||||
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"
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка по заголовкам ответа (нормализованы в $HDRN: lowercase, без \r).
|
||||
header_check() {
|
||||
desc=$1
|
||||
shift
|
||||
ok=1
|
||||
for pat in "$@"; do
|
||||
if ! grep -qF -- "$pat" "$HDRN"; 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 "$HDRN"
|
||||
fi
|
||||
}
|
||||
|
||||
# Первый id (pr_) из JSON-тела ответа (тело — первая строка $OUT, вторая — служебный [HTTP:...]).
|
||||
extract_id() {
|
||||
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
|
||||
}
|
||||
|
||||
# n-й файловый id (pf_) из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
|
||||
file_id_at() {
|
||||
grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$1" | sed -n "${2}s/.*\"id\":\"\(pf_[0-9a-f][0-9a-f]*\)\"/\1/p"
|
||||
}
|
||||
|
||||
# n-й objectKey из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
|
||||
object_key_at() {
|
||||
grep -o '"objectKey":"[^"]*"' "$1" | sed -n "${2}s/.*\"objectKey\":\"\([^\"]*\)\"/\1/p"
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 2
|
||||
if netstat -ano 2>/dev/null | grep -q ':5080'; then
|
||||
taskkill //F //PID "$1" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
echo " [PASS] Deal.Api остановлен"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: остановка Api и очистка созданных строк/вложений =="
|
||||
stop_app "$APP_PID"
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
|
||||
rm -rf "$ATTACH/projects/$PRJ" 2>/dev/null
|
||||
rm -rf "$TMPB"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$DL" "$LOG"
|
||||
mkdir -p "$SRC"
|
||||
printf '%s' '%PDF-1.4 Task9 tz document bytes 1234567890' > "$SRC/tz.pdf"
|
||||
printf '%s' 'Task9 photo bytes png 0987654321 xyz' > "$SRC/photo.png"
|
||||
SZ_PDF=$(wc -c < "$SRC/tz.pdf")
|
||||
SZ_PNG=$(wc -c < "$SRC/photo.png")
|
||||
|
||||
echo "== 0. Очистка ProjectCards дефолтного тенанта и вложений (повторяемость) =="
|
||||
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
|
||||
rm -rf "$ATTACH/projects" 2>/dev/null
|
||||
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
|
||||
if [ "$ROWS_LEFT" = "0" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] ProjectCards пусты"
|
||||
else
|
||||
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$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 40 ]; then
|
||||
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
|
||||
tail -n 30 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
grep -q 'LocalFileStorage' "$LOG"
|
||||
if [ $? = 0 ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
|
||||
head -n 3 "$LOG"
|
||||
fi
|
||||
echo " health: $(curl -s "$BASE_URL/api/health")"
|
||||
|
||||
echo
|
||||
echo "== 2. 401 без сессии на файл-эндпоинтах =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects/pr_x/files/pf_x/download" > "$OUT"
|
||||
check "GET download без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X DELETE "$BASE_URL/api/projects/pr_x/files/pf_x" > "$OUT"
|
||||
check "DELETE файла без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -X POST -F "files=@$SRC/tz.pdf;type=application/pdf" "$BASE_URL/api/projects/pr_x/files" > "$OUT"
|
||||
check "POST files без куки → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== 3. Login admin/admin =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
|
||||
check "login 200 ok" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 4. Локальная карточка POST /api/projects {title:''} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
|
||||
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
|
||||
PRJ=$(extract_id)
|
||||
echo " -> PRJ: $PRJ"
|
||||
if [ -z "$PRJ" ]; then exit 1; fi
|
||||
|
||||
echo
|
||||
echo "== 5. Upload 2 файлов: tz.pdf (pdf) + photo.png (png) → {items:[2]} =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
|
||||
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
|
||||
-F "files=@$SRC/photo.png;type=image/png;filename=photo.png" \
|
||||
"$BASE_URL/api/projects/$PRJ/files" > "$OUT"
|
||||
check "upload 200 {items:[2]}" '[HTTP:200]' '"items":[' '"id":"pf_'
|
||||
check "tz.pdf → document/Документ" '"name":"tz.pdf"' '"kind":"document"' '"label":"Документ"'
|
||||
check "photo.png → image/Изображение" '"name":"photo.png"' '"kind":"image"' '"label":"Изображение"'
|
||||
check "size записей = размеры файлов" "\"size\":$SZ_PDF" "\"size\":$SZ_PNG"
|
||||
FID_PDF=$(file_id_at "$OUT" 1)
|
||||
FID_PNG=$(file_id_at "$OUT" 2)
|
||||
KEY_PDF=$(object_key_at "$OUT" 1)
|
||||
echo " -> FID_PDF: $FID_PDF, FID_PNG: $FID_PNG"
|
||||
echo " -> KEY_PDF: $KEY_PDF"
|
||||
if [ -z "$FID_PDF" ] || [ -z "$FID_PNG" ] || [ -z "$KEY_PDF" ]; then exit 1; fi
|
||||
|
||||
echo
|
||||
echo "== 6. GET /api/projects/{id}: files со счётчиком, объекты на диске data/attachments =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "карточка с files: оба файла в массиве" '[HTTP:200]' '"files":[' '"name":"tz.pdf"' '"name":"photo.png"'
|
||||
FILES_COUNT=$(grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l)
|
||||
if [ "$FILES_COUNT" = "2" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] files содержит 2 записи"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] files содержит записей: $FILES_COUNT"
|
||||
fi
|
||||
OBJ_COUNT=$(ls "$ATTACH/projects/$PRJ" 2>/dev/null | wc -l)
|
||||
if [ "$OBJ_COUNT" = "2" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] 2 объекта в data/attachments/projects/$PRJ"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] объектов на диске: $OBJ_COUNT (ожидалось 2)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 7. Download tz.pdf: 200, attachment, octet-stream, Content-Length, байты совпадают =="
|
||||
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
|
||||
check "download 200" '[HTTP:200]'
|
||||
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
|
||||
header_check "Content-Disposition attachment + имя" 'content-disposition:' 'attachment' 'tz.pdf'
|
||||
header_check "Content-Type octet-stream (local-режим)" 'content-type: application/octet-stream'
|
||||
header_check "Content-Length = размер файла" "content-length: $SZ_PDF"
|
||||
if cmp -s "$SRC/tz.pdf" "$DL"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] байты download совпадают с загруженным tz.pdf"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] байты download НЕ совпадают с tz.pdf"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 8. Download photo.png: 200 + байты совпадают =="
|
||||
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PNG/download" > "$OUT"
|
||||
check "download 200" '[HTTP:200]'
|
||||
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
|
||||
header_check "photo.png attachment" 'content-disposition:' 'attachment' 'photo.png'
|
||||
header_check "Content-Length png" "content-length: $SZ_PNG"
|
||||
if cmp -s "$SRC/photo.png" "$DL"; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] байты download совпадают с загруженным photo.png"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] байты download НЕ совпадают с photo.png"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 9. Download записи, которой нет в метаданных → 404 «Карточка не найдена» =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/pf_dead00000000/download" > "$OUT"
|
||||
check "download pf_dead → 404" '[HTTP:404]' 'Карточка не найдена'
|
||||
|
||||
echo
|
||||
echo "== 10. Upload на несуществующую карточку → 404; не-multipart → 400 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
|
||||
-F "files=@$SRC/tz.pdf;type=application/pdf" "$BASE_URL/api/projects/pr_dead00000000/files" > "$OUT"
|
||||
check "upload на pr_dead → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{}' "$BASE_URL/api/projects/$PRJ/files" > "$OUT"
|
||||
check "upload JSON → 400 «Ожидается multipart/form-data»" '[HTTP:400]' 'Ожидается multipart/form-data'
|
||||
|
||||
echo
|
||||
echo "== 11. DELETE photo.png: {ok:true}; карточка без файла; объект удалён с диска; download → 404 =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PNG" > "$OUT"
|
||||
check "DELETE файла → 200 {ok:true}" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "карточка без photo.png" '"name":"tz.pdf"'
|
||||
if grep -qF -- '"name":"photo.png"' "$OUT"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] photo.png остался в files карточки"
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] photo.png удалён из files карточки"
|
||||
fi
|
||||
OBJ_COUNT=$(ls "$ATTACH/projects/$PRJ" 2>/dev/null | wc -l)
|
||||
if [ "$OBJ_COUNT" = "1" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] объект photo.png удалён из data/attachments (остался tz.pdf)"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] объектов на диске после DELETE: $OBJ_COUNT (ожидалось 1)"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PNG/download" > "$OUT"
|
||||
check "download удалённого → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
|
||||
|
||||
echo
|
||||
echo "== 12. Запись с пустым objectKey (psql) → download 410 =="
|
||||
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"FilesJson\" = replace(\"FilesJson\", '\"objectKey\":\"$KEY_PDF\"', '\"objectKey\":\"\"') WHERE \"Id\" = '$PRJ';" >/dev/null
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
|
||||
check "download без objectKey → 410 «Файл не сохранён в объектном хранилище»" '[HTTP:410]' 'Файл не сохранён в объектном хранилище'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PDF" > "$OUT"
|
||||
check "DELETE записи без objectKey → {ok:true} (delete объекта пропущен)" '[HTTP:200]' '"ok":true'
|
||||
|
||||
echo
|
||||
echo "== 13. Объект удалён напрямую из хранилища при живой мете → download 404 «Файл не найден в MinIO» =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
|
||||
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
|
||||
"$BASE_URL/api/projects/$PRJ/files" > "$OUT"
|
||||
check "повторный upload tz.pdf → {items:[1]}" '[HTTP:200]' '"name":"tz.pdf"'
|
||||
FID_PDF=$(file_id_at "$OUT" 1)
|
||||
KEY_PDF=$(object_key_at "$OUT" 1)
|
||||
rm -f "$ATTACH/$KEY_PDF"
|
||||
if [ ! -f "$ATTACH/$KEY_PDF" ]; then
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] объект удалён из data/attachments напрямую"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] объект не удалился: $ATTACH/$KEY_PDF"
|
||||
fi
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
|
||||
check "download отсутствующего объекта → 404 «Файл не найден в MinIO»" '[HTTP:404]' 'Файл не найден в MinIO'
|
||||
|
||||
echo
|
||||
echo "== 14. DELETE файла с удалённым объектом → {ok:true}; карточка без files =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PDF" > "$OUT"
|
||||
check "DELETE → {ok:true}" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
|
||||
check "карточка с files:[]" '"files":[]'
|
||||
|
||||
echo
|
||||
echo "== 15. logout → 401 на download =="
|
||||
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
|
||||
check "logout 200" '[HTTP:200]' '"ok":true'
|
||||
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects/$PRJ/files/pf_x/download" > "$OUT"
|
||||
check "download после logout → 401" '[HTTP:401]' 'Требуется авторизация'
|
||||
|
||||
echo
|
||||
echo "== Проверка лога Api: исключений нет =="
|
||||
if grep -qE 'Exception|\[ERR\]|Unhandled' "$LOG"; then
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
echo " [FAIL] в логе Api есть исключения:"
|
||||
grep -E 'Exception|\[ERR\]|Unhandled' "$LOG" | head -n 5
|
||||
else
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
echo " [PASS] лог Api чист (без исключений)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
|
||||
tail -n 25 "$LOG"
|
||||
exit 1
|
||||
fi
|
||||
echo " [PASS] файл-эндпоинты приняты"
|
||||
exit 0
|
||||
@@ -0,0 +1,99 @@
|
||||
# Task 9 — Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка — отчёт
|
||||
|
||||
Статус: **DONE** (build 0/0; 602/602 PASS — 599 этапов 1–8 + 3 новых StatAsync-теста LocalFileStorage;
|
||||
curl-приёмка :5080 — **41/41 PASS**, локальный режим LocalFileStorage). План:
|
||||
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 9 (L390–409), Rulings 4/11 + Ruling T6;
|
||||
источники `backend/app/routers/projects_routes.py` L155–186, `files.py` L57–94, `object_store.py` L61–108,
|
||||
api-map L7–19/L168–172, §4.3 L283–301; фронт store.js L2031–2053 (addProjectFiles/removeProjectFile),
|
||||
ProjectDrawer.vue (прямая ссылка …/files/{id}/download).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Изменены (контракт/адаптеры — закрытие Ruling T6)
|
||||
- `Deal.Contracts/Integrations/IFileStorage.cs` — новый метод порта `StatAsync(objectKey, ct) → FileMeta?`
|
||||
(MinIO StatObject / FileInfo локального файла; null — объекта нет). Ruling T6 (progress.md L19: «FileMeta
|
||||
задействуется в T9 — download: Content-Length/Type через StatObject/FileInfo») закрыт: дескриптор больше не
|
||||
«мёртвый тип», у download-эндпоинта есть источник Content-Length/Content-Type и отдельная проверка
|
||||
«объекта нет» до открытия потока. XML-doc порта/дескриптора обновлены.
|
||||
- `Deal.Contracts/Integrations/Models/FileMeta.cs` — XML-doc: у типа появился потребитель (StatAsync →
|
||||
download Task 9); описан пустой ContentType для Local-режима.
|
||||
- `Deal.Infrastructure/Integrations/Storage/LocalFileStorage.cs` — `StatAsync`: FileInfo (размер; объект
|
||||
отсутствует → null); ContentType пуст — локально MIME не хранится (как прототип: пишутся только байты).
|
||||
- `Deal.Infrastructure/Integrations/Storage/MinioFileStorage.cs` — `StatAsync`: `StatObjectAsync` →
|
||||
FileMeta(размер + contentType, сохранённый при put); `ObjectNotFoundException` → null (как GetAsync);
|
||||
иные сбои уходят вызывающему (эндпоинт мапит их в 404 «Файл не найден в MinIO»).
|
||||
- `Deal.Api/Http/EndpointResults.cs` — добавлен `Gone(detail)` (410, формат {detail}; api-map §1: код 410 в
|
||||
списке ошибок прототипа).
|
||||
|
||||
### Изменён (эндпоинты)
|
||||
- `Deal.Api/Endpoints/ProjectsEndpoints.cs` — 3 файл-маршрута (13 эндпоинтов группы; порядок вложенных за
|
||||
/{cardId}, Ruling 9):
|
||||
- **POST /{cardId}/files** — multipart/form-data, поле `files` (routes L155–162): 401-гейт; карточка
|
||||
проверяется ДО чтения формы/записи объектов (эталон _card_or_404 L156; AddAsync не пишет объект на
|
||||
отсутствующей карточке — приёмка Task 7); `context.Request.ReadFormAsync`; каждый файл
|
||||
(имя/ContentType/поток/длина) → `ProjectFilesService.AddAsync`; ответ `{items:[§4.3 файл]}`; 404
|
||||
«Карточка не найдена»; тело не-multipart → 400 «Ожидается multipart/form-data» (новый не-прототипный
|
||||
текст — фронт так не шлёт, FastAPI-422 у нас не повторяется; прецедент InvalidBodyDetail Task 8).
|
||||
Лимит тела multipart — дефолты HTTP-слоя (Kestrel MaxRequestBodySize/FormOptions), свой не вводим
|
||||
(в прототипе лимитов нет, зона — HTTP-слой, зафиксировано в ProjectFilesService class-doc Task 7).
|
||||
- **GET /{cardId}/files/{fileId}/download** — поток (routes L165–179): entry нет (карточка/запись) → 404
|
||||
«Карточка не найдена» (Ruling 4: прототип на этом пути KeyError/500 — у нас корректный 404);
|
||||
objectKey пуст → **410** «Файл не сохранён в объектном хранилище»; `StatAsync` + `GetAsync` (null или
|
||||
сбой хранилища, кроме отмены → 404 «Файл не найден в MinIO» — фиксированная строка routes L173, любое
|
||||
исключение get → 404); ответ — `Results.Stream` (сам диспозит поток): Content-Length = FileMeta.Size,
|
||||
Content-Type = FileMeta.ContentType если непуст (MinIO — MIME из put) иначе `application/octet-stream`
|
||||
(Local-режим, 1:1 routes L174–179), Content-Disposition attachment с именем без кавычек «"»
|
||||
(safe_name L174) — Ruling T6 закрыт. Хелпер `ToDownloadFileName`.
|
||||
- **DELETE /{cardId}/files/{fileId}** — `{ok:true}` | 404 карточки (routes L182–186); записи нет в
|
||||
FilesJson — успех без изменений (эталон RemoveLinkAsync/RemoveAsync Task 7); objectKey пуст — delete
|
||||
объекта пропускается (remove_file L92).
|
||||
|
||||
### Создан/изменён (тесты и приёмка)
|
||||
- `tests/Deal.Tests.Unit/FakeFileStorage.cs` — реализован `StatAsync` (семантика Minio-адаптера: размер +
|
||||
MIME как при put; null — объекта нет) — интерфейс порта расширен, фейк обязан реализовать.
|
||||
- `tests/Deal.Tests.Unit/LocalFileStorageTests.cs` — +3 теста Stat: после put → размер и пустой
|
||||
ContentType; отсутствующий объект → null; «..»-обход → ArgumentException (как Get/Delete).
|
||||
- `.superpowers/sdd/deal-stage5-projects/task-9-curl-acceptance.sh` (+ лог `task-9-curl-acceptance.log`) —
|
||||
приёмочный сценарий :5080 (ниже).
|
||||
|
||||
## Решения и замечания
|
||||
|
||||
- **Ruling T6 закрыт расширением порта**: FileMeta (Key/Size/ContentType) было не у чего «задействовать» —
|
||||
порт не умел отдавать мету объекта. Добавлен `StatAsync` (MinIO StatObject / Local FileInfo); download
|
||||
берёт из дескриптора Content-Length и Content-Type. Отклонений от Ruling 4 нет: локальный режим MIME не
|
||||
хранит → пустой ContentType дескриптора → `application/octet-stream` (1:1 прототип и api-map §1);
|
||||
MinIO-режим отдаёт сохранённый при put MIME (attachment остаётся — браузер скачивает, а не открывает).
|
||||
Content-Disposition формирует `Results.Stream` через `fileDownloadName` (имя без кавычек «"»).
|
||||
- **410 vs 404 порядка 1:1 с routes**: entry/карточка не найдены → 404 «Карточка не найдена» (не 500,
|
||||
Ruling 4); objectKey пуст → 410 до обращения к хранилищу; объект отсутствует/хранилище недоступно → 404
|
||||
«Файл не найден в MinIO» (фиксированная строка прототипа, даже для Local-режима — текст 1:1 с routes
|
||||
L173, не «переводим»).
|
||||
- **Upload-ответ — мета, не карточка**: `{items:[§4.3 файл]}` — фронт после upload/delete сам перечитывает
|
||||
карточку (store.js L2031–2053, api-map §4.3 L300). Ответ на 2 файла — 2 записи в порядке формы.
|
||||
- **FileKindDetector на download не нужен**: kind/label есть в метаданных записи, но MIME ответа даёт
|
||||
хранилище (StatObject) либо фиксированный octet-stream; детектор остаётся зоной upload (Task 7).
|
||||
- Лимиты размера не вводим (прототип без лимитов; HTTP-слой — дефолты Kestrel 30 МБ/FormOptions).
|
||||
- Curl-сценарий на Windows: native curl (mingw64) не видит bash-путь `/tmp` в `-F files=@…` (аргумент с
|
||||
`=` не конвертируется MSYS) и в `-c/-b/-o/-D` — рабочие файлы приёмки положены в Windows-TEMP
|
||||
(`cygpath -m /tmp`) в Windows-форме. Кириллица в теле запросов curl искажается (как в Task 8) — в
|
||||
запросах ASCII, серверные ответы проверяются кириллическими подстроками.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `scripts/build.sh` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
|
||||
2. `scripts/test.sh` — **602/602 PASS** (599 + 3 StatAsync LocalFileStorage; таргетно
|
||||
LocalFileStorageTests/ProjectFilesServiceTests/FileKindDetectorTests — зелёные).
|
||||
3. Curl-приёмка :5080 (`task-9-curl-acceptance.sh` → `task-9-curl-acceptance.log`) — **PASS=41 FAIL=0**
|
||||
(local-режим, LocalFileStorage из стартового лога): очистка → запуск → 401 без куки (download/delete/
|
||||
upload) → login → локальная карточка → upload tz.pdf+photo.png `{items:[2]}` (document/«Документ»,
|
||||
image/«Изображение», size=файлов; объекты в data/attachments/projects/{pr}) → GET карточки files:2 →
|
||||
download обоих: 200, Content-Disposition attachment+имя, Content-Type octet-stream, Content-Length,
|
||||
байты cmp-совпадают → download pf_dead 404 «Карточка не найдена» → upload на pr_dead 404 → upload JSON
|
||||
400 «Ожидается multipart/form-data» → DELETE photo.png `{ok:true}` (карточка без файла, объект удалён с
|
||||
диска, download удалённого 404) → objectKey='' через psql → download **410** «Файл не сохранён в
|
||||
объектном хранилище» (DELETE ok) → объект удалён напрямую из data/attachments → download 404 «Файл не
|
||||
найден в MinIO» → DELETE → files:[] → logout → 401. Лог Api без исключений; после приёмки строки
|
||||
очищены (0), порт :5080 свободен.
|
||||
|
||||
## Отчёт
|
||||
`.superpowers/sdd/deal-stage5-projects/task-9-report.md`; ledger progress.md обновлён (Task 9 complete).
|
||||
Reference in New Issue
Block a user