From e3d86ca5890068d573e2a8493616a9c731d6defa Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Wed, 25 Feb 2026 15:45:24 -0300 Subject: [PATCH 01/11] chore: initialize repository with .gitignore, docker-compose, README.md, and requirements.txt --- .gitignore | 23 ++++++++++++++++ README.md | 68 ++++++++++++---------------------------------- docker-compose.yml | 16 +++++++++++ requirements.txt | 4 +++ 4 files changed, 61 insertions(+), 50 deletions(-) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 requirements.txt 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/README.md b/README.md index 39dedd60ec..6849ef3f1d 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,23 @@ -# 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. - -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: - -### Junior Software Developer - -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. - -### Mid-level Software Developer - -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. - -### Senior-level Software Developer - -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. - -## Challenges Full-Stack - -- [ ] [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 - -- [ ] [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) - -## Challenges DevOps - -- [ ] [01 - Dynamox DevOps Developer Challenge Foundation Teams](./dev-sec-fin-ops-challenge-v1/README.md) - -## Challenges Mobile - -- [ ] [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) - -## 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.** 🚀 + SERIES ||--|{ SERIES_DATA : "" +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..432da24bb1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +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 + restart: unless-stopped + +volumes: + timescaledb_data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..b1c2779e72 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.112.0 +uvicorn==0.41.0 +sqlalchemy==2.0.47 +asyncpg==0.31.0 \ No newline at end of file From 4bcc5549aa72cdb49980f94c943b8d6be666a8bc Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Thu, 26 Feb 2026 10:55:54 -0300 Subject: [PATCH 02/11] feat: implement POST endpoint for API --- app/api/endpoints.py | 61 +++++++++++++++++++++++++++++++++++++++++++ app/core/database.py | 18 +++++++++++++ app/main.py | 54 ++++++++++++++++++++++++++++++++++++++ app/models/series.py | 26 ++++++++++++++++++ app/schemas/series.py | 46 ++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 app/api/endpoints.py create mode 100644 app/core/database.py create mode 100644 app/main.py create mode 100644 app/models/series.py create mode 100644 app/schemas/series.py diff --git a/app/api/endpoints.py b/app/api/endpoints.py new file mode 100644 index 0000000000..84576d5e09 --- /dev/null +++ b/app/api/endpoints.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import insert +import logging + +from app.core.database import get_db +from app.models.series import Series, SeriesData +from app.schemas.series import SeriesCreate, SeriesResponse + +router = APIRouter(prefix="/series", tags=["Series de Tempo"]) + +logger = logging.getLogger(__name__) + +@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." + ) \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000000..1bb1a56319 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,18 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import declarative_base + +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) + +# 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..0438f74e38 --- /dev/null +++ b/app/models/series.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime +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.utcnow) + +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..1f3629aede --- /dev/null +++ b/app/schemas/series.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, Field +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_items=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 + + class Config: + # Habilitado para ler dos modelos SQLAlchemy + from_attributes = True + +class DataPointResponse(BaseModel): + """Esquema para devolver os dados brutos de uma série""" + timestamp: datetime + value: float + + class Config: + 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") \ No newline at end of file From b1f371881a0d1ebb4845369cdfba8f01cadbe666 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Thu, 26 Feb 2026 11:20:41 -0300 Subject: [PATCH 03/11] feat: implement GET endpoint --- app/api/endpoints.py | 75 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/app/api/endpoints.py b/app/api/endpoints.py index 84576d5e09..970f40ef76 100644 --- a/app/api/endpoints.py +++ b/app/api/endpoints.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import insert +from sqlalchemy import insert, select, func +from uuid import UUID import logging from app.core.database import get_db from app.models.series import Series, SeriesData -from app.schemas.series import SeriesCreate, SeriesResponse +from app.schemas.series import SeriesCreate, SeriesResponse, SeriesFullResponse, MetricsResponse router = APIRouter(prefix="/series", tags=["Series de Tempo"]) @@ -58,4 +59,72 @@ async def create_time_series( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erro interno ao tentar salvar os dados da série temporal." - ) \ No newline at end of file + ) + +@router.get("/{series_id}", response_model=SeriesFullResponse) +async def get_series_details( + series_id: UUID, + db: AsyncSession = Depends(get_db) +): + """ + Retorna os metadados de uma série e todos os seus pontos de dados. + """ + + 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." + ) + + # Busca dos pontos de dados 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.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 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 + } \ No newline at end of file From 0f07794f05a07db19353e7cb41800f61745460b7 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Thu, 26 Feb 2026 14:17:44 -0300 Subject: [PATCH 04/11] feat: implement REMOVE endpoint --- app/api/endpoints.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/app/api/endpoints.py b/app/api/endpoints.py index 970f40ef76..f4a803a0c6 100644 --- a/app/api/endpoints.py +++ b/app/api/endpoints.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import insert, select, func +from sqlalchemy import insert, select, func, delete from uuid import UUID import logging @@ -117,7 +117,7 @@ async def get_series_metrics( 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 não encontrada.") + raise HTTPException(status_code=404, detail="Série temporal não encontrada.") return MetricsResponse(series_id=series_id, count=0) @@ -127,4 +127,32 @@ async def get_series_metrics( "average": metrics.average, "max_value": metrics.max_value, "min_value": metrics.min_value - } \ No newline at end of file + } + +@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 \ No newline at end of file From f2ff9d14cbeb3bd4127f4626f0437b718d0ef067 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Fri, 27 Feb 2026 16:50:20 -0300 Subject: [PATCH 05/11] feat: implement python tests --- app/core/database.py | 2 +- app/models/series.py | 4 +-- app/schemas/series.py | 12 ++++----- docker-compose.yml | 1 + init.sql | 1 + pytest.ini | 3 +++ requirements.txt | 4 ++- tests/conftest.py | 49 +++++++++++++++++++++++++++++++++++++ tests/test_api.py | 57 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 init.sql create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py diff --git a/app/core/database.py b/app/core/database.py index 1bb1a56319..c6c55b91c1 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -4,7 +4,7 @@ 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) +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) diff --git a/app/models/series.py b/app/models/series.py index 0438f74e38..d6090c1df5 100644 --- a/app/models/series.py +++ b/app/models/series.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +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 @@ -10,7 +10,7 @@ class Series(Base): 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.utcnow) + created_at = Column(DateTime(timezone=True), default=datetime.now(UTC)) class SeriesData(Base): __tablename__ = "series_data" diff --git a/app/schemas/series.py b/app/schemas/series.py index 1f3629aede..aec718e2a7 100644 --- a/app/schemas/series.py +++ b/app/schemas/series.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from datetime import datetime from typing import List, Optional from uuid import UUID @@ -12,7 +12,7 @@ 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_items=1, description="Lista de pontos de dados a serem inseridos") + 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""" @@ -21,17 +21,15 @@ class SeriesResponse(BaseModel): unit: str created_at: datetime - class Config: - # Habilitado para ler dos modelos SQLAlchemy - from_attributes = True + # 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 - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class SeriesFullResponse(SeriesResponse): """Esquema para devolver a série completa""" diff --git a/docker-compose.yml b/docker-compose.yml index 432da24bb1..21bf10b01d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - "5432:5432" volumes: - timescaledb_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql restart: unless-stopped volumes: 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/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 index b1c2779e72..cd12a744b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ fastapi==0.112.0 uvicorn==0.41.0 sqlalchemy==2.0.47 -asyncpg==0.31.0 \ No newline at end of file +asyncpg==0.31.0 +pytest-asyncio==0.21.1 +httpx==0.28.1 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..a294f46c5e --- /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@localhost: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..fa035b6c0f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,57 @@ +import pytest +from datetime import datetime, timedelta, timezone + +@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 \ No newline at end of file From 52a0d3819db9bf9f7810925bb2fefb48f5676bf6 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Fri, 27 Feb 2026 18:33:04 -0300 Subject: [PATCH 06/11] feat: add endpoints to retrieve series count and full data --- app/api/endpoints.py | 78 +++++++++++++++++++++++++------------------- tests/test_api.py | 74 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 35 deletions(-) diff --git a/app/api/endpoints.py b/app/api/endpoints.py index f4a803a0c6..0e6e5ed92f 100644 --- a/app/api/endpoints.py +++ b/app/api/endpoints.py @@ -12,6 +12,15 @@ 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, @@ -61,40 +70,6 @@ async def create_time_series( detail="Erro interno ao tentar salvar os dados da série temporal." ) -@router.get("/{series_id}", response_model=SeriesFullResponse) -async def get_series_details( - series_id: UUID, - db: AsyncSession = Depends(get_db) -): - """ - Retorna os metadados de uma série e todos os seus pontos de dados. - """ - - 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." - ) - - # Busca dos pontos de dados 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.get("/{series_id}/metrics", response_model=MetricsResponse) async def get_series_metrics( series_id: UUID, @@ -129,6 +104,41 @@ async def get_series_metrics( "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, diff --git a/tests/test_api.py b/tests/test_api.py index fa035b6c0f..f80abdade8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ import pytest from datetime import datetime, timedelta, timezone +from uuid import uuid4 @pytest.mark.asyncio async def test_create_series_performance(client, setup_db): @@ -54,4 +55,75 @@ async def test_get_metrics(client, setup_db): assert metrics["count"] == 3 assert metrics["average"] == 20.0 assert metrics["max_value"] == 30.0 - assert metrics["min_value"] == 10.0 \ No newline at end of file + 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." \ No newline at end of file From 620ab3575aa09698819b8c0328ad738e0fa32d3a Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Sat, 28 Feb 2026 16:11:10 -0300 Subject: [PATCH 07/11] feat: create load tests with locust --- locustfile.py | 35 +++++++++++++++++++++++++++++++++++ requirements.txt | 3 ++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 locustfile.py 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/requirements.txt b/requirements.txt index cd12a744b0..4136b0d7d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn==0.41.0 sqlalchemy==2.0.47 asyncpg==0.31.0 pytest-asyncio==0.21.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +locust==2.43.3 \ No newline at end of file From 72bb9263dacd77d3cbf1d5f65f518bd6437d3cb3 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Sat, 28 Feb 2026 18:11:57 -0300 Subject: [PATCH 08/11] feat: implement linear regression prediction --- app/api/endpoints.py | 83 +++++++++++++++++++++++++++++++++++++++++-- app/schemas/series.py | 14 +++++++- requirements.txt | 3 +- tests/test_api.py | 66 +++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/app/api/endpoints.py b/app/api/endpoints.py index 0e6e5ed92f..d3b5a57039 100644 --- a/app/api/endpoints.py +++ b/app/api/endpoints.py @@ -2,11 +2,12 @@ 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 +from app.schemas.series import SeriesCreate, SeriesResponse, SeriesFullResponse, MetricsResponse, PredictionResponse router = APIRouter(prefix="/series", tags=["Series de Tempo"]) @@ -165,4 +166,82 @@ async def delete_series( await db.commit() # Retorna 'Status 204' sem corpo de resposta - return None \ No newline at end of file + 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/schemas/series.py b/app/schemas/series.py index aec718e2a7..33df07548c 100644 --- a/app/schemas/series.py +++ b/app/schemas/series.py @@ -41,4 +41,16 @@ class MetricsResponse(BaseModel): 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") \ No newline at end of file + 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/requirements.txt b/requirements.txt index 4136b0d7d0..6d86508fad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.112.0 uvicorn==0.41.0 sqlalchemy==2.0.47 asyncpg==0.31.0 -pytest-asyncio==0.21.1 +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/test_api.py b/tests/test_api.py index f80abdade8..23ec4f5c40 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -126,4 +126,68 @@ async def test_get_full_series_not_found(client, setup_db): response = await client.get(f"/api/series/{fake_id}") assert response.status_code == 404 - assert response.json()["detail"] == "Série não encontrada." \ No newline at end of file + 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 From be718b256ca13b338e8c6977117a8c0e3634e962 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Sun, 1 Mar 2026 22:34:46 -0300 Subject: [PATCH 09/11] feat: add Dockerfile and finalize README --- Dockerfile | 19 +++++++++++ README.md | 78 +++++++++++++++++++++++++++++++++++++++++++- app/core/database.py | 7 ++-- docker-compose.yml | 10 ++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Dockerfile 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 6849ef3f1d..0eff6f0f11 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,80 @@ erDiagram } SERIES ||--|{ SERIES_DATA : "" -``` \ No newline at end of file +``` + +## Requirements + +* Docker and Docker Compose +* Python 3.12+ + +## How to Run - Docker + +The easiest way to execute the project is using Docker Compose, which will provision both the PostgreSQL database and the FastAPI application. + +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` + +## How to Run - Local Development + +To run the application locally outside of the application container: + +1. Start only the database container: + ```bash + docker-compose up -d db + ``` +2. Create and activate a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate + ``` +3. Install the dependencies: + ```bash + pip install -r requirements.txt + ``` +4. Run the application using Uvicorn: + ```bash + uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + ``` + +## Running the Automated Tests + +The project uses `pytest` for unit and integration testing. Ensure the database container is running before executing the tests. + +Execute the tests from the project root: +```bash +pytest -v +``` + +## Running the Load Tests + +Load tests were implemented using Locust to ensure the application meets the sub-350ms response time requirement for bulk data insertion. + +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. + +## API Endpoints Overview + +* `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. + +## Implemented Features + +* **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/core/database.py b/app/core/database.py index c6c55b91c1..a388ac61af 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,8 +1,11 @@ +import os from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.orm import declarative_base -DATABASE_URL = "postgresql+asyncpg://dynamox_user:dynamox_password@localhost:5432/timeseries_db" - +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) diff --git a/docker-compose.yml b/docker-compose.yml index 21bf10b01d..e7d46458fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,5 +13,15 @@ services: - ./init.sql:/docker-entrypoint-initdb.d/init.sql restart: unless-stopped + 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 + volumes: timescaledb_data: \ No newline at end of file From fba0ee448942ccee0f66b5770b4b7fdbaff1d3a2 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Tue, 10 Mar 2026 22:17:16 -0300 Subject: [PATCH 10/11] fix: automated testes working on docker --- README.md | 24 +----------------------- docker-compose.yml | 8 +++++++- tests/conftest.py | 2 +- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0eff6f0f11..169d87e1d5 100644 --- a/README.md +++ b/README.md @@ -39,35 +39,13 @@ The easiest way to execute the project is using Docker Compose, which will provi 3. The API will be available at: `http://localhost:8000` 4. Access the interactive API documentation at: `http://localhost:8000/docs` -## How to Run - Local Development - -To run the application locally outside of the application container: - -1. Start only the database container: - ```bash - docker-compose up -d db - ``` -2. Create and activate a virtual environment: - ```bash - python -m venv venv - source venv/bin/activate - ``` -3. Install the dependencies: - ```bash - pip install -r requirements.txt - ``` -4. Run the application using Uvicorn: - ```bash - uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - ``` - ## Running the Automated Tests The project uses `pytest` for unit and integration testing. Ensure the database container is running before executing the tests. Execute the tests from the project root: ```bash -pytest -v +docker exec -it dynamox_api pytest -v ``` ## Running the Load Tests diff --git a/docker-compose.yml b/docker-compose.yml index e7d46458fe..5d82d593e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,11 @@ services: - 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: . @@ -21,7 +26,8 @@ services: environment: - DATABASE_URL=postgresql+asyncpg://dynamox_user:dynamox_password@timescaledb:5432/timeseries_db depends_on: - - timescaledb + timescaledb: + condition: service_healthy volumes: timescaledb_data: \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index a294f46c5e..937932a3fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ 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@localhost:5432/timeseries_db_test" +TEST_DATABASE_URL = "postgresql+asyncpg://dynamox_user:dynamox_password@timescaledb:5432/timeseries_db_test" @pytest.fixture async def engine(): From 0b552caccb2363e50ee9b147017d8034a70d4629 Mon Sep 17 00:00:00 2001 From: yagocastrorosa Date: Tue, 10 Mar 2026 22:23:08 -0300 Subject: [PATCH 11/11] fix: add locust on docker --- docker-compose.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5d82d593e1..6750ea8dc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,5 +29,15 @@ services: 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