diff --git a/.gitignore b/.gitignore index 8349ce7..31a0067 100644 --- a/.gitignore +++ b/.gitignore @@ -330,3 +330,6 @@ my_project_env .codegraph/ .superpowers/ .bounded + +# scan-gateway: локальная горячая папка dev-режима +api/.hotfolder/ diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..28ffd93 --- /dev/null +++ b/api/.env.example @@ -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 diff --git a/api/requirements.txt b/api/requirements.txt index f26f6d9..b465576 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/api/src/app.py b/api/src/app.py index 845e7c0..940e53e 100644 --- a/api/src/app.py +++ b/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() diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..cd7881a 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -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() \ No newline at end of file + return Settings(_env_file=os.environ.get("ENV_FILE", ".env")) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index f7643c2..2f7b412 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -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. diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e69de29..446666c 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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() diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py new file mode 100644 index 0000000..9837c24 --- /dev/null +++ b/api/tests/unit/test_app.py @@ -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": "внутренняя ошибка сервера"} diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py new file mode 100644 index 0000000..14e67a4 --- /dev/null +++ b/api/tests/unit/test_config.py @@ -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) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 0b8566c..09323a8 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -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, запуск diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0999d18..01e2d92 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -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"`,