Merge branch 'dev' into feat/openapi-contrat
Complète le contrat OpenAPI de GET /sites et GET /sites/{site_id} (merges depuis dev via #78) :
tag sites décrit, REPONSES_LECTEUR (401 + 403 mot de passe provisoire) posée au niveau du
routeur, 404 et 422 documentés sur la route detail. openapi.json régénéré.
This commit is contained in:
@@ -107,6 +107,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| `/api/v1/users` | Liste et crée des comptes | `admin` |
|
||||
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
|
||||
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
|
||||
| `/api/v1/sites` | Liste les sites | `lecteur` |
|
||||
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ from app.db.session import get_session
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.auth import AuthService, LoginPolicy
|
||||
from app.services.site import SiteService
|
||||
from app.services.user import UserService
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
@@ -131,6 +133,13 @@ def get_user_service(
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
def get_site_service(session: SessionDep) -> SiteService:
|
||||
return SiteService(sites=SiteRepository(session))
|
||||
|
||||
|
||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -50,6 +50,10 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"name": "users",
|
||||
"description": "Administration des comptes. Réservé au rôle `admin`.",
|
||||
},
|
||||
{
|
||||
"name": "sites",
|
||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
|
||||
},
|
||||
]
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
@@ -113,6 +117,18 @@ REPONSES_ADMIN: Final[Reponses] = {
|
||||
},
|
||||
}
|
||||
|
||||
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
||||
# insuffisants, seulement pour le mot de passe provisoire.
|
||||
REPONSES_LECTEUR: Final[Reponses] = {
|
||||
**REPONSES_AUTHENTIFIEES,
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Mot de passe provisoire à changer (`detail` vaut `password_change_required`)."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -27,7 +27,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
try:
|
||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||
except SQLAlchemyError, OSError:
|
||||
# `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
|
||||
# `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
|
||||
except (SQLAlchemyError, OSError): # fmt: skip
|
||||
logger.exception("Base de données injoignable")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import LecteurDep, SiteServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.site import SiteResponse
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REPONSES_INTROUVABLE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
404: {"model": ErrorResponse, "description": "Aucun site ne porte cet identifiant."},
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
|
||||
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
|
||||
sites = await service.list_all()
|
||||
return [SiteResponse.model_validate(site) for site in sites]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}",
|
||||
response_model=SiteResponse,
|
||||
summary="Décrit un site",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
|
||||
try:
|
||||
site = await service.get_by_id(site_id)
|
||||
except SiteNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
|
||||
) from erreur
|
||||
return SiteResponse.model_validate(site)
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.v1.endpoints import auth, health, sites, users
|
||||
|
||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
||||
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
|
||||
|
||||
class SiteRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
requete = select(Site).order_by(Site.site_id)
|
||||
return (await self._session.scalars(requete)).all()
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site | None:
|
||||
requete = select(Site).where(Site.site_id == site_id)
|
||||
site: Site | None = await self._session.scalar(requete)
|
||||
return site
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SiteResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
site_id: str
|
||||
site_name: str
|
||||
site_type: str
|
||||
location: str | None
|
||||
capacity_kw: float | None
|
||||
status: str | None
|
||||
@@ -0,0 +1,26 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
|
||||
class SiteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteNotFoundError(SiteError):
|
||||
pass
|
||||
|
||||
|
||||
class SiteService:
|
||||
def __init__(self, *, sites: SiteRepository) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
return await self._sites.list_all()
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site:
|
||||
site = await self._sites.get_by_id(site_id)
|
||||
if site is None:
|
||||
raise SiteNotFoundError(site_id)
|
||||
return site
|
||||
@@ -773,6 +773,153 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"sites"
|
||||
],
|
||||
"summary": "Liste les sites",
|
||||
"operationId": "list_sites_api_v1_sites_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SiteResponse"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Response List Sites Api V1 Sites Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"sites"
|
||||
],
|
||||
"summary": "Décrit un site",
|
||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Aucun site ne porte cet identifiant.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -973,6 +1120,65 @@
|
||||
],
|
||||
"title": "Role"
|
||||
},
|
||||
"SiteResponse": {
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"title": "Site Name"
|
||||
},
|
||||
"site_type": {
|
||||
"type": "string",
|
||||
"title": "Site Type"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Location"
|
||||
},
|
||||
"capacity_kw": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Capacity Kw"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Status"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"site_id",
|
||||
"site_name",
|
||||
"site_type",
|
||||
"location",
|
||||
"capacity_kw",
|
||||
"status"
|
||||
],
|
||||
"title": "SiteResponse"
|
||||
},
|
||||
"TemporaryPasswordResponse": {
|
||||
"properties": {
|
||||
"user": {
|
||||
@@ -1185,6 +1391,10 @@
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Administration des comptes. Réservé au rôle `admin`."
|
||||
},
|
||||
{
|
||||
"name": "sites",
|
||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_current_principal, get_site_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
|
||||
def principal(role: Role = Role.LECTEUR) -> Principal:
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email=f"{role.value}@enervision.fr",
|
||||
role=role,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
def site(site_id: str = "site-1") -> Site:
|
||||
return Site(
|
||||
site_id=site_id,
|
||||
site_name="Site de test",
|
||||
site_type="industriel",
|
||||
location="Toulouse",
|
||||
capacity_kw=42.0,
|
||||
status="actif",
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self, erreur: Exception | None = None) -> None:
|
||||
self._erreur = erreur
|
||||
self.site = site()
|
||||
|
||||
async def list_all(self) -> list[Site]:
|
||||
return [self.site]
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return self.site
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal()
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi(
|
||||
app: FastAPI, lecteur_connecte: None
|
||||
) -> Iterator[Callable[[Exception | None], FauxService]]:
|
||||
def installe(erreur: Exception | None = None) -> FauxService:
|
||||
service = FauxService(erreur)
|
||||
app.dependency_overrides[get_site_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_site_service, None)
|
||||
|
||||
|
||||
async def test_list_sites_returns_the_sites(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sites")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps == [
|
||||
{
|
||||
"site_id": "site-1",
|
||||
"site_name": "Site de test",
|
||||
"site_type": "industriel",
|
||||
"location": "Toulouse",
|
||||
"capacity_kw": 42.0,
|
||||
"status": "actif",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_get_site_returns_the_matching_site(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sites/site-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["site_id"] == "site-1"
|
||||
|
||||
|
||||
async def test_get_site_returns_404_for_an_unknown_site(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi(SiteNotFoundError("site-inconnu"))
|
||||
|
||||
response = await client.get("/api/v1/sites/site-inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_list_sites_reaches_the_repository_through_the_session(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=[site("a"), site("b")])
|
||||
|
||||
response = await client.get("/api/v1/sites")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [s["site_id"] for s in response.json()] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_site_reaches_the_repository_through_the_session(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=site("a"))
|
||||
|
||||
response = await client.get("/api/v1/sites/a")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["site_id"] == "a"
|
||||
|
||||
|
||||
async def test_get_site_returns_404_when_the_session_finds_nothing(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=None)
|
||||
|
||||
response = await client.get("/api/v1/sites/inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import Settings
|
||||
@@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
class FakeScalars:
|
||||
"""Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies."""
|
||||
|
||||
def __init__(self, rows: Sequence[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> Sequence[object]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
|
||||
|
||||
@@ -25,6 +36,9 @@ class FakeSession:
|
||||
async def execute(self, *_: object, **__: object) -> object:
|
||||
return self._repondre()
|
||||
|
||||
async def scalars(self, *_: object, **__: object) -> FakeScalars:
|
||||
return FakeScalars(self._repondre() or [])
|
||||
|
||||
def _repondre(self) -> object:
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def identifiant() -> str:
|
||||
return f"site-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
async def creer(session: AsyncSession, **overrides: object) -> Site:
|
||||
site = Site(
|
||||
site_id=overrides.get("site_id", identifiant()),
|
||||
site_name=overrides.get("site_name", "Site de test"),
|
||||
site_type=overrides.get("site_type", "industriel"),
|
||||
location=overrides.get("location", "Toulouse"),
|
||||
capacity_kw=overrides.get("capacity_kw", 42.0),
|
||||
status=overrides.get("status", "actif"),
|
||||
)
|
||||
session.add(site)
|
||||
await session.flush()
|
||||
return site
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None:
|
||||
depot = SiteRepository(session)
|
||||
cree = await creer(session)
|
||||
|
||||
trouve = await depot.get_by_id(cree.site_id)
|
||||
nom = trouve.site_name if trouve else None
|
||||
await session.rollback()
|
||||
|
||||
assert nom == "Site de test"
|
||||
|
||||
|
||||
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
trouve = await SiteRepository(session).get_by_id(identifiant())
|
||||
|
||||
assert trouve is None
|
||||
|
||||
|
||||
async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None:
|
||||
depot = SiteRepository(session)
|
||||
premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"])
|
||||
await creer(session, site_id=second)
|
||||
await creer(session, site_id=premier)
|
||||
|
||||
sites = await depot.list_all()
|
||||
identifiants = [site.site_id for site in sites if site.site_id in (premier, second)]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [premier, second]
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError, SiteService
|
||||
|
||||
|
||||
def site(site_id: str = "site-1") -> Site:
|
||||
return Site(
|
||||
site_id=site_id,
|
||||
site_name="Site de test",
|
||||
site_type="industriel",
|
||||
location="Toulouse",
|
||||
capacity_kw=42.0,
|
||||
status="actif",
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, sites: list[Site]) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> list[Site]:
|
||||
return self._sites
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site | None:
|
||||
return next((s for s in self._sites if s.site_id == site_id), None)
|
||||
|
||||
|
||||
async def test_list_all_returns_the_repository_sites() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
|
||||
|
||||
sites = await service.list_all()
|
||||
|
||||
assert [s.site_id for s in sites] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a")]))
|
||||
|
||||
trouve = await service.get_by_id("a")
|
||||
|
||||
assert trouve.site_id == "a"
|
||||
|
||||
|
||||
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
|
||||
service = SiteService(sites=FakeRepository([]))
|
||||
|
||||
with pytest.raises(SiteNotFoundError):
|
||||
await service.get_by_id("inconnu")
|
||||
Reference in New Issue
Block a user