diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..69ed2ba42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system", + "python.testing.pytestArgs": [ + "signal-processing-api" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/signal-processing-api/.dockerignore b/signal-processing-api/.dockerignore new file mode 100644 index 000000000..403a3b576 --- /dev/null +++ b/signal-processing-api/.dockerignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +\.venv +\.pytest_cache +.coverage +*.db +*.sqlite3 +.env +.idea/ +.vscode/ +__pycache__/ diff --git a/signal-processing-api/.gitignore b/signal-processing-api/.gitignore new file mode 100644 index 000000000..ab101ab9b --- /dev/null +++ b/signal-processing-api/.gitignore @@ -0,0 +1,5 @@ +venv/ +.env +__pycache__/ +*.pyc +*.db diff --git a/signal-processing-api/Dockerfile b/signal-processing-api/Dockerfile new file mode 100644 index 000000000..a9b91f7b4 --- /dev/null +++ b/signal-processing-api/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 +ENV PIP_NO_CACHE_DIR=1 +ENV PYTHONPATH=/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/signal-processing-api/README.md b/signal-processing-api/README.md new file mode 100644 index 000000000..da07612e3 --- /dev/null +++ b/signal-processing-api/README.md @@ -0,0 +1,478 @@ +# Signal Processing API + +API REST para armazenamento, consulta, remoção e cálculo de métricas de séries temporais. O projeto foi desenvolvido para o desafio de back-end da Dynamox usando FastAPI, SQLAlchemy, PostgreSQL e testes automatizados com pytest. + +## Visão Geral + +A aplicação permite que um usuário envie dados brutos de uma série temporal associada a um dispositivo, consulte os dados armazenados, calcule métricas da série, remova registros e conte quantas séries possuem dados ativos. + +Nesta implementação, cada série temporal é identificada pelo dispositivo que envia os dados. O `Device` representa a origem da série temporal, enquanto os registros de `RawData` representam os pontos da série ao longo do tempo. Dessa forma, um dispositivo com dados armazenados equivale a uma série temporal ativa no servidor. + +Funcionalidades implementadas: + +- Armazenar dados brutos de séries temporais. +- Recuperar métricas de uma série temporal. +- Remover os dados de uma série temporal. +- Consultar a quantidade de séries temporais armazenadas, contando dispositivos com dados ativos. +- Recuperar a série temporal completa de um dispositivo. +- Listar as séries temporais completas agrupadas por dispositivo. +- Listar todos os devices cadastrados, mesmo aqueles sem dados associados. +- Validar payloads inválidos, timestamps sem timezone, timestamps futuros e registros duplicados. + +## Tecnologias + +- Python 3.12 +- FastAPI +- SQLAlchemy +- PostgreSQL +- Docker e Docker Compose +- Pytest + + +## Ambiente de Produção +A Api esta publicada e configurada em um ambiente de produção na Oracle Cloud com alta disponibilidade(Load Balancer Nginx + 3 réplicas de aplicação) e banco de dados PostgreSQL gerenciado. + +- **URL Base da API:** [http://163.176.152.66](http://163.176.152.66) +- **Swagger UI (Documentação Interativa):** [http://163.176.152.66/docs](http://163.176.152.66/docs) + + + +## Como Executar Com Docker + +Clonar repositório + +```bash +git clone +``` +Acessar a pasta do projeto +```bash + +cd developer-challenges\signal-processing-api +``` + +Construir e iniciar containers + +```bash +docker compose up --build +``` + +A API ficará disponível em: + +- API: `http://localhost` +- Swagger: `http://localhost/docs` +- Health check: `http://localhost/` + +Para executar em segundo plano: + +```powershell +docker compose up -d --build +``` + +Para parar os containers sem apagar os dados do banco: + +```powershell +docker compose down +``` + +Para parar os containers e apagar também o volume do PostgreSQL: + +```powershell +docker compose down -v +``` + +> Observação: os dados do PostgreSQL são persistidos no volume Docker `db_data`. Portanto, `docker compose down` mantém os dados, enquanto `docker compose down -v` remove o volume e reinicia o banco vazio. + +## Como Executar Localmente + +1. Crie e ative um ambiente virtual: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +``` + +2. Instale as dependências: + +```powershell +pip install -r requirements.txt +``` + +3. Configure a variável de ambiente `DATABASE_URL`: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timeseries_db" +``` + +4. Inicie a aplicação: + +```powershell +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +A API ficará disponível em: + +- API: `http://localhost:8000` +- Swagger: `http://localhost:8000/docs` +- Health check: `http://localhost:8000/` + +## Como Rodar os Testes + +Localmente: + +```powershell +python -m pytest +``` + +Com Docker: + +```powershell +docker compose run --rm api1 python -m pytest +``` + +Testes de performance/latência: + +```powershell +python -m pytest -m performance +``` + +Esses testes medem os principais endpoints contra o limite de 350ms definido no desafio. + +## 🛠️ Como Testar a API + +Você pode testar os endpoints e as regras de validação (como bloqueio de registros duplicados ou com datas futuras) de duas formas: através da documentação interativa ou por clientes HTTP externos. + +### Definição da URL Base +Escolha o endereço de acordo com o ambiente que deseja testar: +- **Ambiente de Produção (Live):** `http://163.176.152.66` +- **Ambiente Local (Docker):** `http://localhost` (ou `http://localhost:8000` se acessar a API diretamente sem o Nginx) + +--- + +### 1. Pelo Swagger UI (Direto no Navegador) +A documentação interativa do FastAPI permite executar testes rápidos sem instalar nada: +1. Acesse o Swagger adicionando `/docs` ao final da sua URL Base escolhida: + - Produção: [http://163.176.152.66/docs](http://163.176.152.66/docs) + - Local: `http://localhost/docs` +2. Clique no endpoint desejado (ex: `POST /api/v1/signals`). +3. Clique no botão **"Try it out"**. +4. Insira o JSON de teste no campo de texto e clique em **"Execute"**. +5. O retorno do servidor e o status HTTP (ex: `201`, `400`) serão exibidos na tela. + +### 2. Por Ferramentas Externas (Postman / Insomnia) +Para criar coleções de testes automatizados ou monitorar o desempenho de requisições mais robustas: +1. Crie uma nova requisição configurando o método correspondente (`POST`, `GET`, `DELETE`). +2. Monte o endereço utilizando o formato: `URL_BASE/nome-da-rota` + - *Exemplo de POST em produção:* `http://163.176.152.66/raw_data` + - *Exemplo de POST local:* `http://localhost/raw_data` + + + +## Modelo de Dados + +A aplicação possui duas entidades principais: + +- `Device`: representa o identificador e a origem da série temporal. +- `RawData`: representa cada ponto enviado por esse dispositivo, contendo `timestamp` e `value`. + +Na prática, a série temporal é formada pelo conjunto de registros `RawData` associados a um mesmo `Device`. + +Regras importantes: + +- `serial_device` é normalizado com `strip()` e `upper()`. +- Um dispositivo é criado automaticamente quando recebe sua primeira série, mesmo todos os dados sendo rejeitados por serem duplicados ou inválidos. Isso garante que o histórico de dispositivos seja mantido mesmo sem dados associados. Não será criado o dispositivo se o payload for completamente inválido (ex: `serial_device` vazio ou `data` vazio), pois a validação do Pydantic bloqueia a requisição inteira nesses casos. + +- Não é permitido inserir dois dados com o mesmo `timestamp` para o mesmo dispositivo. +- Timestamps futuros são rejeitados. +- Timestamps sem timezone são rejeitados. +- Payloads com `serial_device` vazio ou lista `data` vazia são rejeitados. + +## Endpoints + +### Health Check + +```http +GET / +``` + +Resposta: + +```json +{ + "status": "ok" +} +``` + +### Criar Dados Brutos + +```http +POST /raw_data +``` + +Exemplo de payload: + +```json +{ + "serial_device": "DEV-123", + "data": [ + { + "timestamp": "2026-05-18T22:00:00Z", + "value": 23.5 + }, + { + "timestamp": "2026-05-18T22:01:00Z", + "value": 24.0 + } + ] +} +``` + +Exemplo de resposta: + +```json +{ + "device_id": 1, + "serial_device": "DEV-123", + "inserted": 2, + "rejected": 0, + "details": [] +} +``` + +Quando o payload contém registros duplicados, futuros ou já existentes, a API mantém os registros válidos e retorna os rejeitados em `details`. +**Nota:** Se o payload contiver erros de formato (como um texto no campo `value` ou um `timestamp` sem fuso horário), a validação inicial do Pydantic irá bloquear a requisição inteira. + + +### Buscar Métricas de Uma Série + +```http +GET /raw_data/{device_id}/metrics +``` + +Exemplo de resposta: + +```json +{ + "device_id": 1, + "metrics": { + "total_records": 2, + "average_value": 23.75, + "max": { + "value": 24.0, + "timestamp": "2026-05-18T22:01:00" + }, + "min": { + "value": 23.5, + "timestamp": "2026-05-18T22:00:00" + } + }, + "period": { + "start_time": "2026-05-18T22:00:00", + "end_time": "2026-05-18T22:01:00" + } +} +``` + +### Remover Dados de Um Dispositivo + +```http +DELETE /raw_data/{device_id} +``` + +Exemplo de resposta: + +```json +{ + "success": true, + "deleted_records": 2 +} +``` + +Nesta implementação, a remoção é feita por dispositivo, ou seja, ao chamar esse endpoint, todos os registros associados ao `device_id` especificado serão removidos do banco de dados. Matnendo o device cadastrado, mas sem registros associados, o que é útil para manter o histórico de dispositivos mesmo após a remoção dos dados. + +### Contar Séries Temporais Armazenadas + +```http +GET /devices/count/active +``` + +Nesta implementação, uma série temporal é identificada pelo dispositivo que envia os dados. Por isso, este endpoint conta os dispositivos que possuem ao menos um registro em `RawData`, retornando a quantidade de séries temporais ativas no servidor. + +Exemplo de resposta: + +```json +{ + "active_devices_count": 1 +} +``` + +### Buscar Série Temporal de Um Dispositivo + +```http +GET /devices/{device_id}/raw-data +``` + +Este endpoint aceita paginação opcional por query params: + +- `limit`: quantidade máxima de pontos retornados. Valor mínimo: `1`. Valor máximo: `1000`. +- `offset`: quantidade de pontos ignorados antes de iniciar o retorno. Valor mínimo: `0`. + +Exemplo buscando os primeiros 100 pontos da série temporal: + +```http +GET /devices/1/raw-data?limit=100&offset=0 +``` + +Exemplo buscando os próximos 100 pontos: + +```http +GET /devices/1/raw-data?limit=100&offset=100 +``` + +Nesse caso, `offset=100` significa que a API ignora os 100 primeiros pontos da série e retorna a próxima página de resultados. + +Exemplo de resposta: + +```json +[ + { + "timestamp": "2026-05-18T22:00:00", + "value": 23.5 + }, + { + "timestamp": "2026-05-18T22:01:00", + "value": 24.0 + } +] +``` + +### Listar Todas as Séries Temporais + +```http +GET /raw_data/full_time_series +``` + +Este endpoint também aceita paginação opcional: + +- `limit`: quantidade máxima de pontos de dados retornados. Valor mínimo: `1`. Valor máximo: `1000`. +- `offset`: quantidade de pontos ignorados antes de iniciar o retorno. Valor mínimo: `0`. + +Exemplo: + +```http +GET /raw_data/full_time_series?limit=100&offset=0 +``` + +A paginação é aplicada sobre os pontos de dados antes do agrupamento por dispositivo. Por isso, uma mesma série temporal pode aparecer em páginas diferentes quando houver muitos registros. + +Exemplo de resposta: + +```json +[ + { + "device_id": 1, + "series_data": [ + { + "id": 1, + "timestamp": "2026-05-18T22:00:00", + "value": 23.5 + } + ] + } +] +``` + +### Listar Todas os Devices cadastrados +```http +GET /devices +``` +Lista todos os devices cadastrados, mesmo aqueles sem dados associados (ou seja, sem séries temporais ativas). Isso é útil para manter um histórico completo dos dispositivos que já interagiram com a API, mesmo que seus dados tenham sido removidos posteriormente. +```json +[ + { + "id": 4, + "name": "device-DEV-TEST-100", + "serial_device": "DEV-TEST-100", + "created_at": "2026-05-20T17:48:27.969195Z" + }, + { + "id": 3, + "name": "device-DEV-TEST-50", + "serial_device": "DEV-TEST-50", + "created_at": "2026-05-20T17:42:57.775106Z" + } +] +``` + +## Padrão de Erros + +Erros de domínio, como dispositivo inexistente, seguem o formato: + +```json +{ + "success": false, + "error": "Device not found", + "error_code": "NotFoundException" +} +``` + +Erros de validação seguem o formato: + +```json +{ + "success": false, + "error": "Invalid request payload", + "error_code": "ValidationError", + "details": [] +} +``` + +## Persistência + +O banco principal da aplicação é PostgreSQL. Ao executar com Docker Compose, o serviço `db` usa um volume nomeado chamado `db_data`, garantindo que os dados continuem disponíveis após reiniciar os containers. + +Arquivos gerados localmente, como bancos SQLite de teste e arquivos `.pyc`, não devem ser versionados. + +## Observações Técnicas + +- A criação das tabelas é feita automaticamente na inicialização da aplicação com SQLAlchemy. +- A camada de service concentra as regras de negócio. +- A camada de repossitory concentra as consultas e operações de banco. +- A camada routes define os endpoints e a validação de entrada. +- Os testes cobrem os principais fluxos de criação, consulta, remoção, métricas, validação e contagem de séries. +- O Nginx é configurado como load balancer para distribuir requisições entre múltiplas instâncias da API, simulando um ambiente de produção escalável. +- O endpoint `/instance` permite verificar qual instância da API processou a requisição, facilitando a observação do balanceamento de carga. +- O projeto é estruturado para ser facilmente extensível, permitindo a adição de novas funcionalidades ou endpoints sem impactar a organização atual. +- As credencias do banco de dados foram mantidadas no docker compose para facilitar a execução local, mas em um ambiente de produção real, recomenda-se o uso de variáveis de ambiente ou serviços de gerenciamento de segredos para proteger essas informações sensíveis. + + +## Status dos Testes + +Resultado esperado: + +```text +36 passed +``` + +## Load Balancer (Nginx) +Foi adicionado um Nginx como reverse proxy e load balancer, responsável por distribuir as requisições entre múltiplas instâncias da API (api1, api2 e api3), utilizando estratégia de balanceamento round-robin. + +Essa abordagem simula um ambiente de escalabilidade horizontal, onde múltiplas réplicas da aplicação recebem tráfego de forma distribuída. + +Rota de teste de instância + +Para validar o comportamento do load balancer, foi criada uma rota auxiliar de debug: + +```http +GET /instance +``` + +Essa rota retorna informações da instância da API que processou a requisição, permitindo observar a distribuição de carga entre os containers. + +Exemplo de resposta: + +```json +{ + "instance": "api1", + "hostname": "608b869670fd" +} +``` \ No newline at end of file diff --git a/signal-processing-api/app/__init__.py b/signal-processing-api/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/signal-processing-api/app/core/__pycache__/config.cpython-311.pyc b/signal-processing-api/app/core/__pycache__/config.cpython-311.pyc new file mode 100644 index 000000000..cf04ec489 Binary files /dev/null and b/signal-processing-api/app/core/__pycache__/config.cpython-311.pyc differ diff --git a/signal-processing-api/app/core/__pycache__/database.cpython-311.pyc b/signal-processing-api/app/core/__pycache__/database.cpython-311.pyc new file mode 100644 index 000000000..2593626c0 Binary files /dev/null and b/signal-processing-api/app/core/__pycache__/database.cpython-311.pyc differ diff --git a/signal-processing-api/app/core/config.py b/signal-processing-api/app/core/config.py new file mode 100644 index 000000000..5a87b096a --- /dev/null +++ b/signal-processing-api/app/core/config.py @@ -0,0 +1,12 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +if not DATABASE_URL: + raise RuntimeError( + "DATABASE_URL environment variable is required. " + "Example: postgresql://postgres:postgres@localhost:5432/timeseries_db" + ) diff --git a/signal-processing-api/app/core/database.py b/signal-processing-api/app/core/database.py new file mode 100644 index 000000000..2d449e10e --- /dev/null +++ b/signal-processing-api/app/core/database.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from app.core.config import DATABASE_URL + + + +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/signal-processing-api/app/core/db_init.py b/signal-processing-api/app/core/db_init.py new file mode 100644 index 000000000..277f2a901 --- /dev/null +++ b/signal-processing-api/app/core/db_init.py @@ -0,0 +1,11 @@ +from app.core.database import engine, Base +from app.models.device import Device +from app.models.raw_data import RawData + + +def init_db(): + Base.metadata.create_all(bind=engine) + print("Tabelas criadas com sucesso!") + +if __name__ == "__main__": + init_db() \ No newline at end of file diff --git a/signal-processing-api/app/core/exception_handlers.py b/signal-processing-api/app/core/exception_handlers.py new file mode 100644 index 000000000..fce2f862f --- /dev/null +++ b/signal-processing-api/app/core/exception_handlers.py @@ -0,0 +1,40 @@ +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException +from app.core.exceptions import DomainException + + +def domain_exception_handler(request: Request, exc: DomainException): + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": exc.message, + "error_code": exc.__class__.__name__ + } + ) + + +def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": "Invalid request payload", + "error_code": "ValidationError", + "details": jsonable_encoder(exc.errors()) + } + ) + + +def http_exception_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": exc.detail, + "error_code": "HttpError" + } + ) diff --git a/signal-processing-api/app/core/exceptions.py b/signal-processing-api/app/core/exceptions.py new file mode 100644 index 000000000..2f2ba0b4a --- /dev/null +++ b/signal-processing-api/app/core/exceptions.py @@ -0,0 +1,28 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session +from datetime import datetime, timezone + +class DomainException(Exception): + def __init__(self, message: str, status_code: int = 400): + super().__init__(message) + self.message = message + self.status_code = status_code + +class ValidationException(DomainException): + def __init__(self, message: str): + super().__init__(message, 400) + + +class NotFoundException(DomainException): + def __init__(self, message: str): + super().__init__(message, 404) + + +class DuplicateDataException(DomainException): + def __init__(self, message: str): + super().__init__(message, 409) + + +class FutureTimestampException(DomainException): + def __init__(self, message: str): + super().__init__(message, 400) diff --git a/signal-processing-api/app/main.py b/signal-processing-api/app/main.py new file mode 100644 index 000000000..7f10e5c71 --- /dev/null +++ b/signal-processing-api/app/main.py @@ -0,0 +1,52 @@ +import socket +import os + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from app.core.database import get_db, Base, engine + + +from app.models.device import Device +from app.models.raw_data import RawData +from app.routes.raw_data_route import router as raw_data_router +from app.routes.device_route import router as device_router +from app.core.exceptions import DomainException +from app.core.exception_handlers import ( + domain_exception_handler, + http_exception_handler, + validation_exception_handler, +) + + +app = FastAPI( + title="Signal Processing API", + version="1.0.0" +) + +app.add_exception_handler(DomainException, domain_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(StarletteHTTPException, http_exception_handler) + + +app.include_router(raw_data_router) +app.include_router(device_router) + + +INSTANCE_ID = os.getenv("INSTANCE_ID", socket.gethostname()) + +@app.get("/") +def health_check(): + return {"status": "ok"} + +import socket +import os + +@app.get("/instance") +def instance(): + return { + "instance": os.getenv("INSTANCE_NAME"), + "hostname": socket.gethostname() + } + + diff --git a/signal-processing-api/app/models/__pycache__/device.cpython-311.pyc b/signal-processing-api/app/models/__pycache__/device.cpython-311.pyc new file mode 100644 index 000000000..c921733ce Binary files /dev/null and b/signal-processing-api/app/models/__pycache__/device.cpython-311.pyc differ diff --git a/signal-processing-api/app/models/__pycache__/raw_data.cpython-311.pyc b/signal-processing-api/app/models/__pycache__/raw_data.cpython-311.pyc new file mode 100644 index 000000000..637ef8928 Binary files /dev/null and b/signal-processing-api/app/models/__pycache__/raw_data.cpython-311.pyc differ diff --git a/signal-processing-api/app/models/device.py b/signal-processing-api/app/models/device.py new file mode 100644 index 000000000..54869b9b7 --- /dev/null +++ b/signal-processing-api/app/models/device.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, DateTime, func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class Device(Base): + __tablename__ = "devices" + + + id = Column(Integer, primary_key=True, autoincrement=True) + + + name = Column(String, unique=True, index=True, nullable=False) + + + serial_device = Column(String, unique=True, index=True, nullable=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + + raw_data = relationship( + "RawData", + back_populates="device", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/signal-processing-api/app/models/raw_data.py b/signal-processing-api/app/models/raw_data.py new file mode 100644 index 000000000..de749e7a7 --- /dev/null +++ b/signal-processing-api/app/models/raw_data.py @@ -0,0 +1,41 @@ + + + +from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Index, func, String, UniqueConstraint +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class RawData(Base): + __tablename__ = "raw_data" + + + id = Column(Integer, primary_key=True, autoincrement=True) + + device_id = Column( + Integer, + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False + + ) + + + timestamp = Column(DateTime(timezone=True), nullable=False, index=True) + + + value = Column(Float(precision=53), nullable=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + + device = relationship( + "Device", + back_populates="raw_data" + ) + + + __table_args__ = ( + UniqueConstraint("device_id", "timestamp"), + ) + + \ No newline at end of file diff --git a/signal-processing-api/app/repository/device_repository.py b/signal-processing-api/app/repository/device_repository.py new file mode 100644 index 000000000..f749d9159 --- /dev/null +++ b/signal-processing-api/app/repository/device_repository.py @@ -0,0 +1,42 @@ + +from sqlalchemy.orm import relationship +from datetime import datetime +from app.core.database import Base +from sqlalchemy.orm import Session +from app.models.device import Device + + +class DeviceRepository: + def __init__(self, db: Session): + self.db = db + + def get_by_deviceid(self, device_id: int): + return self.db.query(Device).filter( + Device.id == device_id + ).first() + + def get_by_serial_device(self, serial_device: str): + return self.db.query(Device).filter( + Device.serial_device == serial_device + ).first() + + def get_all(self): + return ( + self.db.query(Device) + .order_by(Device.created_at.desc()) + .all() + ) + + + def create(self, name: str, serial_device: str, created_at: datetime): + device = Device( + name=name, + serial_device=serial_device, + created_at=created_at + ) + + self.db.add(device) + self.db.commit() + self.db.refresh(device) + + return device \ No newline at end of file diff --git a/signal-processing-api/app/repository/raw_data_repository.py b/signal-processing-api/app/repository/raw_data_repository.py new file mode 100644 index 000000000..eeb9f139c --- /dev/null +++ b/signal-processing-api/app/repository/raw_data_repository.py @@ -0,0 +1,130 @@ +from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Index, func +from sqlalchemy.orm import relationship +from app.core.database import Base +from sqlalchemy.orm import Session +from app.models.raw_data import RawData + + + +class RawDataRepository: + def __init__(self, db: Session): + self.db = db + + def exists(self, device_id: int, timestamp): + return self.db.query(RawData).filter( + RawData.device_id == device_id, + RawData.timestamp == timestamp + ).first() + + + def get_existing_timestamps(self, device_id: int, timestamps: list): + return ( + self.db.query(RawData.timestamp) + .filter( + RawData.device_id == device_id, + RawData.timestamp.in_(timestamps) + ) + .all() + ) + + def get_by_device_id(self, device_id: int, limit: int | None = None, offset: int = 0): + query = ( + self.db.query(RawData) + .filter(RawData.device_id == device_id) + .order_by(RawData.timestamp.asc()) + ) + + if offset: + query = query.offset(offset) + + if limit is not None: + query = query.limit(limit) + + return query.all() + + def delete_by_device_id(self, device_id: int): + deleted = self.db.query(RawData).filter( + RawData.device_id == device_id + ).delete() + + self.db.commit() + return deleted + + def count_devices_with_data(self): + return self.db.query(func.count(func.distinct(RawData.device_id))).scalar() + + + def get_all_devices_with_data(self, limit: int | None = None, offset: int = 0): + query = ( + self.db.query(RawData) + .order_by(RawData.device_id.asc(), RawData.timestamp.asc()) + ) + + if offset: + query = query.offset(offset) + + if limit is not None: + query = query.limit(limit) + + return query.all() + + + + def bulk_create(self, instances_list): + try: + if instances_list and isinstance(instances_list[0], dict): + self.db.bulk_insert_mappings(RawData, instances_list) + else: + self.db.bulk_save_objects(instances_list) + + self.db.commit() + + + return {"status": "success", "count": len(instances_list)} + + except Exception as e: + self.db.rollback() + raise e + + + + + def get_time_series_stats(self, device_id: int): + + general_stats = self.db.query( + func.avg(RawData.value).label("avg_value"), + func.count(RawData.id).label("total_records"), + func.min(RawData.timestamp).label("first_timestamp"), + func.max(RawData.timestamp).label("last_timestamp") + ).filter(RawData.device_id == device_id).first() + + if not general_stats or general_stats.total_records == 0: + return None + + + max_record = ( + self.db.query(RawData.value, RawData.timestamp) + .filter(RawData.device_id == device_id) + .order_by(RawData.value.desc(), RawData.timestamp.asc()) + .first() + ) + + + min_record = ( + self.db.query(RawData.value, RawData.timestamp) + .filter(RawData.device_id == device_id) + .order_by(RawData.value.asc(), RawData.timestamp.asc()) + .first() + ) + + return { + "general": general_stats, + "max_record": max_record, + "min_record": min_record + } + + + + + + diff --git a/signal-processing-api/app/routes/device_route.py b/signal-processing-api/app/routes/device_route.py new file mode 100644 index 000000000..43abd795f --- /dev/null +++ b/signal-processing-api/app/routes/device_route.py @@ -0,0 +1,41 @@ +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.schemas.device import DeviceResponse +from app.service.device_service import DeviceService +from app.schemas.raw_data import RawDataPointResponse + + +router = APIRouter( + prefix="/devices", + tags=["Devices"] +) + + +@router.get( + "", + response_model=List[DeviceResponse] +) +def get_all_devices(db: Session = Depends(get_db)): + service = DeviceService(db) + return service.get_all_devices() + + +@router.get( + "/{device_id}/raw-data", + response_model=list[RawDataPointResponse] +) +def get_device_raw_data( + device_id: int, + limit: int | None = Query(default=None, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db) +): + service = DeviceService(db) + return service.get_device_raw_data( + device_id=device_id, + limit=limit, + offset=offset + ) diff --git a/signal-processing-api/app/routes/raw_data_route.py b/signal-processing-api/app/routes/raw_data_route.py new file mode 100644 index 000000000..778d36c8c --- /dev/null +++ b/signal-processing-api/app/routes/raw_data_route.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.service.raw_data_service import RawDataService +from app.schemas.raw_data import ( + ActiveDevicesCountResponse, + DeleteRawDataResponse, + FullTimeSeriesResponse, + RawDataCreate, + RawDataCreateResponse, + RawDataMetricsResponse, +) + +router = APIRouter() + + +@router.post("/raw_data", response_model=RawDataCreateResponse) +def create_raw_data( + payload: RawDataCreate, + db: Session = Depends(get_db) +): + service = RawDataService(db) + return service.create_raw_data(payload) + + +@router.get( + "/raw_data/full_time_series", + response_model=list[FullTimeSeriesResponse] +) +def get_full_time_series( + limit: int | None = Query(default=None, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db) +): + service = RawDataService(db) + return service.get_full_time_series( + limit=limit, + offset=offset + ) + + +@router.get( + "/devices/count/active", + response_model=ActiveDevicesCountResponse +) +def get_active_devices_count( + db: Session = Depends(get_db) +): + service = RawDataService(db) + return service.get_active_devices_count() + + +@router.delete( + "/raw_data/{device_id}", + response_model=DeleteRawDataResponse +) +def delete_by_device( + device_id: int, + db: Session = Depends(get_db) +): + service = RawDataService(db) + return service.delete_by_device(device_id) + +@router.get( + "/raw_data/{device_id}/metrics", + response_model=RawDataMetricsResponse +) +def get_device_metrics( + device_id: int, + db: Session = Depends(get_db) +): + service = RawDataService(db) + return service.get_device_metrics(device_id) diff --git a/signal-processing-api/app/schemas/device.py b/signal-processing-api/app/schemas/device.py new file mode 100644 index 000000000..d73345479 --- /dev/null +++ b/signal-processing-api/app/schemas/device.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel,ConfigDict +from datetime import datetime + + +class DeviceCreate(BaseModel): + name: str + serial_device: str + + +class DeviceResponse(BaseModel): + id: int + name: str + serial_device: str + created_at: datetime + model_config = ConfigDict(from_attributes=True ) + + \ No newline at end of file diff --git a/signal-processing-api/app/schemas/raw_data.py b/signal-processing-api/app/schemas/raw_data.py new file mode 100644 index 000000000..129f62668 --- /dev/null +++ b/signal-processing-api/app/schemas/raw_data.py @@ -0,0 +1,106 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class RawDataItem(BaseModel): + timestamp: datetime + value: float + + +class RawDataPointResponse(BaseModel): + timestamp: datetime + value: float + + +class RawDataInput(RawDataItem): + @field_validator("timestamp") + @classmethod + def timestamp_must_have_timezone(cls, value: datetime): + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamp must include timezone") + return value + + +class RawDataCreate(BaseModel): + serial_device: str = Field(..., min_length=1) + data: list[RawDataInput] = Field(..., min_length=1) + + @field_validator("serial_device") + @classmethod + def serial_device_must_not_be_blank(cls, value: str): + value = value.strip() + if not value: + raise ValueError("serial_device must not be blank") + return value + + +class RejectedRawDataItem(BaseModel): + timestamp: datetime + reason: str + + +class RawDataCreateResponse(BaseModel): + device_id: int + serial_device: str + inserted: int + rejected: int + details: list[RejectedRawDataItem] + + +class MetricValuePoint(BaseModel): + value: float | None + timestamp: datetime | None + + +class RawDataMetricsSummary(BaseModel): + total_records: int + average_value: float + max: MetricValuePoint + min: MetricValuePoint + + +class RawDataPeriod(BaseModel): + start_time: datetime + end_time: datetime + + +class RawDataMetricsResponse(BaseModel): + device_id: int + metrics: RawDataMetricsSummary + period: RawDataPeriod + + +class DeleteRawDataResponse(BaseModel): + success: bool + deleted_records: int + + +class ActiveDevicesCountResponse(BaseModel): + active_devices_count: int + + +class FullTimeSeriesItem(BaseModel): + id: int + timestamp: datetime + value: float + + +class FullTimeSeriesResponse(BaseModel): + device_id: int + series_data: list[FullTimeSeriesItem] + + +class DeviceDataResponse(BaseModel): + device_id: int + data: list[RawDataItem] + + +class RawDataResponse(BaseModel): + id: int + device_id: int + timestamp: datetime + value: float + created_at: datetime + model_config = ConfigDict(from_attributes=True ) + diff --git a/signal-processing-api/app/service/device_service.py b/signal-processing-api/app/service/device_service.py new file mode 100644 index 000000000..a465e2119 --- /dev/null +++ b/signal-processing-api/app/service/device_service.py @@ -0,0 +1,33 @@ +from sqlalchemy.orm import Session +from app.core.exceptions import NotFoundException +from app.repository.device_repository import DeviceRepository +from app.repository.raw_data_repository import RawDataRepository + + + + +class DeviceService: + + def __init__(self, db): + self.device_repo = DeviceRepository(db) + self.raw_repo = RawDataRepository(db) + + def get_all_devices(self): + return self.device_repo.get_all() + + + def get_device_raw_data(self, device_id: int, limit: int | None = None, offset: int = 0): + device = self.device_repo.get_by_deviceid(device_id) + + if not device: + raise NotFoundException("device not found") + + return self.raw_repo.get_by_device_id( + device_id=device_id, + limit=limit, + offset=offset + ) + + + + diff --git a/signal-processing-api/app/service/raw_data_service.py b/signal-processing-api/app/service/raw_data_service.py new file mode 100644 index 000000000..2bd17ec13 --- /dev/null +++ b/signal-processing-api/app/service/raw_data_service.py @@ -0,0 +1,175 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import Session +from app.repository.device_repository import DeviceRepository +from app.repository.raw_data_repository import RawDataRepository +from app.schemas.raw_data import RawDataCreate +from app.core.exceptions import NotFoundException + +from itertools import groupby + + +class RawDataService: + + def __init__(self, db): + self.device_repo = DeviceRepository(db) + self.raw_repo = RawDataRepository(db) + + def create_raw_data(self, payload: RawDataCreate): + + now = datetime.now(timezone.utc) + + # NORMALIZAÇÃO DO SERIAL + serial = payload.serial_device.strip().upper() + + # BUSCA OU CRIA DEVICE + device = self.device_repo.get_by_serial_device(serial) + + if not device: + device = self.device_repo.create( + name=f"device-{serial}", + serial_device=serial, + created_at=now + ) + + # BUSCA EM LOTE O QUE JÁ EXISTE NO BANCO + timestamps = [item.timestamp for item in payload.data] + + existing = self.raw_repo.get_existing_timestamps( + device_id=device.id, + timestamps=timestamps + ) + + existing_set = {t[0] for t in existing} + + # PROCESSAMENTO DO BATCH + seen = set() + inserts = [] + rejected = [] + + for item in payload.data: + + # valida timestamp futuro + if item.timestamp > now: + rejected.append({ + "timestamp": item.timestamp, + "reason": "future_timestamp" + }) + continue + + # já existeno próprio payload + if item.timestamp in seen: + rejected.append({ + "timestamp": item.timestamp, + "reason": "duplicate_in_payload" + }) + continue + + # já existe no banco + if item.timestamp in existing_set: + rejected.append({ + "timestamp": item.timestamp, + "reason": "already_exists" + }) + continue + + seen.add(item.timestamp) + + inserts.append({ + "device_id": device.id, + "timestamp": item.timestamp, + "value": item.value + }) + + # INSERÇÃO EM LOTE + if inserts: + self.raw_repo.bulk_create(inserts) + + # RESPONSE FINAL + return { + "device_id": device.id, + "serial_device": serial, + "inserted": len(inserts), + "rejected": len(rejected), + "details": rejected + } + + + def get_full_time_series(self, limit: int | None = None, offset: int = 0): + # Busca todos os dados brutos ordenados por dispositivo + all_data = self.raw_repo.get_all_devices_with_data( + limit=limit, + offset=offset + ) + + result = [] + + # Agrupa os dados dinamicamente pelo ID do dispositivo + for device_id, group in groupby(all_data, key=lambda x: x.device_id): + series_entries = [] + + for item in group: + series_entries.append({ + "id": item.id, + "timestamp": item.timestamp, + "value": item.value + }) + + # Monta o objeto do dispositivo com a sua respectiva série temporal + result.append({ + "device_id": device_id, + "series_data": series_entries + }) + + return result + + + def get_active_devices_count(self): + devices_count = self.raw_repo.count_devices_with_data() + return {"active_devices_count": devices_count} + + + def delete_by_device(self, device_id: int): + + device = self.device_repo.get_by_deviceid(device_id) + + if not device: + raise NotFoundException("Device not found") + + deleted_count = self.raw_repo.delete_by_device_id(device_id) + + return { + "success": True, + "deleted_records": deleted_count + } + + + def get_device_metrics(self, device_id: int): + stats = self.raw_repo.get_time_series_stats(device_id) + + if not stats: + raise NotFoundException("Nenhum dado de série temporal encontrado para este dispositivo.") + + general = stats["general"] + max_rec = stats["max_record"] + min_rec = stats["min_record"] + + return { + "device_id": device_id, + "metrics": { + "total_records": general.total_records, + "average_value": round(general.avg_value, 2) if general.avg_value else 0.0, + "max": { + "value": max_rec.value if max_rec else None, + "timestamp": max_rec.timestamp if max_rec else None + }, + "min": { + "value": min_rec.value if min_rec else None, + "timestamp": min_rec.timestamp if min_rec else None + } + }, + "period": { + "start_time": general.first_timestamp, + "end_time": general.last_timestamp + } + } diff --git a/signal-processing-api/app/tests/integration/conftest.py b/signal-processing-api/app/tests/integration/conftest.py new file mode 100644 index 000000000..3de14489a --- /dev/null +++ b/signal-processing-api/app/tests/integration/conftest.py @@ -0,0 +1,41 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.main import app +from app.core.database import Base, get_db + +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture +def client(): + # limpa tudo antes de cada teste + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + + with TestClient(app) as c: + yield c \ No newline at end of file diff --git a/signal-processing-api/app/tests/integration/test_endpoints.py b/signal-processing-api/app/tests/integration/test_endpoints.py new file mode 100644 index 000000000..2a185993a --- /dev/null +++ b/signal-processing-api/app/tests/integration/test_endpoints.py @@ -0,0 +1,408 @@ +def test_bulk_insert_success_clean_data(client): + """Deve aceitar e processar lote totalmente válido.""" + payload = { + "serial_device": "DEV-TEST-OK", + "data": [ + {"timestamp": "2026-05-18T22:00:00Z", "value": 23.5}, + {"timestamp": "2026-05-18T22:01:00Z", "value": 24.0} + ] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 200 + + body = response.json() + + assert body["inserted"] == 2 + assert body["rejected"] == 0 + + +def test_not_found_route_uses_standard_error_format(client): + response = client.get("/not-found-route") + + assert response.status_code == 404 + + body = response.json() + assert body["success"] is False + assert body["error"] == "Not Found" + assert body["error_code"] == "HttpError" + + +def test_method_not_allowed_uses_standard_error_format(client): + response = client.post("/devices/1/raw-data") + + assert response.status_code == 405 + + body = response.json() + assert body["success"] is False + assert body["error"] == "Method Not Allowed" + assert body["error_code"] == "HttpError" + + +def test_bulk_insert_should_reject_future_date(client): + """Deve rejeitar registros com timestamp futuro, mas manter resposta 200.""" + payload = { + "serial_device": "DEV-TEST-FUTURE", + "data": [ + {"timestamp": "2028-12-25T00:00:00Z", "value": 99.9} + ] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 200 + + body = response.json() + + assert body["inserted"] == 0 + assert body["rejected"] == 1 + assert body["details"][0]["reason"] == "future_timestamp" + + +def test_bulk_insert_rejects_empty_data_payload(client): + payload = { + "serial_device": "DEV-EMPTY", + "data": [] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 422 + + body = response.json() + assert body["success"] is False + assert body["error_code"] == "ValidationError" + + +def test_bulk_insert_rejects_blank_serial_device(client): + payload = { + "serial_device": " ", + "data": [ + {"timestamp": "2026-05-18T22:00:00Z", "value": 23.5} + ] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 422 + + body = response.json() + assert body["success"] is False + assert body["error_code"] == "ValidationError" + + +def test_bulk_insert_rejects_timestamp_without_timezone(client): + payload = { + "serial_device": "DEV-NO-TZ", + "data": [ + {"timestamp": "2026-05-18T22:00:00", "value": 23.5} + ] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 422 + + body = response.json() + assert body["success"] is False + assert body["error_code"] == "ValidationError" + + +def test_bulk_insert_handle_duplicate_timestamps(client): + """Deve rejeitar timestamp duplicado no mesmo payload.""" + payload = { + "serial_device": "DEV-TEST-DUP", + "data": [ + {"timestamp": "2026-05-18T22:00:00Z", "value": 20.0}, + {"timestamp": "2026-05-18T22:00:00Z", "value": 25.5} + ] + } + + response = client.post("/raw_data", json=payload) + + assert response.status_code == 200 + + body = response.json() + + assert body["inserted"] == 1 + assert body["rejected"] == 1 + assert body["details"][0]["reason"] == "duplicate_in_payload" + + +def test_get_metrics_calculates_correctly(client): + """Deve calcular min, max e média corretamente.""" + + payload = { + "serial_device": "DEV-MATH-01", + "data": [ + {"timestamp": "2026-05-18T20:00:00Z", "value": 10.0}, + {"timestamp": "2026-05-18T21:00:00Z", "value": 30.0} + ] + } + + # insere dados + client.post("/raw_data", json=payload) + + # busca métricas + response = client.get("/raw_data/1/metrics") + + assert response.status_code == 200 + + body = response.json() + metrics = body["metrics"] + + assert metrics["min"]["value"] == 10.0 + assert metrics["max"]["value"] == 30.0 + assert metrics["average_value"] == 20.0 + assert metrics["total_records"] == 2 + + + +def test_get_device_raw_data_success(client): + response_post = client.post("/raw_data", json={ + "serial_device": "DEV-RAW", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10} + ] + }) + + device_id = response_post.json()["device_id"] + + response = client.get(f"/devices/{device_id}/raw-data") + + assert response.status_code == 200 + + body = response.json() + assert isinstance(body, list) + assert len(body) > 0 + + +def test_get_device_raw_data_with_limit_and_offset(client): + response_post = client.post("/raw_data", json={ + "serial_device": "DEV-PAGINATION", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10}, + {"timestamp": "2026-05-18T11:00:00Z", "value": 20}, + {"timestamp": "2026-05-18T12:00:00Z", "value": 30} + ] + }) + + device_id = response_post.json()["device_id"] + + response = client.get(f"/devices/{device_id}/raw-data?limit=1&offset=1") + + assert response.status_code == 200 + + body = response.json() + assert len(body) == 1 + assert body[0]["value"] == 20.0 + + +def test_get_device_raw_data_rejects_invalid_pagination(client): + response = client.get("/devices/1/raw-data?limit=0&offset=-1") + + assert response.status_code == 422 + + body = response.json() + assert body["success"] is False + assert body["error_code"] == "ValidationError" + + +def test_get_device_raw_data_not_found(client): + response = client.get("/devices/999/raw-data") + + assert response.status_code == 404 + + +def test_delete_by_device_success(client): + # cria device com dado + response_post = client.post("/raw_data", json={ + "serial_device": "DEV-DELETE", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10} + ] + }) + + device_id = response_post.json()["device_id"] + + # garante que existe dado antes do delete + response_before = client.get(f"/devices/{device_id}/raw-data") + assert len(response_before.json()) == 1 + + # faz delete + response_delete = client.delete(f"/raw_data/{device_id}") + + assert response_delete.status_code == 200 + + body = response_delete.json() + assert body["success"] is True + assert body["deleted_records"] == 1 + + # valida que apagou + response_after = client.get(f"/devices/{device_id}/raw-data") + assert response_after.json() == [] + +def test_delete_by_device_not_found(client): + response = client.delete("/raw_data/999") + + assert response.status_code == 404 + + body = response.json() + + assert body["success"] is False + assert body["error"] == "Device not found" + + + +def test_delete_is_idempotent(client): + r = client.post("/raw_data", json={ + "serial_device": "DEV-IDEMPOTENT", + "data": [{"timestamp": "2026-05-18T10:00:00Z", "value": 10}] + }) + + device_id = r.json()["device_id"] + + r1 = client.delete(f"/raw_data/{device_id}") + assert r1.status_code == 200 + assert r1.json()["deleted_records"] == 1 + + r2 = client.delete(f"/raw_data/{device_id}") + assert r2.status_code == 200 + assert r2.json()["deleted_records"] == 0 + +def test_get_active_devices_count(client): + client.post("/raw_data", json={ + "serial_device": "DEV-COUNT-1", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10} + ] + }) + + client.post("/raw_data", json={ + "serial_device": "DEV-COUNT-2", + "data": [ + {"timestamp": "2026-05-18T11:00:00Z", "value": 20} + ] + }) + + response = client.get("/devices/count/active") + + assert response.status_code == 200 + + body = response.json() + + assert "active_devices_count" in body + assert body["active_devices_count"] == 2 + +def test_get_active_devices_count_empty(client): + response = client.get("/devices/count/active") + + assert response.status_code == 200 + + body = response.json() + + assert body["active_devices_count"] == 0 + + +def test_active_devices_count_does_not_duplicate(client): + client.post("/raw_data", json={ + "serial_device": "DEV-SAME", + "data": [{"timestamp": "2026-05-18T10:00:00Z", "value": 10}] + }) + + client.post("/raw_data", json={ + "serial_device": "DEV-SAME", + "data": [{"timestamp": "2026-05-18T11:00:00Z", "value": 20}] + }) + + response = client.get("/devices/count/active") + + body = response.json() + + assert body["active_devices_count"] == 1 + + +def test_full_time_series_grouping(client): + client.post("/raw_data", json={ + "serial_device": "DEV-A", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10}, + {"timestamp": "2026-05-18T11:00:00Z", "value": 20} + ] + }) + + response = client.get("/raw_data/full_time_series") + + assert response.status_code == 200 + + body = response.json() + + assert isinstance(body, list) + assert len(body) >= 1 + + device_series = body[0] + assert "device_id" in device_series + assert "series_data" in device_series + assert len(device_series["series_data"]) == 2 + + +def test_full_time_series_with_limit_and_offset(client): + client.post("/raw_data", json={ + "serial_device": "DEV-FULL-PAGINATION", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10}, + {"timestamp": "2026-05-18T11:00:00Z", "value": 20}, + {"timestamp": "2026-05-18T12:00:00Z", "value": 30} + ] + }) + + response = client.get("/raw_data/full_time_series?limit=1&offset=1") + + assert response.status_code == 200 + + body = response.json() + assert len(body) == 1 + assert len(body[0]["series_data"]) == 1 + assert body[0]["series_data"][0]["value"] == 20.0 + + +def test_full_time_series_rejects_invalid_pagination(client): + response = client.get("/raw_data/full_time_series?limit=0&offset=-1") + + assert response.status_code == 422 + + body = response.json() + assert body["success"] is False + assert body["error_code"] == "ValidationError" + + +def test_full_time_series_structure(client): + client.post("/raw_data", json={ + "serial_device": "DEV-B", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 15} + ] + }) + + response = client.get("/raw_data/full_time_series") + body = response.json() + + item = body[0]["series_data"][0] + + assert "id" in item + assert "timestamp" in item + assert "value" in item + +def test_full_time_series_empty(client): + response = client.get("/raw_data/full_time_series") + + assert response.status_code == 200 + assert response.json() == [] + + + + + + + diff --git a/signal-processing-api/app/tests/performance/conftest.py b/signal-processing-api/app/tests/performance/conftest.py new file mode 100644 index 000000000..b5287d26f --- /dev/null +++ b/signal-processing-api/app/tests/performance/conftest.py @@ -0,0 +1,41 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.database import Base, get_db +from app.main import app + + +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture +def client(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + + with TestClient(app) as c: + yield c diff --git a/signal-processing-api/app/tests/performance/test_latency.py b/signal-processing-api/app/tests/performance/test_latency.py new file mode 100644 index 000000000..734103c6d --- /dev/null +++ b/signal-processing-api/app/tests/performance/test_latency.py @@ -0,0 +1,82 @@ +import time + +import pytest + + +pytestmark = pytest.mark.performance + +MAX_LATENCY_SECONDS = 0.350 + + +def assert_latency_under_350ms(start_time): + elapsed = time.perf_counter() - start_time + assert elapsed < MAX_LATENCY_SECONDS, f"Request took {elapsed:.4f}s" + + +def create_time_series(client, serial_device="DEV-PERF"): + response = client.post("/raw_data", json={ + "serial_device": serial_device, + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10.0}, + {"timestamp": "2026-05-18T10:01:00Z", "value": 20.0}, + {"timestamp": "2026-05-18T10:02:00Z", "value": 30.0} + ] + }) + assert response.status_code == 200 + return response.json()["device_id"] + + +def test_create_raw_data_latency(client): + payload = { + "serial_device": "DEV-PERF-POST", + "data": [ + {"timestamp": "2026-05-18T10:00:00Z", "value": 10.0}, + {"timestamp": "2026-05-18T10:01:00Z", "value": 20.0} + ] + } + + start = time.perf_counter() + response = client.post("/raw_data", json=payload) + assert_latency_under_350ms(start) + + assert response.status_code == 200 + + +def test_get_metrics_latency(client): + device_id = create_time_series(client, "DEV-PERF-METRICS") + + start = time.perf_counter() + response = client.get(f"/raw_data/{device_id}/metrics") + assert_latency_under_350ms(start) + + assert response.status_code == 200 + + +def test_get_device_raw_data_latency(client): + device_id = create_time_series(client, "DEV-PERF-RAW") + + start = time.perf_counter() + response = client.get(f"/devices/{device_id}/raw-data?limit=100&offset=0") + assert_latency_under_350ms(start) + + assert response.status_code == 200 + + +def test_get_active_devices_count_latency(client): + create_time_series(client, "DEV-PERF-COUNT") + + start = time.perf_counter() + response = client.get("/devices/count/active") + assert_latency_under_350ms(start) + + assert response.status_code == 200 + + +def test_delete_raw_data_latency(client): + device_id = create_time_series(client, "DEV-PERF-DELETE") + + start = time.perf_counter() + response = client.delete(f"/raw_data/{device_id}") + assert_latency_under_350ms(start) + + assert response.status_code == 200 diff --git a/signal-processing-api/app/tests/unit/conftest.py b/signal-processing-api/app/tests/unit/conftest.py new file mode 100644 index 000000000..e69de29bb diff --git a/signal-processing-api/app/tests/unit/test_unit.py b/signal-processing-api/app/tests/unit/test_unit.py new file mode 100644 index 000000000..b0704fdae --- /dev/null +++ b/signal-processing-api/app/tests/unit/test_unit.py @@ -0,0 +1,168 @@ +from unittest.mock import MagicMock +from types import SimpleNamespace +from datetime import datetime, timezone +from app.service.raw_data_service import RawDataService +from app.core.exceptions import NotFoundException +import pytest + + +@pytest.mark.unit +def test_delete_by_device_success(): + device_repo = MagicMock() + raw_repo = MagicMock() + + device_repo.get_by_deviceid.return_value = SimpleNamespace(id=1) + raw_repo.delete_by_device_id.return_value = 3 + + service = RawDataService(db=None) + service.device_repo = device_repo + service.raw_repo = raw_repo + + result = service.delete_by_device(1) + + assert result["success"] is True + assert result["deleted_records"] == 3 + + raw_repo.delete_by_device_id.assert_called_once_with(1) + + +@pytest.mark.unit +def test_delete_by_device_not_found(): + device_repo = MagicMock() + raw_repo = MagicMock() + + device_repo.get_by_deviceid.return_value = None + + service = RawDataService(db=None) + service.device_repo = device_repo + service.raw_repo = raw_repo + + with pytest.raises(NotFoundException) as exc: + service.delete_by_device(999) + + assert str(exc.value) == "Device not found" + + +@pytest.mark.unit +def test_get_device_metrics_not_found(): + device_repo = MagicMock() + raw_repo = MagicMock() + + device_repo.get_by_deviceid.return_value = SimpleNamespace(id=1) + raw_repo.get_time_series_stats.return_value = None + + service = RawDataService(db=None) + service.device_repo = device_repo + service.raw_repo = raw_repo + + with pytest.raises(NotFoundException) as exc: + service.get_device_metrics(1) + + assert str(exc.value) == "Nenhum dado de série temporal encontrado para este dispositivo." + + +@pytest.mark.unit +def test_get_device_metrics_success(): + device_repo = MagicMock() + raw_repo = MagicMock() + + device_repo.get_by_deviceid.return_value = SimpleNamespace(id=1) + raw_repo.get_time_series_stats.return_value = { + "general": SimpleNamespace( + total_records=100, + avg_value=20.0, + first_timestamp=datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc), + last_timestamp=datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc) + ), + "max_record": SimpleNamespace( + value=30.0, + timestamp=datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc) + ), + "min_record": SimpleNamespace( + value=10.0, + timestamp=datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc) + ) + } + + service = RawDataService(db=None) + service.device_repo = device_repo + service.raw_repo = raw_repo + + result = service.get_device_metrics(1) + + assert result["metrics"]["min"]["value"] == 10.0 + assert result["metrics"]["max"]["value"] == 30.0 + assert result["metrics"]["average_value"] == 20.0 + assert result["metrics"]["total_records"] == 100 + assert result["period"]["start_time"] == datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc) + assert result["period"]["end_time"] == datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc) + + +@pytest.mark.unit +def test_get_active_devices_count(): + raw_repo = MagicMock() + raw_repo.count_devices_with_data.return_value = 5 + + service = RawDataService(db=None) + service.device_repo = MagicMock() + service.raw_repo = raw_repo + + result = service.get_active_devices_count() + + assert result == {"active_devices_count": 5} + + +@pytest.mark.unit +def test_get_full_time_series_groups_by_device(): + raw_repo = MagicMock() + raw_repo.get_all_devices_with_data.return_value = [ + SimpleNamespace(id=1, device_id=1, timestamp=datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc), value=10.0), + SimpleNamespace(id=2, device_id=1, timestamp=datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc), value=20.0), + SimpleNamespace(id=3, device_id=2, timestamp=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc), value=30.0) + ] + + service = RawDataService(db=None) + service.device_repo = MagicMock() + service.raw_repo = raw_repo + + result = service.get_full_time_series() + + assert len(result) == 2 + assert result[0]["device_id"] == 1 + assert result[0]["series_data"][0]["value"] == 10.0 + assert result[0]["series_data"][1]["value"] == 20.0 + assert result[1]["device_id"] == 2 + assert result[1]["series_data"][0]["value"] == 30.0 + + +@pytest.mark.unit +def test_create_raw_data_rejects_existing_and_duplicate_timestamps(): + device_repo = MagicMock() + raw_repo = MagicMock() + + device_repo.get_by_serial_device.return_value = SimpleNamespace(id=1) + raw_repo.get_existing_timestamps.return_value = [ + (datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc),) + ] + raw_repo.bulk_create.return_value = {"status": "success", "count": 1} + + payload = SimpleNamespace( + serial_device=" dev-123 ", + data=[ + SimpleNamespace(timestamp=datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc), value=10.0), + SimpleNamespace(timestamp=datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc), value=20.0), + SimpleNamespace(timestamp=datetime(2026, 5, 18, 11, 0, tzinfo=timezone.utc), value=22.0) + ] + ) + + service = RawDataService(db=None) + service.device_repo = device_repo + service.raw_repo = raw_repo + + result = service.create_raw_data(payload) + + assert result["serial_device"] == "DEV-123" + assert result["inserted"] == 1 + assert result["rejected"] == 2 + assert {item["reason"] for item in result["details"]} == {"already_exists", "duplicate_in_payload"} + raw_repo.bulk_create.assert_called_once() diff --git a/signal-processing-api/docker-compose.yml b/signal-processing-api/docker-compose.yml new file mode 100644 index 000000000..0ae1ca401 --- /dev/null +++ b/signal-processing-api/docker-compose.yml @@ -0,0 +1,75 @@ +services: + db: + image: postgres:15-alpine + container_name: signal-processing-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + db-init: + build: . + container_name: signal-processing-db-init + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/app + command: python -m app.core.db_init + + api1: + build: . + container_name: signal-processing-api1 + depends_on: + db-init: + condition: service_completed_successfully + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/app + INSTANCE_NAME: api1 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + + api2: + build: . + container_name: signal-processing-api2 + depends_on: + db-init: + condition: service_completed_successfully + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/app + INSTANCE_NAME: api2 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + + api3: + build: . + container_name: signal-processing-api3 + depends_on: + db-init: + condition: service_completed_successfully + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/app + INSTANCE_NAME: api3 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + + nginx: + image: nginx:latest + container_name: signal-processing-nginx + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + depends_on: + - api1 + - api2 + - api3 + +volumes: + db_data: \ No newline at end of file diff --git a/signal-processing-api/nginx.conf b/signal-processing-api/nginx.conf new file mode 100644 index 000000000..c82c1dd11 --- /dev/null +++ b/signal-processing-api/nginx.conf @@ -0,0 +1,20 @@ +events {} + +http { + upstream api_servers { + server api1:8000; + server api2:8000; + server api3:8000; + } + + server { + listen 80; + + location / { + proxy_pass http://api_servers; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} \ No newline at end of file diff --git a/signal-processing-api/pytest.ini b/signal-processing-api/pytest.ini new file mode 100644 index 000000000..3741e9818 --- /dev/null +++ b/signal-processing-api/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = app/tests + +markers = + unit: unit tests (service layer) + integration: integration tests (api layer) + performance: latency/performance tests diff --git a/signal-processing-api/requirements.txt b/signal-processing-api/requirements.txt new file mode 100644 index 000000000..cd80b0ff8 Binary files /dev/null and b/signal-processing-api/requirements.txt differ