From a9a5e2a413d1af9660c93b15112dce7b42f61e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20P=C3=B3voas?= <105643834+francisco-povoas@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:09:06 -0300 Subject: [PATCH 1/2] Challenge solved --- Solution/Dockerfile | 34 +++++ Solution/README.md | 102 ++++++++++++++ Solution/app/__init__.py | 71 ++++++++++ Solution/app/infra/database.py | 27 ++++ Solution/app/infra/time_series.py | 48 +++++++ Solution/app/models/signal_model.py | 53 +++++++ .../app/repositories/signal_repository.py | 63 +++++++++ Solution/app/routes/v1/__init__.py | 9 ++ Solution/app/routes/v1/signal_router.py | 74 ++++++++++ Solution/app/services/signal_service.py | 131 ++++++++++++++++++ Solution/app/sources/sources.py | 12 ++ Solution/app/sources/voltage_sources.py | 18 +++ Solution/docker-compose.yml | 39 ++++++ Solution/pytest.ini | 3 + Solution/requirements.txt | 17 +++ Solution/run.py | 3 + Solution/tests/__init__.py | 0 Solution/tests/conftest.py | 38 +++++ Solution/tests/test_delete_series.py | 65 +++++++++ Solution/tests/test_get_metrics.py | 63 +++++++++ Solution/tests/test_get_series_count.py | 66 +++++++++ Solution/tests/test_get_series_paginated.py | 130 +++++++++++++++++ Solution/tests/test_post_create_series.py | 115 +++++++++++++++ 23 files changed, 1181 insertions(+) create mode 100644 Solution/Dockerfile create mode 100644 Solution/README.md create mode 100644 Solution/app/__init__.py create mode 100644 Solution/app/infra/database.py create mode 100644 Solution/app/infra/time_series.py create mode 100644 Solution/app/models/signal_model.py create mode 100644 Solution/app/repositories/signal_repository.py create mode 100644 Solution/app/routes/v1/__init__.py create mode 100644 Solution/app/routes/v1/signal_router.py create mode 100644 Solution/app/services/signal_service.py create mode 100644 Solution/app/sources/sources.py create mode 100644 Solution/app/sources/voltage_sources.py create mode 100644 Solution/docker-compose.yml create mode 100644 Solution/pytest.ini create mode 100644 Solution/requirements.txt create mode 100644 Solution/run.py create mode 100644 Solution/tests/__init__.py create mode 100644 Solution/tests/conftest.py create mode 100644 Solution/tests/test_delete_series.py create mode 100644 Solution/tests/test_get_metrics.py create mode 100644 Solution/tests/test_get_series_count.py create mode 100644 Solution/tests/test_get_series_paginated.py create mode 100644 Solution/tests/test_post_create_series.py diff --git a/Solution/Dockerfile b/Solution/Dockerfile new file mode 100644 index 0000000000..e251fe97ce --- /dev/null +++ b/Solution/Dockerfile @@ -0,0 +1,34 @@ +# ─── Build stage ────────────────────────────────────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Instala dependências de sistema necessárias para compilar asyncpg +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . + +RUN pip install --upgrade pip \ + && pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ─── Runtime stage ──────────────────────────────────────────────────────────── +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Copia apenas as dependências já instaladas do builder +COPY --from=builder /install /usr/local + +# Copia o código da aplicação +COPY . . + +EXPOSE 8000 + +# Cria usuário não-root para executar a aplicação +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser +USER appuser + +CMD ["uvicorn", "run:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Solution/README.md b/Solution/README.md new file mode 100644 index 0000000000..49dad865d4 --- /dev/null +++ b/Solution/README.md @@ -0,0 +1,102 @@ +# Signal Processing API + +## Pré-requisitos + +- [Docker](https://docs.docker.com/get-docker/) e [Docker Compose](https://docs.docker.com/compose/install/) + +--- + +## Configuração + +Copie o arquivo de exemplo e ajuste as variáveis conforme necessário: + +```bash +cp .env.example .env +``` + +> O `.env` já vem pré-configurado para funcionar com o Docker Compose sem alterações. + +--- + +## Subindo a aplicação + +```bash +docker compose up --build +``` + +A API estará disponível em **http://localhost:8000**. + +> Na primeira execução as tabelas são criadas automaticamente no banco. + +--- + +## Banco de Dados + +**PostgreSQL 16** — gerenciado via SQLAlchemy. + +| Tabela | Descrição | +|--------|-----------| +| `time_series` | Metadados e métricas pré-computadas de cada série (min, max, média, violações) | +| `time_series_points` | Pontos individuais de cada série (`series_id`, `ts`, `value`) | + +--- + +## Endpoints + +| Método | Rota | Descrição | +|--------|------|-----------| +| `POST` | `/api/v1/signal/series` | Cria uma nova série temporal com seus pontos e métricas pré-computadas | +| `GET` | `/api/v1/signal/series/count` | Retorna o total de séries armazenadas | +| `GET` | `/api/v1/signal/series/{series_id}/data` | Retorna os pontos de uma série de forma paginada (`?offset=0`) | +| `GET` | `/api/v1/signal/metrics/{series_id}` | Retorna as métricas de uma série (min, max, média, violações) | +| `DELETE` | `/api/v1/signal/series/{series_id}` | Remove uma série e todos os seus pontos | + + +Documentação interativa (Swagger): **http://localhost:8000/docs** + +--- + +## Rodando os testes + +```bash +# Sem Docker (requer ambiente Python local com dependências instaladas) +# Linux +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pytest -v +``` + +--- + +## Parando a aplicação + +```bash +docker compose down # para e remove os containers +docker compose down -v # também remove o volume do banco +``` + +TODO: + +- Inserir nova rota que retorna os ids das series presentes no banco. +- Talvez trocar timestamp do ponto da serie para os segundos desde 1970, provavelmente terá uma redução percebida na performance de escrita e armazenamento (provavelmente aumentaria o tempo de leitura para organizar o timestamp em data hora)... +- Nao foi inserido alembic para trabalhar com migrations, a criação da tabela é feita pelo lifespan da aplicação, para escalar pode nao ser uma boa caso duas aplicacoes subam ao mesmo tempo tentando criar tabela no banco... +- Criar CI. +- A Aplicação foi montada pensando numa modularização clara router->service->repository router cuida de validacoes de entrada e saida da rota, service cuida da logica da aplicacao e como os dados serão persistidos e repository cuida da persistencia e leitura em banco. Esse modelo facilita os testes automatizados, para nao utilizar o banco em testes automatizados de integracao parcial, consigo mockar as chamadas pro repository, assim como no exemplo do unico arquivo de teste criado Post... +- Subir a cobertura de testes. +- Atualmente a resposta pro client na criação de uma serie está sincrono, devolvemos detalhes da inserção como id registrado em banco após inserção, dependendo como essa aplicação crescer isso não será escalavel, dado que poderá haver outras comunicações durante a inserção, como por exemplo o acionamento para um servico de alarme ao perceber que há registros de pontos da serie violados para o objeto medido... Uma possivel solução seria criar uma fila de mensagens como o rabbitMQ e worker consumindo a fila para o registro em banco. +- Não foram realizados testes de carga na aplicacao, nem teste com aplicação hospedada em nuvem. +- O calculo das metricas pré computadas na criação da série não está redondo para teste, porque ao mesmo tempo que calcula as metricas com base no payload (isso na camada service logic busines) faz chamada de repository para persistir a serie em banco. Ainda é testavel mas precisará de um mock para o repository... Talvez o ideal fosse tornar uma funcao o calculo de metricas... +- Insercao de log na aplicacao. +- Criar os testes parciais com subida de banco. Nesse teste será preciso criar uma fixture que permita a utilizacao do lifespan pra criacao das tabelas. Depois alterado pra alembic. + +resultados POST POSTMAN maquina local + +pontos tempo total ms +1500 193.74 + + +GET paginado + +pontos totais offset 150 pontos paginados +1500 10 ms diff --git a/Solution/app/__init__.py b/Solution/app/__init__.py new file mode 100644 index 0000000000..727ac9ccac --- /dev/null +++ b/Solution/app/__init__.py @@ -0,0 +1,71 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.infra.database import Base, engine +from app.routes.v1 import v1_router + +# Garante que os modelos SQLAlchemy sejam registrados no Base.metadata +import app.infra.time_series # noqa: F401 + +logger = logging.getLogger(__name__) + + +def create_app(testing: bool = False) -> FastAPI: + + @asynccontextmanager + async def lifespan(app: FastAPI): + """Cria as tabelas no banco na inicialização, se ainda não existirem. + + Usa CREATE TABLE IF NOT EXISTS do PostgreSQL — operação atômica e segura + Isso aqui nao escala muito bem aparentemente (duas instancias podem subir ao mesmo tempo e tentar criar a tabela), mas é suficiente para o que foi implementado do teste. Em produção, eu usaria uma ferramenta de migração como Alembic. + """ + # Modo de teste: nao cria tabelas, pois o repositório é 100% mockado e nao precisa de um banco real. + # Para um teste de integracao com banco real, bastaria criar um conftest que cria o app com testing=False e um banco de teste configurado, e nao mockar o repositorio. Assim, as tabelas seriam criadas normalmente e o teste usaria um banco real, mas isolado do ambiente de desenvolvimento/produção. + if not testing: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + if not testing: + await engine.dispose() + + app = FastAPI( + title="Signal Processing API", + version="1.0.0", + lifespan=lifespan, + ) + app.include_router(v1_router, prefix="/api/v1") + + # Assegura que o FastAPI nao vaze campo padrao que mostre os dados de entrada do payload, que pode conter dados sensiveis. + # O RequestValidationError é o erro lançado pelo FastAPI quando o payload nao bate com o modelo Pydantic esperado. + # Alem de seguranca, isso torna a resposta mais leve, dado que o payload pode ser grande (pontos da serie). + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content={ + "detail": [ + { + "type": error["type"], + "loc": error["loc"], + "msg": error["msg"], + } + for error in exc.errors() + ] + }, + ) + + # Assegura que erros nao tratados sejam convertidos em respostas JSON genéricas, sem vazar detalhes do erro ou dados de entrada. + @app.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Erro não tratado: %s", exc) + return JSONResponse( + status_code=500, + content={"detail": "Erro interno do servidor."}, + ) + + return app \ No newline at end of file diff --git a/Solution/app/infra/database.py b/Solution/app/infra/database.py new file mode 100644 index 0000000000..70bc4d3410 --- /dev/null +++ b/Solution/app/infra/database.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncGenerator +import os + +from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_async_engine(DATABASE_URL, echo=True) + +AsyncSessionLocal = async_sessionmaker( + engine, + expire_on_commit=False +) + +Base = declarative_base() + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Dependency to get database session""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() \ No newline at end of file diff --git a/Solution/app/infra/time_series.py b/Solution/app/infra/time_series.py new file mode 100644 index 0000000000..98d63be795 --- /dev/null +++ b/Solution/app/infra/time_series.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, DateTime, Float, Integer, BigInteger, ForeignKey, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship, Mapped, mapped_column +from app.infra.database import Base + +class TimeSeries(Base): + __tablename__ = "time_series" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String, nullable=False) + source: Mapped[str | None] = mapped_column(String, nullable=True) + unit: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + start_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + points_count: Mapped[int] = mapped_column(Integer, nullable=False) + min_value: Mapped[float] = mapped_column(Float, nullable=False) + max_value: Mapped[float] = mapped_column(Float, nullable=False) + average: Mapped[float] = mapped_column(Float, nullable=False) + min_value_aceptable_violated_count: Mapped[int] = mapped_column(Integer, nullable=False) + max_value_aceptable_violated_count: Mapped[int] = mapped_column(Integer, nullable=False) + + points = relationship( + "TimeSeriesPoint", + back_populates="series", + cascade="all, delete-orphan", + passive_deletes=True, + ) + +class TimeSeriesPoint(Base): + __tablename__ = "time_series_points" + __table_args__ = ( + UniqueConstraint("series_id", "ts", name="uq_series_ts"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + series_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("time_series.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + value: Mapped[float] = mapped_column(Float, nullable=False) + + series = relationship("TimeSeries", back_populates="points") diff --git a/Solution/app/models/signal_model.py b/Solution/app/models/signal_model.py new file mode 100644 index 0000000000..1375758756 --- /dev/null +++ b/Solution/app/models/signal_model.py @@ -0,0 +1,53 @@ +from datetime import datetime +from uuid import UUID +from pydantic import BaseModel, ConfigDict, Field +from typing import Optional, List + +class PointIn(BaseModel): + model_config = ConfigDict(from_attributes=True) + + ts: datetime + value: float + +class CreateSeriesRequest(BaseModel): + """Schema to create a new series.""" + name: str = Field(..., min_length=1, max_length=255) + source: str = Field(..., max_length=255) + unit: Optional[str] = Field(default=None, max_length=50) + points: List[PointIn] = Field(..., min_length=1) + +class SeriesResponse(BaseModel): + """Schema for the response of a series created.""" + id: UUID + name: str + source: str + unit: Optional[str] + points_count: int + start_ts: datetime + end_ts: datetime + created_at: datetime + min_value: float + max_value: float + average: float + min_value_aceptable_violated_count: int + max_value_aceptable_violated_count: int + +class NumberOfSeriesResponse(BaseModel): + """Schema for the response of the number of series stored in the server.""" + count: int + +class FullSeriesResponse(SeriesResponse): + """Schema for the response of a full series, with all the points.""" + id: UUID + points: List[PointIn] + +class PaginatedSeriesResponse(BaseModel): + """Schema for the paginated response of a time series, with a subset of its points. + + Pass `next_offset` as the `offset` query param in the next request. + When `has_more` is False, `next_offset` is None and pagination is complete. + """ + series_id: UUID + data: List[PointIn] + has_more: bool + next_offset: Optional[int] \ No newline at end of file diff --git a/Solution/app/repositories/signal_repository.py b/Solution/app/repositories/signal_repository.py new file mode 100644 index 0000000000..811a29ce78 --- /dev/null +++ b/Solution/app/repositories/signal_repository.py @@ -0,0 +1,63 @@ +import uuid + +from sqlalchemy import select, func, delete, insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infra.time_series import TimeSeries, TimeSeriesPoint + +SERIES_PAGE_SIZE = 150 + +class SeriesRepository: + """Repository for managing series data in the database.""" + + @staticmethod + async def create_series( + db: AsyncSession, + series: TimeSeries, + points: list[dict]) -> TimeSeries: + """Create a new series in the database.""" + + db.add(series) + await db.flush() # write series row first to satisfy FK constraint + await db.execute( + insert(TimeSeriesPoint), + points + ) + await db.commit() + await db.refresh(series) + return series + + @staticmethod + async def get_series_by_id(db: AsyncSession, series_id: uuid.UUID) -> TimeSeries: + """Get a series by its id.""" + return await db.get(TimeSeries, series_id) + + @staticmethod + async def get_series_count(db: AsyncSession) -> int: + """Get the number of series stored in the server.""" + return await db.scalar(select(func.count()).select_from(TimeSeries)) or 0 + + @staticmethod + async def get_full_series_paginated( + db: AsyncSession, + series_id: uuid.UUID, + offset: int = 0 + ) -> list[TimeSeriesPoint]: + """Get a paginated list of points for a series. + Fetches SERIES_PAGE_SIZE + 1 rows so the caller can detect + whether there is a next page without an extra COUNT query. + """ + result = await db.execute( + select(TimeSeriesPoint) + .where(TimeSeriesPoint.series_id == series_id) + .order_by(TimeSeriesPoint.ts) + .offset(offset) + .limit(SERIES_PAGE_SIZE + 1) + ) + return list(result.scalars().all()) + + @staticmethod + async def delete_series(db: AsyncSession, series_id: uuid.UUID) -> None: + """Delete a series and its points (cascaded by the DB) by id.""" + await db.execute(delete(TimeSeries).where(TimeSeries.id == series_id)) + await db.commit() \ No newline at end of file diff --git a/Solution/app/routes/v1/__init__.py b/Solution/app/routes/v1/__init__.py new file mode 100644 index 0000000000..76d8950bbb --- /dev/null +++ b/Solution/app/routes/v1/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from .signal_router import router as signal_router + +v1_router = APIRouter() + +# Equivalente a v1_bp.register_blueprint(auth_bp, url_prefix='/auth') +v1_router.include_router(signal_router, prefix="/signal", tags=["signal"]) +# v1_router.include_router(predictions_router, prefix="/predictions", tags=["predictions"]) \ No newline at end of file diff --git a/Solution/app/routes/v1/signal_router.py b/Solution/app/routes/v1/signal_router.py new file mode 100644 index 0000000000..4410378983 --- /dev/null +++ b/Solution/app/routes/v1/signal_router.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from uuid import UUID +from app.infra.database import get_db + +from app.models.signal_model import NumberOfSeriesResponse, SeriesResponse, PaginatedSeriesResponse +from app.models.signal_model import CreateSeriesRequest + +from app.services.signal_service import SeriesService + +router = APIRouter() + +@router.post("/series", response_model=SeriesResponse, status_code=201) +async def create_series( + payload: CreateSeriesRequest, + db: AsyncSession = Depends(get_db) +) -> SeriesResponse: + + series = await SeriesService.create_series(db, payload) + + return SeriesResponse.model_validate(series, from_attributes=True) + +# Rota estática antes de qualquer rota com parâmetro dinâmico no mesmo prefixo +# As a user, I want to be able to retrieve the number of time series i've stored in the server. +@router.get("/series/count", status_code=200) +async def get_series_count( + db: AsyncSession = Depends(get_db) +) -> NumberOfSeriesResponse: + + count = await SeriesService.get_series_count(db) + + return NumberOfSeriesResponse(count=count) + +# As a user, I want to be able to retrieve a paginated time series i've stored. +@router.get("/series/{series_id}/data", response_model=PaginatedSeriesResponse, status_code=200) +async def get_series_paginated( + series_id: UUID, + offset: int = 0, + db: AsyncSession = Depends(get_db) +) -> PaginatedSeriesResponse: + + data, has_more, next_offset = await SeriesService.get_full_series_paginated(db, series_id, offset) + + if not data and offset == 0: + raise HTTPException(status_code=404, detail=f"Series {series_id} not found or has no points") + + return PaginatedSeriesResponse( + series_id=series_id, + data=data, + has_more=has_more, + next_offset=next_offset + ) + +@router.get("/metrics/{series_id}", response_model=SeriesResponse, status_code=200) +async def get_metrics( + series_id: UUID, + db: AsyncSession = Depends(get_db) +) -> SeriesResponse: + + metrics = await SeriesService.get_metrics(db, series_id) + if not metrics: + raise HTTPException(status_code=404, detail=f"Series {series_id} not found") + + return SeriesResponse.model_validate(metrics, from_attributes=True) + +@router.delete("/series/{series_id}", status_code=200) +async def delete_series( + series_id: UUID, + db: AsyncSession = Depends(get_db) +) -> dict: + + await SeriesService.delete_series(db, series_id) + + return {"mensagem": f"{series_id} deletado com sucesso"} \ No newline at end of file diff --git a/Solution/app/services/signal_service.py b/Solution/app/services/signal_service.py new file mode 100644 index 0000000000..9a5b65a8ac --- /dev/null +++ b/Solution/app/services/signal_service.py @@ -0,0 +1,131 @@ +"""Signal service module for handling signal processing logic.""" +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.signal_model import CreateSeriesRequest, FullSeriesResponse, SeriesResponse +from app.repositories.signal_repository import SeriesRepository, SERIES_PAGE_SIZE +from app.sources.sources import SourcesFactory +from app.infra.time_series import TimeSeries + +class SeriesService: + """Service class for signal processing.""" + + # O(n) because i need to iterate over all the points to calculate the metrics, but i can do it in one pass, so it's O(n) + @staticmethod + async def create_series( + db: AsyncSession, + payload: CreateSeriesRequest + ) -> TimeSeries: + """Create a new series with the given data.""" + + points = [] + min_value = payload.points[0].value + max_value = payload.points[0].value + total = 0 + average = 0 + id=uuid.uuid4() + name=payload.name + source=payload.source + unit=payload.unit + start_ts=payload.points[0].ts + end_ts=payload.points[-1].ts + points_count=len(payload.points) # len is O(1) i don't need to worry + + # get the aceptable values for the source of the series, if there is no source, i will consider that there is no aceptable values, so all the points are aceptable + _source = SourcesFactory.get_source(source) + min_value_aceptable = (_source.minValueAcceptable if _source else None) or float('-inf') + max_value_aceptable = (_source.maxValueAcceptable if _source else None) or float('inf') + + min_value_aceptable_violated_count = 0 + max_value_aceptable_violated_count = 0 + + # IMPORTANT NOTE: + # Here i pre-compute the metrics in order to avoid doing it later when the user request the data. + # In a real implementation, i would not pre compute here, i would save the raw series in the broker, + # and the worker would consume the serie, pre compute and save on the database. + + # i need to calculate metrics... + + for point in payload.points: + # creates the points to be saved in the database + points.append({ + "series_id": id, + "ts": point.ts, + "value": point.value, + }) + # calculate some metrics here (max and min) + if point.value < min_value: + min_value = point.value + if point.value > max_value: + max_value = point.value + total += point.value + + # check points that are not acceptable + if point.value < min_value_aceptable: + min_value_aceptable_violated_count += 1 + if point.value > max_value_aceptable: + max_value_aceptable_violated_count += 1 + + # if there is any violation of the acceptable values, we need to send a request to the alerting system + # Send request http to microsservice (alerting system), but for now, just print a message, + # Considering that this is a worker, and in this case not working sincronously like in this API EXAMPLE... + # For better performance i would send all the points that are not acceptable in the batch, only 1 request... + # for convinience i will not print failled points during the API EXAMPLE... + + average = total / len(payload.points) + + # fill the series with params and the metrics calculated + series = TimeSeries( + id=id, + name=name, + source=source, + unit=unit, + start_ts=start_ts, + end_ts=end_ts, + points_count=points_count, + min_value=min_value, + max_value=max_value, + average=average, + min_value_aceptable_violated_count=min_value_aceptable_violated_count, + max_value_aceptable_violated_count=max_value_aceptable_violated_count + ) + + # Populates database via repository with data and returns the created series with metrics + return await SeriesRepository.create_series(db, series, points) + + @staticmethod + async def get_metrics(db: AsyncSession, series_id: uuid.UUID) -> TimeSeries | None: + """Get the metrics of a series by its id. Returns None if not found.""" + return await SeriesRepository.get_series_by_id(db, series_id) + + @staticmethod + async def get_series_count(db: AsyncSession) -> int: + """Get the number of series stored in the server.""" + return await SeriesRepository.get_series_count(db) + + @staticmethod + async def get_full_series_paginated( + db: AsyncSession, + series_id: uuid.UUID, + offset: int = 0 + ) -> tuple: + """Get a paginated chunk of points for a series. + + Returns a tuple (data, has_more, next_offset) where: + - data: list of TimeSeriesPoint for the current page + - has_more: True if there are more points beyond this page + - next_offset: the offset to use in the next request, or None + """ + rows = await SeriesRepository.get_full_series_paginated(db, series_id, offset) + + has_more = len(rows) > SERIES_PAGE_SIZE + data = rows[:SERIES_PAGE_SIZE] + next_offset = offset + SERIES_PAGE_SIZE if has_more else None + + return data, has_more, next_offset + + @staticmethod + async def delete_series(db: AsyncSession, series_id: uuid.UUID) -> None: + """Delete a series by its id.""" + await SeriesRepository.delete_series(db, series_id) \ No newline at end of file diff --git a/Solution/app/sources/sources.py b/Solution/app/sources/sources.py new file mode 100644 index 0000000000..8d83abea9b --- /dev/null +++ b/Solution/app/sources/sources.py @@ -0,0 +1,12 @@ +from app.sources.voltage_sources import voltageMeterChinese_001 + +# Here you should register your meter... + +class SourcesFactory: + """Factory class to create sources.""" + @staticmethod + def get_source(source_name: str) -> object: + """Get a source by its name.""" + return { + 'voltageMeterChinese_001': voltageMeterChinese_001 + }.get(source_name, None) diff --git a/Solution/app/sources/voltage_sources.py b/Solution/app/sources/voltage_sources.py new file mode 100644 index 0000000000..9fc5377d1c --- /dev/null +++ b/Solution/app/sources/voltage_sources.py @@ -0,0 +1,18 @@ +# This file contains the sources of the signal, voltage meter. + +# During the boot of API we could load all the sources in memory, and then we can access them when we need. +# We could expose the configuration of the sources through an endpoint, but for now we will just use it internally to validate the points that are being sent by the user. +class voltageMeterChinese_001: + """Configuration for source VoltageMeterChinese001.""" + def __init__(self): + self.name = "VoltageMeterChinese_001" + self._minValueAcceptable = 210 + self._maxValueAcceptable = 230 + + @property + def minValueAcceptable(self): + return self._minValueAcceptable + + @property + def maxValueAcceptable(self): + return self._maxValueAcceptable \ No newline at end of file diff --git a/Solution/docker-compose.yml b/Solution/docker-compose.yml new file mode 100644 index 0000000000..7ba6d79346 --- /dev/null +++ b/Solution/docker-compose.yml @@ -0,0 +1,39 @@ +services: + + # ─── PostgreSQL ───────────────────────────────────────────────────────────── + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "5436:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # ─── FastAPI ──────────────────────────────────────────────────────────────── + api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + volumes: + - .:/app # monta o código local,, alterações refletem sem rebuild + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/Solution/pytest.ini b/Solution/pytest.ini new file mode 100644 index 0000000000..4584de7e86 --- /dev/null +++ b/Solution/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/Solution/requirements.txt b/Solution/requirements.txt new file mode 100644 index 0000000000..c5c3a3cf0d --- /dev/null +++ b/Solution/requirements.txt @@ -0,0 +1,17 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +asyncpg==0.30.0 +exceptiongroup==1.3.1 +fastapi==0.135.1 +greenlet==3.3.2 +httpx==0.28.1 +idna==3.11 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dotenv==1.1.0 +SQLAlchemy==2.0.48 +starlette==0.52.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn[standard]==0.34.0 diff --git a/Solution/run.py b/Solution/run.py new file mode 100644 index 0000000000..7368d5adf0 --- /dev/null +++ b/Solution/run.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app() \ No newline at end of file diff --git a/Solution/tests/__init__.py b/Solution/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Solution/tests/conftest.py b/Solution/tests/conftest.py new file mode 100644 index 0000000000..a6e0439702 --- /dev/null +++ b/Solution/tests/conftest.py @@ -0,0 +1,38 @@ +import os +import sys +from unittest.mock import MagicMock + +# Ambas as linhas devem vir ANTES de qualquer import do pacote `app`, +# pois database.py inicializa o engine em nível de módulo. +# +# - DATABASE_URL: evita o ArgumentError por URL nula. +# - asyncpg mockado: evita ModuleNotFoundError do driver PostgreSQL, +# que não precisa estar instalado pois get_db é 100% sobrescrito em testes. +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/testdb") +sys.modules.setdefault("asyncpg", MagicMock()) + +import pytest +from fastapi.testclient import TestClient + +from app import create_app +from app.infra.database import get_db + +@pytest.fixture +def client(): + """TestClient com a dependência get_db sobrescrita por um mock. + + Nenhuma conexão real ao banco é feita durante os testes. + O mock da sessão é injetado; as chamadas ao repositório são mockadas + individualmente em cada teste. + """ + app = create_app(testing=True) + + async def override_get_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as c: + yield c + + app.dependency_overrides.clear() diff --git a/Solution/tests/test_delete_series.py b/Solution/tests/test_delete_series.py new file mode 100644 index 0000000000..1a10874124 --- /dev/null +++ b/Solution/tests/test_delete_series.py @@ -0,0 +1,65 @@ +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch + +from app.infra.time_series import TimeSeries + +# ── helpers ────────────────────────────────────────────────────────────────── + +BASE_TS = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +SERIES_URL = "/api/v1/signal/series" + +def make_payload(n: int = 100) -> dict: + return { + "name": "Delete Series", + "source": "generic_source", + "unit": "V", + "points": [ + {"ts": (BASE_TS + timedelta(seconds=i)).isoformat(), "value": float(i + 1)} + for i in range(n) + ], + } + +def make_fake_series(series_id: uuid.UUID, points_count: int = 100) -> TimeSeries: + return TimeSeries( + id=series_id, + name="Delete Series", + source="generic_source", + unit="V", + created_at=datetime.now(timezone.utc), + start_ts=BASE_TS, + end_ts=BASE_TS + timedelta(seconds=points_count - 1), + points_count=points_count, + min_value=1.0, + max_value=float(points_count), + average=round((1.0 + float(points_count)) / 2, 1), + min_value_aceptable_violated_count=0, + max_value_aceptable_violated_count=0, + ) + +# ── testes ──────────────────────────────────────────────────────────────────── + +class TestDeleteSeries: + @patch("app.services.signal_service.SeriesRepository") + def test_post_then_delete(self, mock_repo, client): + """ + Cenário: O usuário cria uma série e depois a deleta com sucesso. + """ + series_id = uuid.uuid4() + fake_series = make_fake_series(series_id) + + # ── 1. POST: cria a série ───────────────────────────────────────────── + mock_repo.create_series = AsyncMock(return_value=fake_series) + + post_response = client.post(SERIES_URL, json=make_payload()) + + assert post_response.status_code == 201 + assert post_response.json()["id"] == str(series_id) + + # ── 2. DELETE: remove a série pelo id ───────────────────────────────── + mock_repo.delete_series = AsyncMock(return_value=None) + + delete_response = client.delete(f"{SERIES_URL}/{series_id}") + + assert delete_response.status_code == 200 + assert delete_response.json() == {"mensagem": f"{series_id} deletado com sucesso"} diff --git a/Solution/tests/test_get_metrics.py b/Solution/tests/test_get_metrics.py new file mode 100644 index 0000000000..295da17710 --- /dev/null +++ b/Solution/tests/test_get_metrics.py @@ -0,0 +1,63 @@ +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch + +from app.infra.time_series import TimeSeries + +# ── helpers ────────────────────────────────────────────────────────────────── + +BASE_TS = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +METRICS_SERIES_URL = "/api/v1/signal/metrics" + +# build a fake TimeSeries that will be returned by the mocked repository +def make_fake_series(points_count: int = 100, source: str = "generic_source") -> TimeSeries: + return TimeSeries( + id=uuid.uuid4(), + name="Test Series", + source=source, + unit="V", + created_at=datetime.now(timezone.utc), + start_ts=BASE_TS, + end_ts=BASE_TS + timedelta(seconds=points_count - 1), + points_count=points_count, + min_value=1.0, + max_value=float(points_count), + average=round((1.0 + float(points_count)) / 2, 1), + min_value_aceptable_violated_count=0, + max_value_aceptable_violated_count=0, + ) + +# ── testes ──────────────────────────────────────────────────────────────────── + +class TestGetMetrics: + @patch("app.services.signal_service.SeriesRepository") + def test_get_metrics_found(self, mock_repo, client): + """ + Cenário: O usuário faz uma requisição GET para obter as métricas de uma série existente. + """ + + # Configura mocks do Repository + mock_repo.get_series_by_id = AsyncMock(spec=TimeSeries) + + fake_series = make_fake_series(230, "voltageMeterChinese_001") + + mock_repo.get_series_by_id.return_value = fake_series + + response = client.get(f"{METRICS_SERIES_URL}/{fake_series.id}") + + assert response.status_code == 200 + assert response.json() == { + "id": str(fake_series.id), + "name": fake_series.name, + "source": fake_series.source, + "unit": fake_series.unit, + "points_count": fake_series.points_count, + "start_ts": fake_series.start_ts.isoformat().replace("+00:00", "Z"), + "end_ts": fake_series.end_ts.isoformat().replace("+00:00", "Z"), + "created_at": fake_series.created_at.isoformat().replace("+00:00", "Z"), + "min_value": fake_series.min_value, + "max_value": fake_series.max_value, + "average": fake_series.average, + "min_value_aceptable_violated_count": fake_series.min_value_aceptable_violated_count, + "max_value_aceptable_violated_count": fake_series.max_value_aceptable_violated_count, + } diff --git a/Solution/tests/test_get_series_count.py b/Solution/tests/test_get_series_count.py new file mode 100644 index 0000000000..3ed2269d3f --- /dev/null +++ b/Solution/tests/test_get_series_count.py @@ -0,0 +1,66 @@ +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch + +from app.infra.time_series import TimeSeries + +# ── helpers ────────────────────────────────────────────────────────────────── + +BASE_TS = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +SERIES_URL = "/api/v1/signal/series" +COUNT_URL = "/api/v1/signal/series/count" + +def make_payload(series_id: uuid.UUID, n: int = 100) -> dict: + return { + "name": "Count Series", + "source": "generic_source", + "unit": "V", + "points": [ + {"ts": (BASE_TS + timedelta(seconds=i)).isoformat(), "value": float(i + 1)} + for i in range(n) + ], + } + +def make_fake_series(series_id: uuid.UUID, points_count: int = 100) -> TimeSeries: + return TimeSeries( + id=series_id, + name="Count Series", + source="generic_source", + unit="V", + created_at=datetime.now(timezone.utc), + start_ts=BASE_TS, + end_ts=BASE_TS + timedelta(seconds=points_count - 1), + points_count=points_count, + min_value=1.0, + max_value=float(points_count), + average=round((1.0 + float(points_count)) / 2, 1), + min_value_aceptable_violated_count=0, + max_value_aceptable_violated_count=0, + ) + +# ── testes ──────────────────────────────────────────────────────────────────── + +class TestGetSeriesCount: + @patch("app.services.signal_service.SeriesRepository") + def test_post_then_get_count(self, mock_repo, client): + """ + Cenário: O usuário cria uma série e consulta o total de séries armazenadas. + """ + series_id = uuid.uuid4() + fake_series = make_fake_series(series_id, points_count=75) + + # ── 1. POST: cria a série ───────────────────────────────────────────── + mock_repo.create_series = AsyncMock(return_value=fake_series) + + post_response = client.post(SERIES_URL, json=make_payload(series_id, n=75)) + + assert post_response.status_code == 201 + assert post_response.json()["id"] == str(series_id) + + # ── 2. GET /count: deve retornar 1 série armazenada ─────────────────── + mock_repo.get_series_count = AsyncMock(return_value=1) + + count_response = client.get(COUNT_URL) + + assert count_response.status_code == 200 + assert count_response.json() == {"count": 1} diff --git a/Solution/tests/test_get_series_paginated.py b/Solution/tests/test_get_series_paginated.py new file mode 100644 index 0000000000..2b112c2ef2 --- /dev/null +++ b/Solution/tests/test_get_series_paginated.py @@ -0,0 +1,130 @@ +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch + +from app.infra.time_series import TimeSeries, TimeSeriesPoint +from app.repositories.signal_repository import SERIES_PAGE_SIZE + +# ── helpers ────────────────────────────────────────────────────────────────── + +BASE_TS = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +SERIES_URL = "/api/v1/signal/series" +TOTAL_POINTS = 452 + +def make_payload() -> dict: + return { + "name": "Paginated Series", + "source": "generic_source", + "unit": "V", + "points": [ + {"ts": (BASE_TS + timedelta(seconds=i)).isoformat(), "value": float(i + 1)} + for i in range(TOTAL_POINTS) + ], + } + +def make_fake_series(series_id: uuid.UUID) -> TimeSeries: + return TimeSeries( + id=series_id, + name="Paginated Series", + source="generic_source", + unit="V", + created_at=datetime.now(timezone.utc), + start_ts=BASE_TS, + end_ts=BASE_TS + timedelta(seconds=TOTAL_POINTS - 1), + points_count=TOTAL_POINTS, + min_value=1.0, + max_value=float(TOTAL_POINTS), + average=round((1.0 + float(TOTAL_POINTS)) / 2, 1), + min_value_aceptable_violated_count=0, + max_value_aceptable_violated_count=0, + ) + +def make_points(series_id: uuid.UUID, start: int, count: int) -> list[TimeSeriesPoint]: + """Cria `count` TimeSeriesPoints a partir do índice `start`.""" + return [ + TimeSeriesPoint( + series_id=series_id, + ts=BASE_TS + timedelta(seconds=start + i), + value=float(start + i + 1), + ) + for i in range(count) + ] + +# ── testes ──────────────────────────────────────────────────────────────────── + +class TestGetSeriesPaginated: + @patch("app.services.signal_service.SeriesRepository") + def test_paginate_all_points(self, mock_repo, client): + """ + Cenário: O usuário cria uma série com 452 pontos e percorre todas as + páginas até esgotar os pontos. + + Com PAGE_SIZE=150 e 452 pontos, esperam-se 4 páginas: + página 1 → 150 pontos, has_more=True, next_offset=150 + página 2 → 150 pontos, has_more=True, next_offset=300 + página 3 → 150 pontos, has_more=True, next_offset=450 + página 4 → 2 pontos, has_more=False, next_offset=None + """ + series_id = uuid.uuid4() + fake_series = make_fake_series(series_id) + + # ── 1. POST: cria a série ───────────────────────────────────────────── + mock_repo.create_series = AsyncMock(return_value=fake_series) + + post_response = client.post(SERIES_URL, json=make_payload()) + + assert post_response.status_code == 201 + assert post_response.json()["points_count"] == TOTAL_POINTS + + # ── 2-5. GET paginado: o repositório devolve PAGE_SIZE+1 rows por + # chamada para que o serviço consiga detetar has_more. + # + # offset= 0 → devolve 151 rows → has_more=True, próxima página em 150 + # offset=150 → devolve 151 rows → has_more=True, próxima página em 300 + # offset=300 → devolve 151 rows → has_more=True, próxima página em 450 + # offset=450 → devolve 2 rows → has_more=False, fim + # mockado para 4 chamadas + mock_repo.get_full_series_paginated = AsyncMock(side_effect=[ + make_points(series_id, start=0, count=SERIES_PAGE_SIZE + 1), # 151 + make_points(series_id, start=150, count=SERIES_PAGE_SIZE + 1), # 151 + make_points(series_id, start=300, count=SERIES_PAGE_SIZE + 1), # 151 + make_points(series_id, start=450, count=2), # 2 + ]) + + paginated_url = f"{SERIES_URL}/{series_id}/data" + total_received = 0 + + # ── página 1 (sem offset) ───────────────────────────────────────────── + r1 = client.get(paginated_url) + assert r1.status_code == 200 + assert len(r1.json()["data"]) == SERIES_PAGE_SIZE + assert r1.json()["has_more"] is True + assert r1.json()["next_offset"] == 150 + total_received += len(r1.json()["data"]) + + # ── página 2 ────────────────────────────────────────────────────────── + r2 = client.get(paginated_url, params={"offset": r1.json()["next_offset"]}) + assert r2.status_code == 200 + assert len(r2.json()["data"]) == SERIES_PAGE_SIZE + assert r2.json()["has_more"] is True + assert r2.json()["next_offset"] == 300 + total_received += len(r2.json()["data"]) + + # ── página 3 ────────────────────────────────────────────────────────── + r3 = client.get(paginated_url, params={"offset": r2.json()["next_offset"]}) + assert r3.status_code == 200 + assert len(r3.json()["data"]) == SERIES_PAGE_SIZE + assert r3.json()["has_more"] is True + assert r3.json()["next_offset"] == 450 + total_received += len(r3.json()["data"]) + + # ── página 4 (última) ───────────────────────────────────────────────── + r4 = client.get(paginated_url, params={"offset": r3.json()["next_offset"]}) + assert r4.status_code == 200 + assert len(r4.json()["data"]) == TOTAL_POINTS - (SERIES_PAGE_SIZE * 3) # 2 + assert r4.json()["has_more"] is False + assert r4.json()["next_offset"] is None + total_received += len(r4.json()["data"]) + + # ── total acumulado deve ser igual ao total enviado no POST ─────────── + assert total_received == TOTAL_POINTS diff --git a/Solution/tests/test_post_create_series.py b/Solution/tests/test_post_create_series.py new file mode 100644 index 0000000000..8a7ca4b71d --- /dev/null +++ b/Solution/tests/test_post_create_series.py @@ -0,0 +1,115 @@ +import uuid +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch + +import pytest + +from app.infra.time_series import TimeSeries + +# ── helpers ────────────────────────────────────────────────────────────────── + +BASE_TS = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +SERIES_URL = "/api/v1/signal/series" + +def make_payload(n: int = 100, source: str = "generic_source") -> dict: + return { + "name": "Test Series", + "source": source, + "unit": "V", + "points": [ + { + "ts": (BASE_TS + timedelta(seconds=i)).isoformat(), + "value": float(i + 1), + } + for i in range(n) + ], + } + +def make_fake_series(points_count: int = 100, source: str = "generic_source") -> TimeSeries: + return TimeSeries( + id=uuid.uuid4(), + name="Test Series", + source=source, + unit="V", + created_at=datetime.now(timezone.utc), + start_ts=BASE_TS, + end_ts=BASE_TS + timedelta(seconds=points_count - 1), + points_count=points_count, + min_value=1.0, + max_value=float(points_count), + average=round((1.0 + float(points_count)) / 2, 1), + min_value_aceptable_violated_count=0, + max_value_aceptable_violated_count=0, + ) + +# ── testes ──────────────────────────────────────────────────────────────────── + +class TestPostSeries: + @patch("app.services.signal_service.SeriesRepository") + def test_post_series(self, mock_repo, client): + """ + Cenário: O usuário faz uma requisição POST para criar uma nova série de sinais. + """ + fake = make_fake_series(100, "generic_source") + mock_repo.create_series = AsyncMock(return_value=fake) + + response = client.post(SERIES_URL, json=make_payload(100)) + + assert response.status_code == 201 + assert response.json() == { + "id": str(fake.id), + "name": fake.name, + "source": fake.source, + "unit": fake.unit, + "points_count": fake.points_count, + "start_ts": fake.start_ts.isoformat().replace("+00:00", "Z"), + "end_ts": fake.end_ts.isoformat().replace("+00:00", "Z"), + "created_at": fake.created_at.isoformat().replace("+00:00", "Z"), + "min_value": fake.min_value, + "max_value": fake.max_value, + "average": fake.average, + "min_value_aceptable_violated_count": fake.min_value_aceptable_violated_count, + "max_value_aceptable_violated_count": fake.max_value_aceptable_violated_count, + } + + @pytest.mark.parametrize("points_count", [1, 50, 100, 200]) + @patch("app.services.signal_service.SeriesRepository") + def test_post_series_points_count(self, mock_repo, client, points_count): + """ + Cenário: O número de pontos retornado na resposta reflete o payload enviado. + """ + fake = make_fake_series(points_count) + mock_repo.create_series = AsyncMock(return_value=fake) + + response = client.post(SERIES_URL, json=make_payload(points_count)) + + assert response.status_code == 201 + assert response.json()["points_count"] == fake.points_count + assert response.json()["name"] == fake.name + assert response.json()["source"] == fake.source + assert response.json()["unit"] == fake.unit + assert response.json()["start_ts"] == fake.start_ts.isoformat().replace("+00:00", "Z") + assert response.json()["end_ts"] == fake.end_ts.isoformat().replace("+00:00", "Z") + assert response.json()["created_at"] == fake.created_at.isoformat().replace("+00:00", "Z") + assert response.json()["min_value"] == fake.min_value + assert response.json()["max_value"] == fake.max_value + assert response.json()["average"] == fake.average + assert response.json()["min_value_aceptable_violated_count"] == fake.min_value_aceptable_violated_count + assert response.json()["max_value_aceptable_violated_count"] == fake.max_value_aceptable_violated_count + + def test_post_series_invalid_payload(self, client): + """ + Cenário: O usuário faz uma requisição POST com um payload inválido. + """ + + # fakear payload e arrancar um campo obrigatório pro Pydantic atuar. + invalid_payload = make_payload() + invalid_payload.pop("name") + + response = client.post(SERIES_URL, json=invalid_payload) + assert response.status_code == 422 + assert 'detail' in response.json() + assert isinstance(response.json()['detail'], list) + assert 'msg' in response.json()['detail'][0] + assert response.json()['detail'][0]['msg'] == 'Field required' + assert response.json()['detail'][0]['loc'] == ['body', 'name'] From 64b0eb8500e0be3c0c756f95755ec3f748655f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20P=C3=B3voas?= <105643834+francisco-povoas@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:16:58 -0300 Subject: [PATCH 2/2] fix: README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aumentei a cobertura de testes e não removi menção de um único arquivo de teste no bloco TODO. --- Solution/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Solution/README.md b/Solution/README.md index 49dad865d4..0488e09481 100644 --- a/Solution/README.md +++ b/Solution/README.md @@ -82,7 +82,7 @@ TODO: - Talvez trocar timestamp do ponto da serie para os segundos desde 1970, provavelmente terá uma redução percebida na performance de escrita e armazenamento (provavelmente aumentaria o tempo de leitura para organizar o timestamp em data hora)... - Nao foi inserido alembic para trabalhar com migrations, a criação da tabela é feita pelo lifespan da aplicação, para escalar pode nao ser uma boa caso duas aplicacoes subam ao mesmo tempo tentando criar tabela no banco... - Criar CI. -- A Aplicação foi montada pensando numa modularização clara router->service->repository router cuida de validacoes de entrada e saida da rota, service cuida da logica da aplicacao e como os dados serão persistidos e repository cuida da persistencia e leitura em banco. Esse modelo facilita os testes automatizados, para nao utilizar o banco em testes automatizados de integracao parcial, consigo mockar as chamadas pro repository, assim como no exemplo do unico arquivo de teste criado Post... +- A Aplicação foi montada pensando numa modularização clara router->service->repository router cuida de validacoes de entrada e saida da rota, service cuida da logica da aplicacao e como os dados serão persistidos e repository cuida da persistencia e leitura em banco. Esse modelo facilita os testes automatizados, para nao utilizar o banco em testes automatizados de integracao parcial, consigo mockar as chamadas pro repository. - Subir a cobertura de testes. - Atualmente a resposta pro client na criação de uma serie está sincrono, devolvemos detalhes da inserção como id registrado em banco após inserção, dependendo como essa aplicação crescer isso não será escalavel, dado que poderá haver outras comunicações durante a inserção, como por exemplo o acionamento para um servico de alarme ao perceber que há registros de pontos da serie violados para o objeto medido... Uma possivel solução seria criar uma fila de mensagens como o rabbitMQ e worker consumindo a fila para o registro em banco. - Não foram realizados testes de carga na aplicacao, nem teste com aplicação hospedada em nuvem.