diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..3cec9b7180 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.env +.env.* +*.pem +*.key +__pycache__/ +*.py[cod] +*$py.class +*.so +venv/ +env/ +.venv/ +ENV/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +*.log +*.sqlite3 +timescaledb_data/ # O volume do Docker que criámos! +.vscode/ +.idea/ +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..c6e069e86a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +# Define o diretório de trabalho dentro do container +WORKDIR /app + +# Copia os requisitos primeiro para otimizar o cache do Docker +COPY requirements.txt . + +# Instala as dependências +RUN pip install --no-cache-dir -r requirements.txt + +# Copia o resto do código do projeto para dentro do container +COPY . . + +# Expõe a porta que o FastAPI vai usar +EXPOSE 8000 + +# Comandos para iniciar a aplicação +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index 39dedd60ec..169d87e1d5 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,77 @@ -# Dynamox Developer Challenges +# Dynamox Back-end Challenge -## About Dynamox +## Database -[Dynamox](https://dynamox.net/) is a high-tech firm specializing in vibration analysis and industrial asset condition monitoring. Our expert team develops comprehensive hardware and software solutions, encompassing firmware, mobile applications (Android and iOS), and full-stack cloud native applications. +For this project, I selected a database composed of two tables: one containing the measurement metadata (name and unit), and another containing the corresponding time series data. -With our proficiency in signal processing for vibration and acoustics, we deliver advanced and precise monitoring systems. We are committed to optimizing operational efficiency and facilitating proactive maintenance through our innovative technology and integrated solutions. +```mermaid +erDiagram + SERIES { + uuid id PK + string name + string unit + timestamp created_at + } -## Positions + SERIES_DATA { + timestamp timestamp PK + uuid series_id PK,FK + float value + } -We are looking for developers who are passionate about learning, growing, and contributing to our team. You will play a key role in our development efforts, working on a variety of projects and collaborating with different teams to build and improve our solutions. + SERIES ||--|{ SERIES_DATA : "" +``` -We value flexibility and collaboration, hence we provide opportunities for you to lend your skills to other teams when required. Join us on this exciting journey as we revolutionize our digital platforms. Currently we are particularly interested in individuals who can identify with one of the following role descriptions: +## Requirements -### Junior Software Developer +* Docker and Docker Compose +* Python 3.12+ -With limited experience, assists in coding, testing, and stabilizing systems under supervision. Communicates with immediate team members and solves straightforward problems with guidance. Should display a willingness to learn and grow professionally. This is an individual contributor role. +## How to Run - Docker -### Mid-level Software Developer +The easiest way to execute the project is using Docker Compose, which will provision both the PostgreSQL database and the FastAPI application. -With a certain level of proven experience, contributes to software development, solves moderate problems, and starts handling ambiguous situations with minimal guidance. Communicates with the broader team and engages in code reviews and documentation. This role also includes supporting junior engineers and commitment to continuous learning. This is an individual contributor role. +1. Clone the repository and navigate to the project root. +2. Build and start the containers: + ```bash + docker-compose up --build -d + ``` +3. The API will be available at: `http://localhost:8000` +4. Access the interactive API documentation at: `http://localhost:8000/docs` -### Senior-level Software Developer +## Running the Automated Tests -With vast experience, enhances software development, leading complex system development and ambiguous situation handling. Tackles intricate problems and mentors junior and mid-level engineers. Champions coding standards, project strategy, and technology adoption. Communicates across teams, influencing technical and non-technical stakeholders. This individual contributor role blends technical expertise with leadership, focusing on innovation, mentorship, and strategic contributions to the development process. +The project uses `pytest` for unit and integration testing. Ensure the database container is running before executing the tests. -## Challenges Full-Stack +Execute the tests from the project root: +```bash +docker exec -it dynamox_api pytest -v +``` -- [ ] [01 - Dynamox Full-Stack Node.js React Developer Challenge](./full-stack-challenge.md) -- [ ] [02 - Dynamox Full-Stack C# React Developer Challenge](./full-stack-csharp-react-challenge.md) - -## Challenges Front-End +## Running the Load Tests -- [ ] [01 - Dynamox Front-end React Developer Challenge Marketing Teams](./front-end-challenge-v1.md) -- [ ] [02 - Dynamox Front-end React Developer Challenge Product Teams](./front-end-challenge-v2.md) +Load tests were implemented using Locust to ensure the application meets the sub-350ms response time requirement for bulk data insertion. -## Challenges DevOps +1. Ensure the API is running (via Docker or locally). +2. In a separate terminal, with the virtual environment activated, run: + ```bash + locust -f locustfile.py + ``` +3. Open `http://localhost:8089` in your browser. +4. Configure the test (e.g., 50 concurrent users, spawn rate of 5) and set the host to `http://localhost:8000`. +5. Start the test to observe RPS and latency metrics. -- [ ] [01 - Dynamox DevOps Developer Challenge Foundation Teams](./dev-sec-fin-ops-challenge-v1/README.md) +## API Endpoints Overview -## Challenges Mobile +* `POST /api/series/`: Creates a new series with bulk data points. +* `GET /api/series/{id}`: Retrieves a specific series and its data points. +* `GET /api/series/`: Lists all available series. +* `GET /api/series/count`: Returns the total number of data points for a given series. +* `GET /api/series/{id}/predict`: Predicts future values for a series based on historical data using Simple Linear Regression. -- [ ] [01 - Dynamox Kotlin Multiplatform Developer Challenge](./kotlin-multiplatform-challenge.md) -- [ ] [02 - Dynamox Android Developer Challenge](./android-challenge.md) -- [ ] [03 - Dynamox iOS Developer Challenge](./ios-challenge.md) +## Implemented Features -## Challenge Back-End -- [ ] [01 - Dynamox Back-End Time Series ](./back-end-challenge-v1.md) - -## Challenge QA -- [ ] [01- Dynamox QA Challenge](./qa-challenge.md) - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 +* **Asynchronous Database Access:** Implemented with `asyncpg` and SQLAlchemy for high throughput. +* **Bulk Inserts:** Optimized data ingestion to handle batches of 500+ points per request efficiently. +* **Data Prediction:** Custom implementation of Ordinary Least Squares (OLS) Linear Regression to forecast future sensor values without heavy external machine learning dependencies. +* **Load Testing:** Locust implementation proving stable latencies under the requested threshold during continuous stress testing. \ No newline at end of file diff --git a/app/api/endpoints.py b/app/api/endpoints.py new file mode 100644 index 0000000000..d3b5a57039 --- /dev/null +++ b/app/api/endpoints.py @@ -0,0 +1,247 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import insert, select, func, delete +from uuid import UUID +from datetime import timedelta +import logging + +from app.core.database import get_db +from app.models.series import Series, SeriesData +from app.schemas.series import SeriesCreate, SeriesResponse, SeriesFullResponse, MetricsResponse, PredictionResponse + +router = APIRouter(prefix="/series", tags=["Series de Tempo"]) + +logger = logging.getLogger(__name__) + +@router.get("/count") +async def get_series_count(db: AsyncSession = Depends(get_db)): + """ + Retorna a quantidade total de séries armazenadas no servidor. + """ + result = await db.execute(select(func.count(Series.id))) + total = result.scalar() or 0 + return {"total_series": total} + +@router.post("/", response_model=SeriesResponse, status_code=status.HTTP_201_CREATED) +async def create_time_series( + payload: SeriesCreate, + db: AsyncSession = Depends(get_db) +): + """ + Recebe os dados de um sensor e insere no banco de dados. + Utilização de Bulk Insert para reduzir a latência. + """ + try: + # Criação do registro principal + new_series = Series( + name=payload.name, + unit=payload.unit + ) + db.add(new_series) + + # Flush para gerar o ID sem ainda inserir no banco de dados + await db.flush() + + # Mapeando os pontos recebidos no JSON + data_points_to_insert = [ + { + "timestamp": point.timestamp, + "series_id": new_series.id, + "value": point.value + } + for point in payload.data_points + ] + + # Inserção dos dados no banco de dados + if data_points_to_insert: + stmt = insert(SeriesData).values(data_points_to_insert) + await db.execute(stmt) + + await db.commit() + await db.refresh(new_series) + + return new_series + + except Exception as e: + # Desfaz a operação se houver algum erro no processo + await db.rollback() + logger.error(f"Erro ao inserir série: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro interno ao tentar salvar os dados da série temporal." + ) + +@router.get("/{series_id}/metrics", response_model=MetricsResponse) +async def get_series_metrics( + series_id: UUID, + db: AsyncSession = Depends(get_db) +): + """ + Calcula métricas (média, máximo e mínimo). + """ + + metrics_stmt = select( + func.count(SeriesData.value).label("count"), + func.avg(SeriesData.value).label("average"), + func.max(SeriesData.value).label("max_value"), + func.min(SeriesData.value).label("min_value") + ).where(SeriesData.series_id == series_id) + + result = await db.execute(metrics_stmt) + metrics = result.one() + + if metrics.count == 0: + series_exists = await db.execute(select(Series).where(Series.id == series_id)) + if not series_exists.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Série temporal não encontrada.") + + return MetricsResponse(series_id=series_id, count=0) + + return { + "series_id": series_id, + "count": metrics.count, + "average": metrics.average, + "max_value": metrics.max_value, + "min_value": metrics.min_value + } + +@router.get("/{series_id}", response_model=SeriesFullResponse) +async def get_full_series( + series_id: UUID, + db: AsyncSession = Depends(get_db) +): + """ + Retorna a série temporal completa, incluindo todos os pontos de dados armazenados. + """ + + # Busca aos metadados da série + result = await db.execute(select(Series).where(Series.id == series_id)) + series = result.scalar_one_or_none() + + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Série não encontrada." + ) + + # Busca os pontos de dados associados a série, ordenados por tempo + data_result = await db.execute( + select(SeriesData) + .where(SeriesData.series_id == series_id) + .order_by(SeriesData.timestamp.asc()) + ) + data_points = data_result.scalars().all() + + return { + "id": series.id, + "name": series.name, + "unit": series.unit, + "created_at": series.created_at, + "data": data_points + } + +@router.delete("/{series_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_series( + series_id: UUID, + db: AsyncSession = Depends(get_db) +): + """ + Remove uma série e todos os seus dados associados via CASCADE. + Retorna '204 No Content' em caso de sucesso. + """ + + # Verifica se a série existe + result = await db.execute(select(Series).where(Series.id == series_id)) + series = result.scalar_one_or_none() + + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Série temporal não encontrada." + ) + + # Comando de deletar os registros associados ao ID + await db.execute(delete(Series).where(Series.id == series_id)) + + await db.commit() + + # Retorna 'Status 204' sem corpo de resposta + return None + +@router.get("/{series_id}/predict", response_model=PredictionResponse) +async def predict_series( + series_id: UUID, + steps: int = 5, + db: AsyncSession = Depends(get_db) +): + """ + Prevê os próximos pontos ('steps') da série temporal usando Regressão Linear Simples. + """ + + result = await db.execute(select(Series).where(Series.id == series_id)) + series = result.scalar_one_or_none() + + if not series: + raise HTTPException(status_code=404, detail="Série não encontrada.") + + data_result = await db.execute( + select(SeriesData) + .where(SeriesData.series_id == series_id) + .order_by(SeriesData.timestamp.asc()) + ) + data_points = data_result.scalars().all() + + # Requisito mínimo de 2 pontos para traçar uma reta + n = len(data_points) + if n < 2: + raise HTTPException(status_code=400, detail="Pontos insuficientes para predição. Necessário pelo menos 2.") + + # Tornando o timestamp em segundos relativos + t0 = data_points[0].timestamp + x_vals = [(pt.timestamp - t0).total_seconds() for pt in data_points] + y_vals = [pt.value for pt in data_points] + + # Cálculo da Regressão Linear + sum_x = sum(x_vals) + sum_y = sum(y_vals) + sum_xy = sum(x * y for x, y in zip(x_vals, y_vals)) + sum_x2 = sum(x**2 for x in x_vals) + + denominator = (n * sum_x2) - (sum_x ** 2) + + # Evita divisão por zero (teórico para caso todos os pontos tenham exatamente o mesmo timestamp) + if denominator == 0: + m = 0.0 + b = sum_y / n + else: + m = ((n * sum_xy) - (sum_x * sum_y)) / denominator + b = (sum_y - (m * sum_x)) / n + + # Cálculo do intervalo médio de tempo para saber quando serão os pontos futuros + avg_interval_seconds = (x_vals[-1] - x_vals[0]) / (n - 1) + if avg_interval_seconds == 0: + avg_interval_seconds = 1.0 + + # Gera os pontos previstos + last_x = x_vals[-1] + last_time = data_points[-1].timestamp + predictions = [] + + for i in range(1, steps + 1): + next_x = last_x + (avg_interval_seconds * i) + next_time = last_time + timedelta(seconds=avg_interval_seconds * i) + + # Fórmula da reta: y = mx + b + next_y = (m * next_x) + b + + predictions.append({ + "timestamp": next_time, + "predicted_value": round(next_y, 4) + }) + + return { + "series_id": series.id, + "name": series.name, + "unit": series.unit, + "predictions": predictions + } \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000000..a388ac61af --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,21 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import declarative_base + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://dynamox_user:dynamox_password@localhost:5432/timeseries_db" +) +# Criação da engine asincrona do banco de dados +engine = create_async_engine(DATABASE_URL, echo=False, future=True) + +# Configuração das transações do banco +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + +# A classe base que será herdada pelos modelos +Base = declarative_base() + +# Função auxiliar para injetar a sessão do banco nas rotas do FastAPI +async def get_db(): + async with AsyncSessionLocal() as session: + yield session \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000..874f35b053 --- /dev/null +++ b/app/main.py @@ -0,0 +1,54 @@ +import time +from contextlib import asynccontextmanager +import fastapi +from sqlalchemy import text + +from app.core.database import engine, Base +from app.api.endpoints import router as series_router + +@asynccontextmanager +async def lifespan(app: fastapi.FastAPI): + # Cria as tabelas do banco de dados, se elas ainda não existirem + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Torna a tabela 'series_data' em hypertable + hypertable_sql = text(""" + SELECT create_hypertable( + 'series_data', + 'timestamp', + if_not_exists => TRUE + ); + """) + await conn.execute(hypertable_sql) + print("Banco de dados e Hypertable criados.") + + yield + + # Quando desligar o servidor, fechamos a conexão com o banco de forma limpa + await engine.dispose() + print("Conexão com o banco de dados encerrada.") + +app = fastapi.FastAPI( + title="Dynamox Time Series API", + description="API de avaliação para o desafio de desenvolvedor back-end.", + version="1.0.0", + lifespan=lifespan +) + +@app.middleware("http") +async def add_process_time_header(request: fastapi.Request, call_next): + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + + # Injetando o tempo de processamento em milissegundos + response.headers["X-Process-Time-ms"] = str(round(process_time * 1000, 2)) + return response + +@app.get("/", include_in_schema=False) +def health_check(): + """Rota raiz apenas para confirmar que a API está viva.""" + return fastapi.responses.HTMLResponse(content="

API ativa.

Acesse /docs para o Swagger.

") + +app.include_router(series_router, prefix="/api") \ No newline at end of file diff --git a/app/models/series.py b/app/models/series.py new file mode 100644 index 0000000000..d6090c1df5 --- /dev/null +++ b/app/models/series.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime, UTC +from sqlalchemy import Column, String, Float, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +class Series(Base): + __tablename__ = "series" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) + unit = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.now(UTC)) + +class SeriesData(Base): + __tablename__ = "series_data" + + timestamp = Column(DateTime(timezone=True), primary_key=True) + + series_id = Column( + UUID(as_uuid=True), + ForeignKey("series.id", ondelete="CASCADE"), + primary_key=True + ) + + value = Column(Float, nullable=False) \ No newline at end of file diff --git a/app/schemas/series.py b/app/schemas/series.py new file mode 100644 index 0000000000..33df07548c --- /dev/null +++ b/app/schemas/series.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +class DataPointCreate(BaseModel): + """Esquema para um único ponto de dado lido pelo sensor""" + timestamp: datetime = Field(..., description="Data e hora exata da leitura") + value: float = Field(..., description="Valor numérico registado pelo sensor") + +class SeriesCreate(BaseModel): + """Esquema principal para o payload de criação de uma nova série""" + name: str = Field(..., min_length=1, max_length=100, description="Nome identificador do sensor/equipamento") + unit: str = Field(..., min_length=1, max_length=20, description="Unidade de medida (ex: °C, mm/s, Hz)") + data_points: List[DataPointCreate] = Field(..., min_length=1, description="Lista de pontos de dados a serem inseridos") + +class SeriesResponse(BaseModel): + """Esquema para devolver os metadados de uma série""" + id: UUID + name: str + unit: str + created_at: datetime + + # Habilitado para ler dos modelos SQLAlchemy + model_config = ConfigDict(from_attributes=True) + +class DataPointResponse(BaseModel): + """Esquema para devolver os dados brutos de uma série""" + timestamp: datetime + value: float + + model_config = ConfigDict(from_attributes=True) + +class SeriesFullResponse(SeriesResponse): + """Esquema para devolver a série completa""" + data: List[DataPointResponse] + +class MetricsResponse(BaseModel): + """Esquema para devolver os cálculos estatísticos""" + series_id: UUID + count: int = Field(description="Total de pontos de dados nesta série") + average: Optional[float] = Field(None, description="Média de todos os valores") + max_value: Optional[float] = Field(None, description="Valor máximo registado") + min_value: Optional[float] = Field(None, description="Valor mínimo registado") + +class PredictedPoint(BaseModel): + """Esquema para representar um ponto previsto no tempo""" + timestamp: datetime = Field(description="Data e hora da previsão") + predicted_value: float = Field(description="Valor previsto para a série no instante informado") + +class PredictionResponse(BaseModel): + """Esquema para devolver as previsões geradas para uma série temporal""" + series_id: UUID = Field(description="Identificador único da série") + name: str = Field(description="Nome da série temporal") + unit: str = Field(description="Unidade de medida da série") + predictions: list[PredictedPoint] = Field(description="Lista de pontos previstos ordenados cronologicamente") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..6750ea8dc2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + timescaledb: + image: timescale/timescaledb:latest-pg15 + container_name: dynamox_timescaledb + environment: + - POSTGRES_USER=dynamox_user + - POSTGRES_PASSWORD=dynamox_password + - POSTGRES_DB=timeseries_db + ports: + - "5432:5432" + volumes: + - timescaledb_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dynamox_user -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + container_name: dynamox_api + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql+asyncpg://dynamox_user:dynamox_password@timescaledb:5432/timeseries_db + depends_on: + timescaledb: + condition: service_healthy + + locust: + image: locustio/locust + ports: + - "8089:8089" + volumes: + - .:/mnt/locust + command: -f /mnt/locust/locustfile.py + depends_on: + - api + +volumes: + timescaledb_data: \ No newline at end of file diff --git a/init.sql b/init.sql new file mode 100644 index 0000000000..05b46415f8 --- /dev/null +++ b/init.sql @@ -0,0 +1 @@ +CREATE DATABASE timeseries_db_test; \ No newline at end of file diff --git a/locustfile.py b/locustfile.py new file mode 100644 index 0000000000..346a25c379 --- /dev/null +++ b/locustfile.py @@ -0,0 +1,35 @@ +from locust import HttpUser, task, between +from datetime import datetime, timezone, timedelta +import random + +class SensorSimulationUser(HttpUser): + + # Simulação de espera de envio dos sensores + wait_time = between(1, 3) + + @task(3) + def simulate_sensor_data_insertion(self): + """Simula um sensor enviando um lote de 500 pontos de uma vez.""" + + # Gera 500 pontos de dados com timestamps sequenciais + base_time = datetime.now(timezone.utc) + data_points = [ + { + "timestamp": (base_time + timedelta(seconds=i)).isoformat(), + "value": random.uniform(20.0, 80.0) + } + for i in range(500) + ] + + payload = { + "name": f"Sensor de Carga {random.randint(1, 100)}", + "unit": "Hz", + "data_points": data_points + } + + self.client.post("/api/series/", json=payload, name="POST /api/series/ (500 pts)") + + @task(1) + def simulate_dashboard_read(self): + """Simula uma leitura moderada no banco de dados por parte de um usuário.""" + self.client.get("/api/series/count", name="GET /api/series/count") \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..206bffb83b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +asyncio_mode = auto \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..6d86508fad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.112.0 +uvicorn==0.41.0 +sqlalchemy==2.0.47 +asyncpg==0.31.0 +pytest==8.4.2 +pytest-asyncio==1.3.0 +httpx==0.28.1 +locust==2.43.3 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..937932a3fc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import pytest +import asyncio +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.core.database import Base, get_db +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +TEST_DATABASE_URL = "postgresql+asyncpg://dynamox_user:dynamox_password@timescaledb:5432/timeseries_db_test" + +@pytest.fixture +async def engine(): + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + yield engine + await engine.dispose() + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + +@pytest.fixture(autouse=True) +async def setup_db(engine): + """Para cria as tabelas antes de cada teste e apaga depois.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture +async def client(engine): + """Configuração de um cliente HTTP assíncrono para testar os endpoints.""" + + AsyncSessionTest = async_sessionmaker(engine, expire_on_commit=False) + + async def override_get_db(): + async with AsyncSessionTest() as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + + async with AsyncClient( + transport=transport, + base_url="http://test" + ) as ac: + yield ac \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000000..23ec4f5c40 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,193 @@ +import pytest +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +@pytest.mark.asyncio +async def test_create_series_performance(client, setup_db): + """Validação de desempenho na inserção de pontos no banco de dados""" + + # Teste com 1000 pontos + data_points = [ + {"timestamp": (datetime.now(timezone.utc) + timedelta(seconds=i)).isoformat(), "value": 20.0 + i} + for i in range(1000) + ] + + payload = { + "name": "Sensor de Stress Test", + "unit": "mm/s", + "data_points": data_points + } + + response = await client.post("/api/series/", json=payload) + + assert response.status_code == 201 + data = response.json() + assert "id" in data + + # Cálculo da latência para validação de requisito + raw_header = response.headers.get("X-Process-Time-ms") + assert raw_header is not None, "Header ausente na resposta." + + latency = float(raw_header) + print(f"\n[LATENCY] {latency}ms") + assert latency < 350, f"Tempo de {latency}ms excedeu o limite de 350ms" + +@pytest.mark.asyncio +async def test_get_metrics(client, setup_db): + """Validação dos cálculos matemáticos das métricas """ + + payload = { + "name": "Sensor Metricas", + "unit": "°C", + "data_points": [ + {"timestamp": "2026-01-01T10:00:00Z", "value": 10.0}, + {"timestamp": "2026-01-01T10:01:00Z", "value": 20.0}, + {"timestamp": "2026-01-01T10:02:00Z", "value": 30.0} + ] + } + create_res = await client.post("/api/series/", json=payload) + series_id = create_res.json()["id"] + + response = await client.get(f"/api/series/{series_id}/metrics") + + assert response.status_code == 200 + metrics = response.json() + assert metrics["count"] == 3 + assert metrics["average"] == 20.0 + assert metrics["max_value"] == 30.0 + assert metrics["min_value"] == 10.0 + +@pytest.mark.asyncio +async def test_get_series_count(client, setup_db): + """Teste para garantir que a contagem de séries temporais funciona corretamente.""" + + # Verifica se o banco começa vazio + res_initial = await client.get("/api/series/count") + assert res_initial.status_code == 200 + assert res_initial.json()["total_series"] == 0 + + # Insere 2 séries diferentes + for i in range(2): + payload = { + "name": f"Sensor de Teste {i}", + "unit": "V", + "data_points": [{"timestamp": "2026-01-01T10:00:00Z", "value": 1.5}] + } + await client.post("/api/series/", json=payload) + + # Verifica se a contagem atualizou para 2 + res_final = await client.get("/api/series/count") + assert res_final.status_code == 200 + assert res_final.json()["total_series"] == 2 + +@pytest.mark.asyncio +async def test_get_full_series_success(client, setup_db): + """Teste para buscar uma série temporal completa e validar seus pontos.""" + + # Cria a série temporal com 2 pontos + payload = { + "name": "Sensor de Pressão", + "unit": "Pa", + "data_points": [ + {"timestamp": "2026-01-01T10:00:00Z", "value": 100.5}, + {"timestamp": "2026-01-01T10:01:00Z", "value": 101.0} + ] + } + create_res = await client.post("/api/series/", json=payload) + series_id = create_res.json()["id"] + + # Busca a série pelo ID e valida o retorno + get_res = await client.get(f"/api/series/{series_id}") + + assert get_res.status_code == 200 + data = get_res.json() + + # Valida metadados + assert data["id"] == series_id + assert data["name"] == "Sensor de Pressão" + assert data["unit"] == "Pa" + assert "created_at" in data + + # Valida a lista de pontos + assert "data" in data + assert len(data["data"]) == 2 + assert data["data"][0]["value"] == 100.5 + assert data["data"][1]["value"] == 101.0 + + +@pytest.mark.asyncio +async def test_get_full_series_not_found(client, setup_db): + """Teste para garantir que buscar um ID inexistente retorna 404.""" + + # Gera um UUID falso e aleatório + fake_id = str(uuid4()) + + # Tenta buscar essa série e retorno '404' com a mensagem esperada + response = await client.get(f"/api/series/{fake_id}") + + assert response.status_code == 404 + assert response.json()["detail"] == "Série não encontrada." + +@pytest.mark.asyncio +async def test_predict_series_success(client, setup_db): + """Teste para validar sucesso verificando a precisão matemática da predição.""" + + # Criando uma série temporal com tendência "óbvia" de 2.0 por degrau + base_time = datetime.now(timezone.utc) + payload = { + "name": "Sensor de Temperatura Crescente", + "unit": "°C", + "data_points": [ + {"timestamp": base_time.isoformat(), "value": 10.0}, # Segundo 0 + {"timestamp": (base_time + timedelta(seconds=1)).isoformat(), "value": 12.0}, # Segundo 1 + {"timestamp": (base_time + timedelta(seconds=2)).isoformat(), "value": 14.0} # Segundo 2 + ] + } + create_res = await client.post("/api/series/", json=payload) + series_id = create_res.json()["id"] + + # Chama o endpoint de predição pedindo 2 passos (steps) no futuro + predict_res = await client.get(f"/api/series/{series_id}/predict?steps=2") + + assert predict_res.status_code == 200 + data = predict_res.json() + + assert len(data["predictions"]) == 2 + # Previsão 1 (Segundo 3): Deve ser 16.0 + assert data["predictions"][0]["predicted_value"] == 16.0 + # Previsão 2 (Segundo 4): Deve ser 18.0 + assert data["predictions"][1]["predicted_value"] == 18.0 + + +@pytest.mark.asyncio +async def test_predict_series_insufficient_data(client, setup_db): + """Teste garantindo que a API recusa prever com menos de 2 pontos.""" + + # Cria uma série temporal com apenas 1 ponto + payload = { + "name": "Sensor Novo", + "unit": "Hz", + "data_points": [ + {"timestamp": "2026-01-01T10:00:00Z", "value": 50.0} + ] + } + create_res = await client.post("/api/series/", json=payload) + series_id = create_res.json()["id"] + + # Tenta prever + predict_res = await client.get(f"/api/series/{series_id}/predict") + + # Deve retornar '400 Bad Request' com a mensagem correta + assert predict_res.status_code == 400 + assert "menos 2" in predict_res.json()["detail"] + + +@pytest.mark.asyncio +async def test_predict_series_not_found(client, setup_db): + """Teste garantindo o erro '404' para IDs inexistentes na predição.""" + + fake_id = str(uuid4()) + predict_res = await client.get(f"/api/series/{fake_id}/predict") + + assert predict_res.status_code == 404 + assert predict_res.json()["detail"] == "Série não encontrada." \ No newline at end of file