Срез 0 [1.2]: каркас приложения без БД и фронта, настройки scan-gateway
- app.py: create_app(settings), SMB-сессия только при SCAN_HOTFOLDER_MODE=smb, без SPA/CORS/РАИСА/nkeys; /healthcheck отдаёт uptime и version - config.py: ENV_FILE, get_settings() с lru_cache, параметры горячих папок/NATS, блок OPENBAO__SMB необязателен, валидация режимов local/smb; убраны DBSettings и OPENBAO.DATABASE - exception_handlers.py: убрана зависимость от sqlalchemy - requirements.txt: убраны asyncpg, SQLAlchemy, alembic, argon2-cffi(+bindings), raisa_*, greenlet, Mako - api/.env.example: минимальный local-режим против compose; unit-тесты config/app Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
31a50879c2
commit
b6eecfcf34
3
.gitignore
vendored
3
.gitignore
vendored
@ -330,3 +330,6 @@ my_project_env
|
||||
.codegraph/
|
||||
.superpowers/
|
||||
.bounded
|
||||
|
||||
# scan-gateway: локальная горячая папка dev-режима
|
||||
api/.hotfolder/
|
||||
|
||||
20
api/.env.example
Normal file
20
api/.env.example
Normal file
@ -0,0 +1,20 @@
|
||||
# Локальное окружение scan-gateway. Скопируйте в .env: cp .env.example .env (.env в git не попадает)
|
||||
# Значения совпадают с ../docs/dev/docker-compose.yml и ../docs/dev/.env (compose: NATS 4222, MinIO 9000, бакет arm-ka).
|
||||
# Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/).
|
||||
|
||||
DEBUG=true
|
||||
|
||||
# MinIO: адрес без схемы (host:port) — так ждёт miniopy-async; MinIO из compose работает по http
|
||||
OPENBAO__MINIO__MINIO_URL=localhost:9000
|
||||
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
|
||||
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin
|
||||
OPENBAO__MINIO__MINIO_BUCKET=arm-ka
|
||||
OPENBAO__MINIO__MINIO_SECURE=false
|
||||
|
||||
# NATS без JetStream; NATS_SEED/NATS_JWT не задаются (пусто — подключение без nkeys)
|
||||
OPENBAO__NATS__NATS_URL=nats://localhost:4222
|
||||
|
||||
# Горячие папки ИС Сканирование: local — обычный каталог (каталог должен существовать: mkdir -p .hotfolder).
|
||||
# Режим smb требует блока OPENBAO__SMB__SMB_HOST / SMB_PATH / SMB_LOGIN / SMB_PASSWORD.
|
||||
SCAN_HOTFOLDER_MODE=local
|
||||
SCAN_HOTFOLDER_PATH=./.hotfolder
|
||||
@ -2,13 +2,9 @@ aiohappyeyeballs==2.7.1
|
||||
aiohttp==3.14.3
|
||||
aiohttp-retry==2.9.1
|
||||
aiosignal==1.4.0
|
||||
alembic==1.14.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
argon2-cffi==23.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
asyncpg==0.30.0
|
||||
attrs==26.1.0
|
||||
certifi==2025.11.12
|
||||
cffi==2.1.0
|
||||
@ -20,7 +16,6 @@ fastapi-cli==0.0.24
|
||||
fastapi-cloud-cli==0.14.1
|
||||
fastar==0.8.0
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.4.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
@ -28,7 +23,6 @@ httpx==0.28.1
|
||||
idna==3.18
|
||||
Jinja2==3.1.6
|
||||
loguru==0.7.3
|
||||
Mako==1.3.10
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
@ -48,13 +42,11 @@ PyNaCl==1.5.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
PyYAML==6.0.3
|
||||
raisa_fastapi_protected_api==1.0.0
|
||||
rich==15.0.0
|
||||
rich-toolkit==0.19.7
|
||||
rignore==0.7.6
|
||||
sentry-sdk==2.66.1
|
||||
shellingham==1.5.4
|
||||
SQLAlchemy==2.0.36
|
||||
smbprotocol==1.15.0
|
||||
starlette==1.3.1
|
||||
typer==0.27.0
|
||||
|
||||
198
api/src/app.py
198
api/src/app.py
@ -1,69 +1,23 @@
|
||||
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
|
||||
"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта.
|
||||
|
||||
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование),
|
||||
middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA.
|
||||
Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result
|
||||
(docs/context/03-architecture.md), зависимость require(permission).
|
||||
Что здесь есть: настройки, регистрация SMB-сессии (только при `SCAN_HOTFOLDER_MODE=smb`), middleware (gzip, proxy
|
||||
headers), обработчики ошибок, `GET /healthcheck` (`uptime`, `version`).
|
||||
Чего здесь пока нет: NATS-воркера и S3-клиента (подключаются в lifespan задачами 5.2/5.3 change slice-0-skeleton),
|
||||
полей `nats`/`hotfolder` в healthcheck.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import nkeys
|
||||
import smbclient
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import HTTPException, StarletteHTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from miniopy_async import Minio
|
||||
from nats.aio.client import Client as NATS
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from src.api.router import router
|
||||
from src.core.clients import S3Storage
|
||||
from src.core.config import get_settings
|
||||
from src.core.config import Settings, get_settings
|
||||
from src.core.exception_handlers import register_exception_handlers
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
VERSION = settings.VERSION
|
||||
UP_TIME = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
|
||||
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8"))
|
||||
|
||||
|
||||
def jwt_cb():
|
||||
return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8")
|
||||
|
||||
|
||||
def sig_cb(nonce):
|
||||
if isinstance(nonce, str):
|
||||
nonce = nonce.encode("utf-8")
|
||||
return base64.b64encode(kp.sign(nonce))
|
||||
|
||||
|
||||
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
|
||||
protected_middleware_available = False
|
||||
if not settings.DEBUG:
|
||||
try:
|
||||
from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports]
|
||||
AuthorizationMiddleware,
|
||||
OpenEndpoint,
|
||||
ProtectedOAuthSettings,
|
||||
ProtectedRolesSettings,
|
||||
ProtectedSettings,
|
||||
SearchType,
|
||||
)
|
||||
|
||||
protected_middleware_available = True
|
||||
except ImportError:
|
||||
protected_middleware_available = False
|
||||
|
||||
|
||||
def _normalize_root_path(value: str) -> str:
|
||||
if not value:
|
||||
@ -74,116 +28,40 @@ def _normalize_root_path(value: str) -> str:
|
||||
return "/" + value.strip("/")
|
||||
|
||||
|
||||
_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings = settings or get_settings()
|
||||
version = settings.VERSION
|
||||
up_time = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.nats_subs = []
|
||||
app.state.storage = None
|
||||
app.state.nats = None
|
||||
|
||||
try:
|
||||
smbclient.register_session(
|
||||
settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Ошибка при регистрации сессии SMB")
|
||||
|
||||
s3_client = Minio(
|
||||
settings.OPENBAO.MINIO.MINIO_URL,
|
||||
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
|
||||
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
|
||||
cert_check=False,
|
||||
)
|
||||
try:
|
||||
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
return
|
||||
app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||
|
||||
nc = NATS()
|
||||
if not settings.DEBUG:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
smb_session = False
|
||||
if settings.SCAN_HOTFOLDER_MODE == "smb":
|
||||
smb = settings.OPENBAO.SMB
|
||||
try:
|
||||
smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
|
||||
smb_session = True
|
||||
except Exception:
|
||||
logger.exception("Ошибка при регистрации сессии SMB")
|
||||
try:
|
||||
await nc.connect(
|
||||
settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2
|
||||
)
|
||||
logger.info("Подключение к NATS произошло")
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
return
|
||||
app.state.nats = nc
|
||||
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
|
||||
logger.info("Приложение запущено")
|
||||
yield
|
||||
finally:
|
||||
logger.info("Приложение закрывается")
|
||||
if smb_session:
|
||||
smbclient.reset_connection_cache()
|
||||
|
||||
try:
|
||||
logger.info("Приложение запущено")
|
||||
yield
|
||||
finally:
|
||||
logger.info("Приложение закрывается")
|
||||
smbclient.reset_connection_cache()
|
||||
for sub in app.state.nats_subs:
|
||||
await sub.unsubscribe()
|
||||
if app.state.nats and not settings.DEBUG:
|
||||
await app.state.nats.close()
|
||||
if app.state.storage:
|
||||
await app.state.storage.client.close_session()
|
||||
app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
||||
|
||||
@app.get("/healthcheck", status_code=200)
|
||||
async def healthcheck():
|
||||
return {"uptime": up_time, "version": version}
|
||||
|
||||
app.include_router(router)
|
||||
register_exception_handlers(app)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
||||
return app
|
||||
|
||||
|
||||
app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/healthcheck", status_code=200)
|
||||
async def healthcheck():
|
||||
return {"uptime": UP_TIME, "version": VERSION}
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- Раздача собранного SPA (web/) ---------------------------------------------------------------
|
||||
os.makedirs("web", exist_ok=True)
|
||||
|
||||
|
||||
def sep_static(path: str, stat_dir: str) -> str:
|
||||
sep_path = path.split("/")
|
||||
index = sep_path.index(stat_dir)
|
||||
return "/".join(sep_path[index:])
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except (HTTPException, StarletteHTTPException) as ex:
|
||||
if ex.status_code == 404:
|
||||
if "images" in path:
|
||||
return await super().get_response(sep_static(path, "images"), scope)
|
||||
if "static" in path:
|
||||
return await super().get_response(sep_static(path, "static"), scope)
|
||||
return await super().get_response("index.html", scope)
|
||||
raise
|
||||
|
||||
|
||||
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
||||
app = create_app()
|
||||
|
||||
@ -1,50 +1,97 @@
|
||||
"""Настройки сервиса (pydantic-settings).
|
||||
|
||||
Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них.
|
||||
Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`;
|
||||
прикладные параметры — обычными именами верхнего уровня (`SCAN_HOTFOLDER_MODE`, `SCAN_POLL_SECONDS`, ...).
|
||||
Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь считается от текущего каталога — `api/`);
|
||||
переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте.
|
||||
"""
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import PositiveFloat, PositiveInt, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DBSettings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
|
||||
class MinioSettings(BaseSettings):
|
||||
MINIO_URL: str
|
||||
MINIO_ADMIN_LOGIN: str
|
||||
MINIO_ADMIN_PASSWORD: str
|
||||
MINIO_BUCKET: str
|
||||
MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose)
|
||||
|
||||
|
||||
class NATSSettings(BaseSettings):
|
||||
NATS_URL: str
|
||||
NATS_SEED: str
|
||||
NATS_JWT: str
|
||||
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys/JWT (локальная разработка, тесты)
|
||||
NATS_JWT: str = ""
|
||||
|
||||
|
||||
class SMBSettings(BaseSettings):
|
||||
SMB_HOST: str
|
||||
SMB_PATH: str
|
||||
SMB_LOGIN: str
|
||||
SMB_PASSWORD: str
|
||||
"""SMB-шара горячих папок ИС Сканирование. Нужна только при `SCAN_HOTFOLDER_MODE=smb` (там все поля обязательны,
|
||||
проверяет `Settings`); в режиме local весь блок можно не задавать."""
|
||||
|
||||
SMB_HOST: str = ""
|
||||
SMB_PATH: str = ""
|
||||
SMB_LOGIN: str = ""
|
||||
SMB_PASSWORD: str = ""
|
||||
|
||||
|
||||
class OpenBAOSettings(BaseSettings):
|
||||
DATABASE: DBSettings
|
||||
MINIO: MinioSettings
|
||||
NATS: NATSSettings
|
||||
SMB: SMBSettings
|
||||
SMB: SMBSettings = SMBSettings() # весь блок необязателен: в режиме local не требуется
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
OPENBAO: OpenBAOSettings
|
||||
DEBUG: bool = False
|
||||
server: ServerSettings = ServerSettings()
|
||||
ROOT_PATH: str = '/'
|
||||
VERSION: str = '1.0.0'
|
||||
ROOT_PATH: str = "/"
|
||||
VERSION: str = "1.0.0"
|
||||
SERVICE_NAME: str = "scan-gateway" # service.name в конверте recognition.result
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
env_file = ".env"
|
||||
env_nested_delimiter = "__"
|
||||
nested_model_default_partial_update = False
|
||||
# NATS: подписка на recognition.image.request и публикация. Выключено — воркер не запускается (dev без NATS)
|
||||
NATS_ENABLED: bool = True
|
||||
PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации результата/ack
|
||||
DONE_CACHE_SIZE: PositiveInt = 1000 # размер LRU обработанных job_id (дедупликация повторов)
|
||||
|
||||
# Горячие папки ИС Сканирование: local — обычный каталог (dev, тесты), smb — SMB-шара платформы
|
||||
SCAN_HOTFOLDER_MODE: Literal["local", "smb"] = "local"
|
||||
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
||||
SCAN_INPUT_DIR: str = "Input"
|
||||
SCAN_OUTPUT_DIR: str = "Output"
|
||||
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
||||
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
|
||||
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
env_file_encoding="utf-8",
|
||||
env_nested_delimiter="__",
|
||||
nested_model_default_partial_update=False,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_hotfolder(self) -> "Settings":
|
||||
if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip():
|
||||
raise ValueError("SCAN_HOTFOLDER_MODE=local требует SCAN_HOTFOLDER_PATH (корень горячей папки)")
|
||||
if self.SCAN_HOTFOLDER_MODE == "smb":
|
||||
missing = [name for name, value in self.OPENBAO.SMB.model_dump().items() if not value.strip()]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"SCAN_HOTFOLDER_MODE=smb требует OPENBAO__SMB__SMB_HOST/SMB_PATH/SMB_LOGIN/SMB_PASSWORD; "
|
||||
f"не заданы: {', '.join(missing)}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
settings = get_settings()
|
||||
return Settings(_env_file=os.environ.get("ENV_FILE", ".env"))
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@ -12,20 +11,6 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error_handler(_: Request, exc: IntegrityError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "нарушение целостности данных в БД"},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "ошибка базы данных"},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
"""Общие фикстуры. Минимальное окружение local-режима задаётся здесь, чтобы `import src` (он создаёт приложение)
|
||||
работал без `.env.test` и без инфраструктуры; значения можно переопределить переменными окружения."""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_MINIMAL_ENV = {
|
||||
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "minioadmin",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "minioadmin",
|
||||
"OPENBAO__MINIO__MINIO_BUCKET": "arm-ka",
|
||||
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
|
||||
"SCAN_HOTFOLDER_MODE": "local",
|
||||
"SCAN_HOTFOLDER_PATH": "/tmp/scan-gateway-test-hotfolder",
|
||||
}
|
||||
for _key, _value in _MINIMAL_ENV.items():
|
||||
os.environ.setdefault(_key, _value)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_settings():
|
||||
"""get_settings кэшируется (lru_cache): сбрасываем кэш до и после каждого теста."""
|
||||
from src.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
76
api/tests/unit/test_app.py
Normal file
76
api/tests/unit/test_app.py
Normal file
@ -0,0 +1,76 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import smbclient
|
||||
|
||||
from src.app import create_app
|
||||
from src.core.config import get_settings
|
||||
|
||||
|
||||
def _client(app) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
async def test_healthcheck_returns_uptime_and_version_without_infrastructure(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
app = create_app(get_settings())
|
||||
|
||||
# lifespan не запускается: подключений к NATS/S3 нет
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/healthcheck")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body) == {"uptime", "version"}
|
||||
assert body["version"] == get_settings().VERSION
|
||||
datetime.fromisoformat(body["uptime"])
|
||||
|
||||
|
||||
async def test_lifespan_does_not_register_smb_session_in_local_mode(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
|
||||
register = MagicMock()
|
||||
monkeypatch.setattr(smbclient, "register_session", register)
|
||||
app = create_app(get_settings())
|
||||
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
assert (await client.get("/healthcheck")).status_code == 200
|
||||
|
||||
register.assert_not_called()
|
||||
|
||||
|
||||
async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
|
||||
monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example")
|
||||
monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share")
|
||||
monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user")
|
||||
monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret")
|
||||
register = MagicMock()
|
||||
reset = MagicMock()
|
||||
monkeypatch.setattr(smbclient, "register_session", register)
|
||||
monkeypatch.setattr(smbclient, "reset_connection_cache", reset)
|
||||
app = create_app(get_settings())
|
||||
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
assert (await client.get("/healthcheck")).status_code == 200
|
||||
|
||||
register.assert_called_once_with("smb.example", "user", "secret")
|
||||
reset.assert_called_once()
|
||||
|
||||
|
||||
async def test_unhandled_error_has_json_500(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
app = create_app(get_settings())
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom():
|
||||
raise RuntimeError("x")
|
||||
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/boom")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "внутренняя ошибка сервера"}
|
||||
122
api/tests/unit/test_config.py
Normal file
122
api/tests/unit/test_config.py
Normal file
@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.core.config import Settings, get_settings
|
||||
|
||||
SMB_ENV = {
|
||||
"OPENBAO__SMB__SMB_HOST": "smb.example",
|
||||
"OPENBAO__SMB__SMB_PATH": "share",
|
||||
"OPENBAO__SMB__SMB_LOGIN": "user",
|
||||
"OPENBAO__SMB__SMB_PASSWORD": "secret",
|
||||
}
|
||||
|
||||
|
||||
def _clean_env(monkeypatch):
|
||||
"""Убрать из окружения всё, что относится к SMB/NATS-учётным данным и режиму горячей папки."""
|
||||
for key in list(SMB_ENV) + ["OPENBAO__NATS__NATS_SEED", "OPENBAO__NATS__NATS_JWT", "SCAN_HOTFOLDER_MODE"]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
|
||||
s = get_settings()
|
||||
|
||||
assert s.SERVICE_NAME == "scan-gateway"
|
||||
assert s.NATS_ENABLED is True
|
||||
assert s.PUBLISH_RETRIES == 3
|
||||
assert s.DONE_CACHE_SIZE == 1000
|
||||
assert s.SCAN_HOTFOLDER_MODE == "local"
|
||||
assert s.SCAN_INPUT_DIR == "Input"
|
||||
assert s.SCAN_OUTPUT_DIR == "Output"
|
||||
assert s.SCAN_POLL_SECONDS == 5
|
||||
assert s.SCAN_TIMEOUT_SECONDS == 300
|
||||
assert s.SCAN_MAX_INFLIGHT == 4
|
||||
assert s.OPENBAO.SMB.SMB_HOST == ""
|
||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
||||
assert not hasattr(s.OPENBAO, "DATABASE")
|
||||
|
||||
|
||||
def test_local_mode_requires_hotfolder_path(monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", "")
|
||||
|
||||
with pytest.raises(ValidationError, match="SCAN_HOTFOLDER_PATH"):
|
||||
get_settings()
|
||||
|
||||
|
||||
def test_smb_mode_without_smb_block_fails_with_clear_error(monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
|
||||
|
||||
with pytest.raises(ValidationError, match="OPENBAO__SMB"):
|
||||
get_settings()
|
||||
|
||||
|
||||
def test_smb_mode_with_smb_block_ok(monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
|
||||
for key, value in SMB_ENV.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
s = get_settings()
|
||||
|
||||
assert s.OPENBAO.SMB.SMB_HOST == "smb.example"
|
||||
|
||||
|
||||
def test_unknown_hotfolder_mode_rejected(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "ftp")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
get_settings()
|
||||
|
||||
|
||||
def test_env_file_selected_by_env_file_variable(monkeypatch, tmp_path):
|
||||
_clean_env(monkeypatch)
|
||||
for key in (
|
||||
"SCAN_HOTFOLDER_PATH",
|
||||
"SCAN_POLL_SECONDS",
|
||||
"SERVER__PORT",
|
||||
"OPENBAO__NATS__NATS_URL",
|
||||
"OPENBAO__MINIO__MINIO_URL",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD",
|
||||
"OPENBAO__MINIO__MINIO_BUCKET",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
env_file = tmp_path / "custom.env"
|
||||
env_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"OPENBAO__MINIO__MINIO_URL=minio.test:9000",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN=a",
|
||||
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=b",
|
||||
"OPENBAO__MINIO__MINIO_BUCKET=bucket",
|
||||
"OPENBAO__NATS__NATS_URL=nats://nats.test:4222",
|
||||
"SCAN_HOTFOLDER_PATH=/data/hot",
|
||||
"SCAN_POLL_SECONDS=1",
|
||||
"SERVER__PORT=8123",
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv("ENV_FILE", str(env_file))
|
||||
|
||||
s = get_settings()
|
||||
|
||||
assert s.OPENBAO.MINIO.MINIO_URL == "minio.test:9000"
|
||||
assert s.OPENBAO.NATS.NATS_URL == "nats://nats.test:4222"
|
||||
assert s.SCAN_HOTFOLDER_PATH == "/data/hot"
|
||||
assert s.SCAN_POLL_SECONDS == 1
|
||||
assert s.server.PORT == 8123
|
||||
|
||||
|
||||
def test_get_settings_is_cached(monkeypatch):
|
||||
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
|
||||
assert get_settings() is get_settings()
|
||||
assert isinstance(get_settings(), Settings)
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
### Task 2: [1.2] `src/app.py`
|
||||
|
||||
- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200
|
||||
- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200
|
||||
|
||||
### Task 3: [1.3] `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
- [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`,
|
||||
`api/src/models/`, `api/src/repositories/`; убрать `Dockerfile` из `.gitignore`; проверка: `git status` — только удаления и `.gitignore`
|
||||
- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию
|
||||
- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию
|
||||
(только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`,
|
||||
`SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`,
|
||||
`SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user