From 8bcb35610eaf34837363a74a4127bb91504e3c9e Mon Sep 17 00:00:00 2001 From: Schlotged Date: Thu, 18 Jun 2026 12:34:23 -0300 Subject: [PATCH 1/4] feat: add load balancer, load tests and edge case tests --- .vscode/settings.json | 3 + gedson-silva/.gitignore | 17 + gedson-silva/Dockerfile | 10 + gedson-silva/README.md | 88 ++++ gedson-silva/alembic.ini | 0 gedson-silva/alembic/env.py | 0 gedson-silva/app/core/config.py | 10 + gedson-silva/app/core/database.py | 11 + gedson-silva/app/enums/messages.py | 6 + gedson-silva/app/main.py | 22 + gedson-silva/app/models/models.py | 26 + gedson-silva/app/repositories/time_series.py | 23 + gedson-silva/app/routers/api.py | 58 +++ gedson-silva/app/schemas/requests.py | 18 + gedson-silva/app/schemas/responses.py | 39 ++ gedson-silva/app/services/get_or_not_found.py | 13 + gedson-silva/app/services/prediction.py | 30 ++ gedson-silva/app/services/time_series.py | 21 + gedson-silva/docker-compose.yml | 39 ++ gedson-silva/guide-tests-curl.md | 444 ++++++++++++++++++ gedson-silva/locustfile.py | 27 ++ gedson-silva/nginx.conf | 19 + gedson-silva/requirements.txt | 11 + gedson-silva/tests/__init__.py | 0 gedson-silva/tests/conftest.py | 22 + gedson-silva/tests/test_time_series.py | 129 +++++ 26 files changed, 1086 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 gedson-silva/.gitignore create mode 100644 gedson-silva/Dockerfile create mode 100644 gedson-silva/README.md create mode 100644 gedson-silva/alembic.ini create mode 100644 gedson-silva/alembic/env.py create mode 100644 gedson-silva/app/core/config.py create mode 100644 gedson-silva/app/core/database.py create mode 100644 gedson-silva/app/enums/messages.py create mode 100644 gedson-silva/app/main.py create mode 100644 gedson-silva/app/models/models.py create mode 100644 gedson-silva/app/repositories/time_series.py create mode 100644 gedson-silva/app/routers/api.py create mode 100644 gedson-silva/app/schemas/requests.py create mode 100644 gedson-silva/app/schemas/responses.py create mode 100644 gedson-silva/app/services/get_or_not_found.py create mode 100644 gedson-silva/app/services/prediction.py create mode 100644 gedson-silva/app/services/time_series.py create mode 100644 gedson-silva/docker-compose.yml create mode 100644 gedson-silva/guide-tests-curl.md create mode 100644 gedson-silva/locustfile.py create mode 100644 gedson-silva/nginx.conf create mode 100644 gedson-silva/requirements.txt create mode 100644 gedson-silva/tests/__init__.py create mode 100644 gedson-silva/tests/conftest.py create mode 100644 gedson-silva/tests/test_time_series.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..c9ebf2d279 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/gedson-silva/.gitignore b/gedson-silva/.gitignore new file mode 100644 index 0000000000..32930ce4bc --- /dev/null +++ b/gedson-silva/.gitignore @@ -0,0 +1,17 @@ +*.env.prod +*.env +.vscode + +pytest_cache/ +.pytest_cache + +guide.md + +*.db +*.db-shm +*db-wal + +venv/ +__pycache__/ +*.pyc +.env \ No newline at end of file diff --git a/gedson-silva/Dockerfile b/gedson-silva/Dockerfile new file mode 100644 index 0000000000..fffe0b30c1 --- /dev/null +++ b/gedson-silva/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/gedson-silva/README.md b/gedson-silva/README.md new file mode 100644 index 0000000000..e35b3bb25d --- /dev/null +++ b/gedson-silva/README.md @@ -0,0 +1,88 @@ +# Dynamox Time-Series API + +API REST para armazenamento e processamento de séries temporais, desenvolvida com FastAPI, PostgreSQL e Docker. + +## Pré-requisitos + +- Python 3.11+ +- Docker e Docker Compose +- Git + +## Como rodar + +### 1. Clone o repositório + +```bash +git clone https://github.com/seu-usuario/developer-challenges.git +cd developer-challenges/gedson-silva +``` + +### 2. Local (SQLite) + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +A API estará disponível em `http://localhost:8000`. + +### 3. Docker (PostgreSQL + Nginx + 3 réplicas) + +```bash +docker compose up --build +``` + +Sobe automaticamente: +- 3 instâncias da API +- PostgreSQL 16 +- Nginx como load balancer na porta 8000 + +A API estará disponível em `http://localhost:8000`. + +## Endpoints + +| Método | Rota | Descrição | +|--------|------|-----------| +| POST | /api/v1/series/ | Criar série | +| GET | /api/v1/series/count | Contar séries | +| GET | /api/v1/series/{id} | Buscar série completa | +| GET | /api/v1/series/{id}/metrics | Métricas estatísticas | +| GET | /api/v1/series/{id}/predict?steps=N | Predição futura (padrão: 10 passos) | +| DELETE | /api/v1/series/{id} | Deletar série | + +## Documentação interativa + +Acesse `http://localhost:8000/docs` após subir a API. + +## Testes + +```bash +pip install -r requirements.txt +pytest tests/ -v +``` + +13 testes cobrindo happy path e casos de borda (404, 422, validações). + +## Load Tests + +Testes de carga realizados com [Locust](https://locust.io/) com 50 usuários simultâneos. + +### Rodar load tests + +```bash +pip install locust +locust -f locustfile.py --host=http://localhost:8000 +``` + +Acesse `http://localhost:8089` para configurar e monitorar. + +### Resultados + +| Endpoint | Mediana (ms) | Percentil 95 (ms) | Falhas | +|---|---|---|---| +| GET /series/{id} | 9 | ~310 | 0% | +| GET /series/{id}/metrics | 10–12 | ~20 | 0% | +| POST /series/ | 5900 | 7200 | 0% | + +> Todos os endpoints GET responderam bem abaixo do requisito de 350ms. +> A latência do POST reflete a criação simultânea de 50 séries no startup do teste, não o uso típico. \ No newline at end of file diff --git a/gedson-silva/alembic.ini b/gedson-silva/alembic.ini new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gedson-silva/alembic/env.py b/gedson-silva/alembic/env.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gedson-silva/app/core/config.py b/gedson-silva/app/core/config.py new file mode 100644 index 0000000000..860df872de --- /dev/null +++ b/gedson-silva/app/core/config.py @@ -0,0 +1,10 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str = "sqlite://./timeseries.db" + APP_ENV: str = "development" + + class ConfigDict: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/gedson-silva/app/core/database.py b/gedson-silva/app/core/database.py new file mode 100644 index 0000000000..8917a2d257 --- /dev/null +++ b/gedson-silva/app/core/database.py @@ -0,0 +1,11 @@ +from app.core.config import settings + +TORTOISE_ORM = { + "connections": {"default": settings.DATABASE_URL}, + "apps": { + "models": { + "models": ["app.models.models", "aerich.models"], + "default_connection": "default", + }, + }, +} \ No newline at end of file diff --git a/gedson-silva/app/enums/messages.py b/gedson-silva/app/enums/messages.py new file mode 100644 index 0000000000..4de713bdbc --- /dev/null +++ b/gedson-silva/app/enums/messages.py @@ -0,0 +1,6 @@ +from enum import StrEnum + +class TimeSeriesMessage(StrEnum): + NOT_FOUND = "Série não encontrada" + CONFLICT = "Registro duplicado" + INSUFFICIENT_POINTS = "Mínimo de 2 pontos para predição" \ No newline at end of file diff --git a/gedson-silva/app/main.py b/gedson-silva/app/main.py new file mode 100644 index 0000000000..4bba0442cd --- /dev/null +++ b/gedson-silva/app/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from tortoise.contrib.fastapi import register_tortoise +from app.routers.api import router as series_router +from app.core.database import TORTOISE_ORM + +app = FastAPI( + title="Dynamox Time-Series API", + version="1.0.0", +) + +register_tortoise( + app, + config=TORTOISE_ORM, + generate_schemas=True, + add_exception_handlers=True, +) + +app.include_router(series_router) + +@app.get("/health") +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/gedson-silva/app/models/models.py b/gedson-silva/app/models/models.py new file mode 100644 index 0000000000..52d67b9f5c --- /dev/null +++ b/gedson-silva/app/models/models.py @@ -0,0 +1,26 @@ +import uuid +from tortoise import fields +from tortoise.fields import CASCADE +from tortoise.models import Model + +class TimeSeries(Model): + id = fields.CharField(primary_key=True, max_length=36, default=lambda: str(uuid.uuid4())) + name = fields.CharField(max_length=255, null=True) + created_at = fields.DatetimeField(auto_now_add=True) + + points: fields.ReverseRelation["DataPoint"] + + class Meta: # type: ignore + table = "time_series" + + +class DataPoint(Model): + id = fields.CharField(primary_key=True, max_length=36, default=lambda: str(uuid.uuid4())) + series: fields.ForeignKeyRelation["TimeSeries"] = fields.ForeignKeyField( + "models.TimeSeries", related_name="points", on_delete=CASCADE + ) + timestamp = fields.DatetimeField() + value = fields.FloatField() + + class Meta: # type: ignore + table = "data_points" \ No newline at end of file diff --git a/gedson-silva/app/repositories/time_series.py b/gedson-silva/app/repositories/time_series.py new file mode 100644 index 0000000000..ab3987debb --- /dev/null +++ b/gedson-silva/app/repositories/time_series.py @@ -0,0 +1,23 @@ +from app.models.models import TimeSeries, DataPoint +from app.schemas.requests import TimeSeriesCreate + + +class TimeSeriesRepository: + + async def create(self, data: TimeSeriesCreate) -> TimeSeries: + series = await TimeSeries.create(name=data.name) + await DataPoint.bulk_create([ + DataPoint(series=series, timestamp=p.timestamp, value=p.value) + for p in data.points + ]) + await series.fetch_related("points") + return series + + async def get_by_id(self, series_id: str) -> TimeSeries | None: + return await TimeSeries.get_or_none(id=series_id).prefetch_related("points") + + async def delete(self, series: TimeSeries) -> None: + await series.delete() + + async def count(self) -> int: + return await TimeSeries.all().count() \ No newline at end of file diff --git a/gedson-silva/app/routers/api.py b/gedson-silva/app/routers/api.py new file mode 100644 index 0000000000..d3d37e569e --- /dev/null +++ b/gedson-silva/app/routers/api.py @@ -0,0 +1,58 @@ +from tortoise.exceptions import IntegrityError +from fastapi import APIRouter, HTTPException, Query +from app.repositories.time_series import TimeSeriesRepository +from app.schemas.requests import TimeSeriesCreate +from app.schemas.responses import CountResponse, TimeSeriesMetrics, TimeSeriesOut, TimeSeriesPrediction +from app.services.time_series import calculate_metrics +from app.services.prediction import predict_linear +from fastapi import status +from app.services.get_or_not_found import get_series_or_404 + +router = APIRouter(prefix="/api/v1/series", tags=["Time Series"]) +repo = TimeSeriesRepository() + + +@router.post("/", status_code=201, response_model=TimeSeriesOut) +async def create_series(data: TimeSeriesCreate): + try: + instance_created = await repo.create(data) + except IntegrityError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + return instance_created + + +@router.get("/count", response_model=CountResponse) +async def count_series(): + total = await repo.count() + return CountResponse(count=total) + + +@router.get("/{series_id}", response_model=TimeSeriesOut) +async def get_series(series_id: str): + return await get_series_or_404(series_id) + + +@router.get("/{series_id}/metrics", response_model=TimeSeriesMetrics) +async def get_metrics(series_id: str): + series = await get_series_or_404(series_id) + return calculate_metrics(series) + + +@router.get("/{series_id}/predict", response_model=TimeSeriesPrediction) +async def predict_series( + series_id: str, + steps: int = Query(default=10, ge=1, le=100), +): + series = await get_series_or_404(series_id) + try: + return predict_linear(series, steps) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) + + + +@router.delete("/{series_id}", status_code=204) +async def delete_series(series_id: str): + series = await get_series_or_404(series_id) + await repo.delete(series) + diff --git a/gedson-silva/app/schemas/requests.py b/gedson-silva/app/schemas/requests.py new file mode 100644 index 0000000000..6d58fb3b11 --- /dev/null +++ b/gedson-silva/app/schemas/requests.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, field_validator +from datetime import datetime + + +class DataPointIn(BaseModel): + timestamp: datetime + value: float + +class TimeSeriesCreate(BaseModel): + name: str | None = None + points: list[DataPointIn] + + @field_validator("points") + @classmethod + def must_have_points(cls, v): + if len(v) == 0: + raise ValueError("A série deve ter pelo menos 1 ponto") + return v diff --git a/gedson-silva/app/schemas/responses.py b/gedson-silva/app/schemas/responses.py new file mode 100644 index 0000000000..75aea03216 --- /dev/null +++ b/gedson-silva/app/schemas/responses.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, field_validator +from datetime import datetime + +class DataPointOut(BaseModel): + timestamp: datetime + value: float + + model_config = {"from_attributes": True} + +class TimeSeriesOut(BaseModel): + id: str + name: str | None + created_at: datetime + points: list[DataPointOut] + + model_config = {"from_attributes": True} + +class TimeSeriesMetrics(BaseModel): + series_id: str + count: int + min: float + max: float + mean: float + median: float + std: float + range: float + +class PredictedPoint(BaseModel): + step: int + value: float + +class TimeSeriesPrediction(BaseModel): + series_id: str + method: str + steps: int + predicted_points: list[PredictedPoint] + +class CountResponse(BaseModel): + count: int \ No newline at end of file diff --git a/gedson-silva/app/services/get_or_not_found.py b/gedson-silva/app/services/get_or_not_found.py new file mode 100644 index 0000000000..789ea46abc --- /dev/null +++ b/gedson-silva/app/services/get_or_not_found.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException +from fastapi import status +from app.enums.messages import TimeSeriesMessage +from app.models.models import TimeSeries +from app.repositories.time_series import TimeSeriesRepository + + +async def get_series_or_404(series_id: str) -> TimeSeries: + repo = TimeSeriesRepository() + series = await repo.get_by_id(series_id) + if not series: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=TimeSeriesMessage.NOT_FOUND) + return series \ No newline at end of file diff --git a/gedson-silva/app/services/prediction.py b/gedson-silva/app/services/prediction.py new file mode 100644 index 0000000000..91384aac8c --- /dev/null +++ b/gedson-silva/app/services/prediction.py @@ -0,0 +1,30 @@ +import numpy as np +from app.enums.messages import TimeSeriesMessage +from app.models.models import TimeSeries +from app.schemas.responses import PredictedPoint, TimeSeriesPrediction + + +def predict_linear(series: TimeSeries, steps: int = 10) -> TimeSeriesPrediction: + if len(series.points) < 2: + raise ValueError(TimeSeriesMessage.INSUFFICIENT_POINTS) + + values = np.array([p.value for p in series.points]) + x = np.arange(len(values)) + + # Regressão linear: y = a*x + b + coeffs = np.polyfit(x, values, deg=1) + poly = np.poly1d(coeffs) + + # Predizer os próximos `steps` pontos + future_x = np.arange(len(values), len(values) + steps) + predicted = poly(future_x) + + return TimeSeriesPrediction( + series_id=series.id, + method="linear_regression", + steps=steps, + predicted_points=[ + PredictedPoint(step=i + 1, value=round(float(v), 6)) + for i, v in enumerate(predicted) + ], + ) \ No newline at end of file diff --git a/gedson-silva/app/services/time_series.py b/gedson-silva/app/services/time_series.py new file mode 100644 index 0000000000..cbfd41c472 --- /dev/null +++ b/gedson-silva/app/services/time_series.py @@ -0,0 +1,21 @@ +import numpy as np +from app.models.models import TimeSeries +from app.schemas.responses import TimeSeriesMetrics + + +def calculate_metrics(series: TimeSeries) -> TimeSeriesMetrics: + if not series.points: + raise ValueError("Série sem pontos para calcular métricas") + + values = np.array([p.value for p in series.points]) + + return TimeSeriesMetrics( + series_id=series.id, + count=len(values), + min=float(np.min(values)), + max=float(np.max(values)), + mean=float(np.mean(values)), + median=float(np.median(values)), + std=float(np.std(values)), + range=float(np.max(values) - np.min(values)), + ) \ No newline at end of file diff --git a/gedson-silva/docker-compose.yml b/gedson-silva/docker-compose.yml new file mode 100644 index 0000000000..0d6baab077 --- /dev/null +++ b/gedson-silva/docker-compose.yml @@ -0,0 +1,39 @@ +services: + api: + build: . + environment: + - DATABASE_URL=postgres://dynamox:dynamox@db:5432/dynamox + - APP_ENV=production + depends_on: + db: + condition: service_healthy + deploy: + replicas: 3 + + nginx: + image: nginx:alpine + ports: + - "8000:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - api + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: dynamox + POSTGRES_PASSWORD: dynamox + POSTGRES_DB: dynamox + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dynamox"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: \ No newline at end of file diff --git a/gedson-silva/guide-tests-curl.md b/gedson-silva/guide-tests-curl.md new file mode 100644 index 0000000000..d1b1365c30 --- /dev/null +++ b/gedson-silva/guide-tests-curl.md @@ -0,0 +1,444 @@ +# Guia de Testes da API Time Series + +Este documento descreve como validar manualmente todos os endpoints da API utilizando **curl** ou **Postman**. + +## Pré-requisitos + +Suba a aplicação: + +```bash +uvicorn app.main:app --reload +``` + +Verifique se a API está disponível: + +```bash +curl http://localhost:8000/health +``` + +Resposta esperada: + +```json +{ + "status": "ok" +} +``` + +--- + +# Variáveis Utilizadas + +Durante os testes, substitua: + +```text + +``` + +pelo ID retornado na criação da série. + +Exemplo: + +```text +3c8e8b3f-d7e0-45c6-8f71-4a8f4d87c1d1 +``` + +--- + +# 1. Criar Série Temporal + +## Objetivo + +Cadastrar uma nova série temporal com múltiplos pontos. + +## Curl + +```bash +curl -X POST "http://localhost:8000/api/v1/series/" \ +-H "Content-Type: application/json" \ +-d '{ + "name": "sensor-test", + "points": [ + { + "timestamp": "2024-01-01T00:00:00Z", + "value": 1.0 + }, + { + "timestamp": "2024-01-01T00:01:00Z", + "value": 2.0 + }, + { + "timestamp": "2024-01-01T00:02:00Z", + "value": 3.0 + }, + { + "timestamp": "2024-01-01T00:03:00Z", + "value": 4.0 + }, + { + "timestamp": "2024-01-01T00:04:00Z", + "value": 5.0 + } + ] +}' +``` + +## Postman + +### Método + +```text +POST +``` + +### URL + +```text +http://localhost:8000/api/v1/series/ +``` + +### Headers + +```text +Content-Type: application/json +``` + +### Body (Raw / JSON) + +```json +{ + "name": "sensor-test", + "points": [ + { + "timestamp": "2024-01-01T00:00:00Z", + "value": 1.0 + }, + { + "timestamp": "2024-01-01T00:01:00Z", + "value": 2.0 + }, + { + "timestamp": "2024-01-01T00:02:00Z", + "value": 3.0 + }, + { + "timestamp": "2024-01-01T00:03:00Z", + "value": 4.0 + }, + { + "timestamp": "2024-01-01T00:04:00Z", + "value": 5.0 + } + ] +} +``` + +## Resultado Esperado + +Status: + +```text +201 Created +``` + +Exemplo: + +```json +{ + "id": "3c8e8b3f-d7e0-45c6-8f71-4a8f4d87c1d1", + "name": "sensor-test", + "created_at": "2026-06-15T12:00:00Z", + "points": [...] +} +``` + +Copie o valor de `id`. + +--- + +# 2. Contar Séries + +## Curl + +```bash +curl http://localhost:8000/api/v1/series/count +``` + +## Postman + +### Método + +```text +GET +``` + +### URL + +```text +http://localhost:8000/api/v1/series/count +``` + +## Resultado Esperado + +```json +{ + "count": 1 +} +``` + +--- + +# 3. Buscar Série por ID + +## Curl + +```bash +curl http://localhost:8000/api/v1/series/ +``` + +## Exemplo + +```bash +curl http://localhost:8000/api/v1/series/3c8e8b3f-d7e0-45c6-8f71-4a8f4d87c1d1 +``` + +## Resultado Esperado + +Status: + +```text +200 OK +``` + +Resposta: + +```json +{ + "id": "...", + "name": "sensor-test", + "created_at": "...", + "points": [...] +} +``` + +--- + +# 4. Buscar Série Inexistente + +## Curl + +```bash +curl http://localhost:8000/api/v1/series/id-inexistente +``` + +## Resultado Esperado + +Status: + +```text +404 Not Found +``` + +Resposta: + +```json +{ + "detail": "Série não encontrada" +} +``` + +--- + +# 5. Consultar Métricas + +## Curl + +```bash +curl http://localhost:8000/api/v1/series//metrics +``` + +## Exemplo + +```bash +curl http://localhost:8000/api/v1/series/3c8e8b3f-d7e0-45c6-8f71-4a8f4d87c1d1/metrics +``` + +## Resultado Esperado + +```json +{ + "series_id": "...", + "count": 5, + "min": 1.0, + "max": 5.0, + "mean": 3.0, + "median": 3.0, + "std": 1.414213562, + "range": 4.0 +} +``` + +--- + +# 6. Consultar Predição (Bônus) + +## Curl + +```bash +curl "http://localhost:8000/api/v1/series//predict?steps=5" +``` + +## Exemplo + +```bash +curl "http://localhost:8000/api/v1/series/3c8e8b3f-d7e0-45c6-8f71-4a8f4d87c1d1/predict?steps=5" +``` + +## Resultado Esperado + +```json +{ + "series_id": "...", + "method": "linear_regression", + "steps": 5, + "predicted_points": [ + { + "step": 1, + "value": 6.0 + }, + { + "step": 2, + "value": 7.0 + } + ] +} +``` + +--- + +# 7. Testar Predição com Poucos Pontos + +Criar uma série contendo apenas um ponto. + +## Payload + +```json +{ + "name": "single-point", + "points": [ + { + "timestamp": "2024-01-01T00:00:00Z", + "value": 10 + } + ] +} +``` + +## Chamada + +```bash +curl "http://localhost:8000/api/v1/series//predict" +``` + +## Resultado Esperado + +```text +422 Unprocessable Entity +``` + +```json +{ + "detail": "Mínimo de 2 pontos para predição" +} +``` + +--- + +# 8. Excluir Série + +## Curl + +```bash +curl -X DELETE \ +"http://localhost:8000/api/v1/series/" +``` + +## Resultado Esperado + +Status: + +```text +204 No Content +``` + +Sem corpo de resposta. + +--- + +# 9. Validar Exclusão + +Após remover a série: + +```bash +curl http://localhost:8000/api/v1/series/ +``` + +## Resultado Esperado + +```text +404 Not Found +``` + +```json +{ + "detail": "Série não encontrada" +} +``` + +--- + +# 10. Testar Payload Inválido + +## Curl + +```bash +curl -X POST "http://localhost:8000/api/v1/series/" \ +-H "Content-Type: application/json" \ +-d '{ + "name": "invalid", + "points": [] +}' +``` + +## Resultado Esperado + +```text +422 Unprocessable Entity +``` + +Resposta semelhante: + +```json +{ + "detail": [ + { + "msg": "A série deve ter pelo menos 1 ponto" + } + ] +} +``` + +--- + +# Checklist Manual + +- [ ] Health check funcionando +- [ ] Criar série +- [ ] Buscar série +- [ ] Contar séries +- [ ] Consultar métricas +- [ ] Consultar predição +- [ ] Testar série inexistente +- [ ] Testar payload inválido +- [ ] Excluir série +- [ ] Confirmar exclusão +- [ ] Tempo de resposta abaixo de 350ms \ No newline at end of file diff --git a/gedson-silva/locustfile.py b/gedson-silva/locustfile.py new file mode 100644 index 0000000000..69ddb86c73 --- /dev/null +++ b/gedson-silva/locustfile.py @@ -0,0 +1,27 @@ +from locust import HttpUser, task, between +from uuid import uuid4 + +class TimeSeriesUser(HttpUser): + wait_time = between(1, 2) + + def on_start(self): + res = self.client.post("/api/v1/series/", json={ + "name": f"load-test-{uuid4()}", + "points": [ + {"timestamp": f"2024-01-01T00:0{i}:00Z", "value": float(i)} + for i in range(5) + ] + }) + self.series_id = res.json().get("id") + + @task(3) + def get_series(self): + self.client.get(f"/api/v1/series/{self.series_id}") + + @task(2) + def get_metrics(self): + self.client.get(f"/api/v1/series/{self.series_id}/metrics") + + @task(1) + def count(self): + self.client.get("/api/v1/series/count") \ No newline at end of file diff --git a/gedson-silva/nginx.conf b/gedson-silva/nginx.conf new file mode 100644 index 0000000000..94aec832a2 --- /dev/null +++ b/gedson-silva/nginx.conf @@ -0,0 +1,19 @@ +events { + worker_connections 1024; +} + +http { + upstream api { + server api:8000; + } + + server { + listen 80; + + location / { + proxy_pass http://api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} \ No newline at end of file diff --git a/gedson-silva/requirements.txt b/gedson-silva/requirements.txt new file mode 100644 index 0000000000..82e10f91f6 --- /dev/null +++ b/gedson-silva/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +tortoise-orm[asyncpg]>=0.21.0 +aerich>=0.7.2 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +numpy>=1.26.0 +scipy>=1.13.0 +httpx>=0.27.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 \ No newline at end of file diff --git a/gedson-silva/tests/__init__.py b/gedson-silva/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gedson-silva/tests/conftest.py b/gedson-silva/tests/conftest.py new file mode 100644 index 0000000000..82fdd70490 --- /dev/null +++ b/gedson-silva/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from tortoise import Tortoise +from app.main import app + +TEST_DATABASE_URL = "sqlite://:memory:" + +@pytest_asyncio.fixture(scope="function") +async def client(): + await Tortoise.init( + db_url=TEST_DATABASE_URL, + modules={"models": ["app.models.models"]}, + ) + await Tortoise.generate_schemas() + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + + await Tortoise.close_connections() \ No newline at end of file diff --git a/gedson-silva/tests/test_time_series.py b/gedson-silva/tests/test_time_series.py new file mode 100644 index 0000000000..461b78652b --- /dev/null +++ b/gedson-silva/tests/test_time_series.py @@ -0,0 +1,129 @@ +import pytest +from httpx import AsyncClient + +PAYLOAD = { + "name": "sensor-test", + "points": [ + {"timestamp": "2024-01-01T00:00:00Z", "value": 1.0}, + {"timestamp": "2024-01-01T00:01:00Z", "value": 2.0}, + {"timestamp": "2024-01-01T00:02:00Z", "value": 3.0}, + {"timestamp": "2024-01-01T00:03:00Z", "value": 4.0}, + {"timestamp": "2024-01-01T00:04:00Z", "value": 5.0}, + ] +} + +@pytest.mark.asyncio +async def test_create_series(client: AsyncClient): + res = await client.post("/api/v1/series/", json=PAYLOAD) + assert res.status_code == 201 + data = res.json() + assert data["name"] == "sensor-test" + assert len(data["points"]) == 5 + +@pytest.mark.asyncio +async def test_create_series_empty_points(client: AsyncClient): + res = await client.post("/api/v1/series/", json={"points": []}) + assert res.status_code == 422 + +@pytest.mark.asyncio +async def test_get_series(client: AsyncClient): + created = await client.post("/api/v1/series/", json=PAYLOAD) + series_id = created.json()["id"] + + res = await client.get(f"/api/v1/series/{series_id}") + assert res.status_code == 200 + assert res.json()["id"] == series_id + +@pytest.mark.asyncio +async def test_get_series_not_found(client: AsyncClient): + res = await client.get("/api/v1/series/id-inexistente") + assert res.status_code == 404 + +@pytest.mark.asyncio +async def test_metrics(client: AsyncClient): + created = await client.post("/api/v1/series/", json=PAYLOAD) + series_id = created.json()["id"] + + res = await client.get(f"/api/v1/series/{series_id}/metrics") + assert res.status_code == 200 + data = res.json() + assert data["min"] == 1.0 + assert data["max"] == 5.0 + assert data["mean"] == 3.0 + assert data["count"] == 5 + +@pytest.mark.asyncio +async def test_predict(client: AsyncClient): + created = await client.post("/api/v1/series/", json=PAYLOAD) + series_id = created.json()["id"] + + res = await client.get(f"/api/v1/series/{series_id}/predict?steps=5") + assert res.status_code == 200 + data = res.json() + assert len(data["predicted_points"]) == 5 + assert data["method"] == "linear_regression" + +@pytest.mark.asyncio +async def test_delete_series(client: AsyncClient): + created = await client.post("/api/v1/series/", json=PAYLOAD) + series_id = created.json()["id"] + + res = await client.delete(f"/api/v1/series/{series_id}") + assert res.status_code == 204 + + res = await client.get(f"/api/v1/series/{series_id}") + assert res.status_code == 404 + +@pytest.mark.asyncio +async def test_count(client: AsyncClient): + res = await client.get("/api/v1/series/count") + assert res.status_code == 200 + initial = res.json()["count"] + + await client.post("/api/v1/series/", json=PAYLOAD) + await client.post("/api/v1/series/", json=PAYLOAD) + + res = await client.get("/api/v1/series/count") + assert res.json()["count"] == initial + 2 + +# --- Testes de borda --- + +@pytest.mark.asyncio +async def test_predict_insufficient_points(client: AsyncClient): + res = await client.post("/api/v1/series/", json={ + "name": "single-point", + "points": [ + {"timestamp": "2024-01-01T00:00:00Z", "value": 1.0}, + ] + }) + series_id = res.json()["id"] + + res = await client.get(f"/api/v1/series/{series_id}/predict") + assert res.status_code == 422 + +@pytest.mark.asyncio +async def test_metrics_not_found(client: AsyncClient): + res = await client.get("/api/v1/series/id-inexistente/metrics") + assert res.status_code == 404 + +@pytest.mark.asyncio +async def test_predict_not_found(client: AsyncClient): + res = await client.get("/api/v1/series/id-inexistente/predict") + assert res.status_code == 404 + +@pytest.mark.asyncio +async def test_delete_not_found(client: AsyncClient): + res = await client.delete("/api/v1/series/id-inexistente") + assert res.status_code == 404 + +@pytest.mark.asyncio +async def test_predict_steps_out_of_range(client: AsyncClient): + created = await client.post("/api/v1/series/", json=PAYLOAD) + series_id = created.json()["id"] + + res = await client.get(f"/api/v1/series/{series_id}/predict?steps=0") + assert res.status_code == 422 + + res = await client.get(f"/api/v1/series/{series_id}/predict?steps=101") + assert res.status_code == 422 + From e0a803cf5c31c94dc5925f54121f330a057b2cbb Mon Sep 17 00:00:00 2001 From: Schlotged Date: Thu, 18 Jun 2026 13:13:13 -0300 Subject: [PATCH 2/4] report: adjusted gitignore --- gedson-silva/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gedson-silva/.gitignore b/gedson-silva/.gitignore index 32930ce4bc..6a28cc58f0 100644 --- a/gedson-silva/.gitignore +++ b/gedson-silva/.gitignore @@ -1,7 +1,7 @@ *.env.prod *.env .vscode - +*.json pytest_cache/ .pytest_cache From cc6a68d3c01bd5d698f4640899726992d2b2e753 Mon Sep 17 00:00:00 2001 From: Schlotged Date: Thu, 18 Jun 2026 13:19:09 -0300 Subject: [PATCH 3/4] report: adjusted gitignore from vscode settings --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index c9ebf2d279..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:system" -} \ No newline at end of file From 2b63a8820200f09dbde19d43b3137092d8e61e09 Mon Sep 17 00:00:00 2001 From: Schlotged Date: Thu, 18 Jun 2026 15:17:32 -0300 Subject: [PATCH 4/4] =?UTF-8?q?report:=20add=20api=20in=20Render=20and=20D?= =?UTF-8?q?ocumenta=C3=A7=C3=A3o=20interativa=20in=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gedson-silva/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/gedson-silva/README.md b/gedson-silva/README.md index e35b3bb25d..160b62e4f9 100644 --- a/gedson-silva/README.md +++ b/gedson-silva/README.md @@ -24,7 +24,22 @@ pip install -r requirements.txt uvicorn app.main:app --reload ``` -A API estará disponível em `http://localhost:8000`. +## Documentação interativa + +### Ambiente local + +Após subir a aplicação localmente, acesse: + +`http://localhost:8000/docs` + +### Ambiente de produção + +A documentação da API publicada para avaliação do desafio está disponível em: + +`https://developer-challenges-qz0n.onrender.com/docs` + +Através dessa interface é possível visualizar todos os endpoints, schemas, exemplos de requisição e testar a API diretamente pelo navegador. + ### 3. Docker (PostgreSQL + Nginx + 3 réplicas)