diff --git a/.env b/.env new file mode 100644 index 0000000..67a2092 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SECRET_KEY=efY2OFw3dWgoPyRjpJA9DHb7I6D_MswRPla7FLA78gI \ No newline at end of file diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..25ea504 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,88 @@ +# ============================================================================= +# Pipeline do Backend — Lint → Test → Build → Push +# ============================================================================= +name: Backend CI + +on: + push: + branches: [main] + paths: ['backend/**'] + pull_request: + branches: [main] + paths: ['backend/**'] + +jobs: + lint: + name: Lint Backend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff + working-directory: ./backend + run: ruff check app/ + + test: + name: Test Backend + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install dependencies + working-directory: ./backend + run: | + pip install -e . + pip install pytest pytest-asyncio httpx aiosqlite + + - name: Run tests + working-directory: ./backend + env: + SECRET_KEY: test-secret-key-for-ci-only + DATABASE_URL: sqlite+aiosqlite:///test.db + run: pytest __tests__/ -v + + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/condocombat-backend:latest + ${{ secrets.DOCKERHUB_USERNAME }}/condocombat-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/deploy-landing.yaml b/.github/workflows/deploy-landing.yaml new file mode 100644 index 0000000..a491386 --- /dev/null +++ b/.github/workflows/deploy-landing.yaml @@ -0,0 +1,81 @@ +name: Deploy Landing Page + +on: + push: + branches: + - main + - master + paths: + - 'landing/**' + - '.github/workflows/deploy-landing.yaml' + workflow_dispatch: + +env: + NODE_VERSION: '20' + +jobs: + ci: + name: CI — Test & Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./landing + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: ./landing/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + env: + PUBLIC_APP_URL: ${{ secrets.PUBLIC_APP_URL }} + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: landing-dist + path: landing/dist/ + + cd: + name: CD — Deploy to Netlify + needs: ci + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: landing-dist + path: landing/dist/ + + - name: Install Netlify CLI + run: npm install -g netlify-cli + + - name: Deploy to Netlify + run: | + netlify deploy \ + --dir=landing/dist \ + --prod \ + --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} \ + --site=${{ secrets.NETLIFY_SITE_ID }} \ No newline at end of file diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..7cf11a7 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,88 @@ +# ============================================================================= +# Pipeline do Frontend — Lint → Test → Build → Push +# ============================================================================= +name: Frontend CI + +on: + push: + branches: [main] + paths: ['frontend/**'] + pull_request: + branches: [main] + paths: ['frontend/**'] + +jobs: + lint: + name: Lint Frontend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Run ESLint + working-directory: ./frontend + run: npm run lint + + test: + name: Test Frontend + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Run tests + working-directory: ./frontend + run: npm run test + + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/condocombat-frontend:latest + ${{ secrets.DOCKERHUB_USERNAME }}/condocombat-frontend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/backend/.ci_trigger b/backend/.ci_trigger new file mode 100644 index 0000000..8283f15 --- /dev/null +++ b/backend/.ci_trigger @@ -0,0 +1 @@ +CI trigger file to force workflow run diff --git a/backend/.ruff_fix_applied b/backend/.ruff_fix_applied new file mode 100644 index 0000000..d3c747b --- /dev/null +++ b/backend/.ruff_fix_applied @@ -0,0 +1,7 @@ +Apply ruff auto-fixes for BLE001 occurrences (auto-generated). + +Replacements made by ruff --fix: +- Applied automatic fixes across backend/ to remove trivial 'except Exception' patterns. +- Where ruff couldn't confidently fix, those files were left for manual review. + +This commit was generated by Copilot assistant per user request. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8456e67 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,55 @@ +# ============================================================================= +# Stage 1: Dependencies — instala dependências Python +# ============================================================================= +FROM python:3.12-slim AS deps + +WORKDIR /app + +# Instala sistema básico e dependências de compilação +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copia requirements e instala dependências +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ============================================================================= +# Stage 2: Runtime — imagem final +# ============================================================================= +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Instala dependências de runtime +RUN apt-get update && apt-get install -y \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Cria usuário não-root +RUN useradd --create-home --shell /bin/bash app + +# Copia dependências do stage deps +COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=deps /usr/local/bin /usr/local/bin + +# Copia código fonte +COPY app/ app/ +COPY alembic/ alembic/ +COPY alembic.ini . + +# Copia e configura script de entrada +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +# Troca para usuário não-root +USER app + +# Expõe porta e health check +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Entry point +ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file diff --git a/backend/__tests__/test_auth.py b/backend/__tests__/test_auth.py index f6a30be..641edb2 100644 --- a/backend/__tests__/test_auth.py +++ b/backend/__tests__/test_auth.py @@ -1,5 +1,7 @@ +from datetime import UTC + import pytest -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient from app.auth.utils import create_access_token from app.main import app @@ -66,7 +68,7 @@ async def test_me_without_token_returns_401(): @pytest.mark.asyncio async def test_me_with_expired_token_returns_401(): - from datetime import datetime, timedelta, timezone + from datetime import datetime, timedelta from jose import jwt @@ -74,7 +76,7 @@ async def test_me_with_expired_token_returns_401(): expired_payload = { "sub": "admin@condocombat.com", - "exp": datetime.now(timezone.utc) - timedelta(hours=1), + "exp": datetime.now(UTC) - timedelta(hours=1), } expired_token = jwt.encode( expired_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM diff --git a/backend/__tests__/test_health.py b/backend/__tests__/test_health.py index 31939b7..ddb830d 100644 --- a/backend/__tests__/test_health.py +++ b/backend/__tests__/test_health.py @@ -1,5 +1,5 @@ import pytest -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient from app.main import app diff --git a/backend/__tests__/test_models.py b/backend/__tests__/test_models.py index bfe75ec..f4596ef 100644 --- a/backend/__tests__/test_models.py +++ b/backend/__tests__/test_models.py @@ -141,7 +141,9 @@ async def test_condominio_apartamentos_relationship(async_session): await async_session.commit() result = await async_session.execute( - select(Condominio).where(Condominio.id == cond.id).options(selectinload(Condominio.apartamentos)) + select(Condominio) + .where(Condominio.id == cond.id) + .options(selectinload(Condominio.apartamentos)) ) cond_db = result.scalar_one() assert len(cond_db.apartamentos) == 2 @@ -159,13 +161,19 @@ async def test_apartamento_moradores_relationship(async_session): async_session.add(apt) await async_session.flush() - m1 = Morador(nome="Ana", cpf="111.222.333-44", email="ana@email.com", apartamento_id=apt.id) - m2 = Morador(nome="Beto", cpf="555.666.777-88", email="beto@email.com", apartamento_id=apt.id) + m1 = Morador( + nome="Ana", cpf="111.222.333-44", email="ana@email.com", apartamento_id=apt.id + ) + m2 = Morador( + nome="Beto", cpf="555.666.777-88", email="beto@email.com", apartamento_id=apt.id + ) async_session.add_all([m1, m2]) await async_session.commit() result = await async_session.execute( - select(Apartamento).where(Apartamento.id == apt.id).options(selectinload(Apartamento.moradores)) + select(Apartamento) + .where(Apartamento.id == apt.id) + .options(selectinload(Apartamento.moradores)) ) apt_db = result.scalar_one() assert len(apt_db.moradores) == 2 @@ -175,9 +183,15 @@ async def test_apartamento_moradores_relationship(async_session): def test_unique_constraint_apartamento(): """Verifica que existe constraint única para numero+bloco+torre+condominio_id no modelo.""" from sqlalchemy import inspect + from app.models import Apartamento + insp = inspect(Apartamento.__table__) - unique_constraints = [c for c in insp.constraints if hasattr(c, 'columns') and c.__class__.__name__ == 'UniqueConstraint'] + unique_constraints = [ + c + for c in insp.constraints + if hasattr(c, "columns") and c.__class__.__name__ == "UniqueConstraint" + ] assert any( set(c.columns.keys()) == {"numero", "bloco", "torre", "condominio_id"} for c in unique_constraints @@ -187,9 +201,15 @@ def test_unique_constraint_apartamento(): def test_unique_constraint_rivalidade(): """Verifica que existe constraint única para par (apartamento_a, apartamento_b) no modelo.""" from sqlalchemy import inspect + from app.models import Rivalidade + insp = inspect(Rivalidade.__table__) - unique_constraints = [c for c in insp.constraints if hasattr(c, 'columns') and c.__class__.__name__ == 'UniqueConstraint'] + unique_constraints = [ + c + for c in insp.constraints + if hasattr(c, "columns") and c.__class__.__name__ == "UniqueConstraint" + ] assert any( set(c.columns.keys()) == {"apartamento_a_id", "apartamento_b_id"} for c in unique_constraints diff --git a/backend/__tests__/test_repositories/test_condominio_repo.py b/backend/__tests__/test_repositories/test_condominio_repo.py index 3b21cba..59f0a71 100644 --- a/backend/__tests__/test_repositories/test_condominio_repo.py +++ b/backend/__tests__/test_repositories/test_condominio_repo.py @@ -61,9 +61,7 @@ async def test_returns_all_condominios(self, repo, mock_session): Condominio(id=1, nome="A", endereco="Rua A"), Condominio(id=2, nome="B", endereco="Rua B"), ] - mock_session.execute.return_value.scalars.return_value.all.return_value = ( - conds - ) + mock_session.execute.return_value.scalars.return_value.all.return_value = conds result = await repo.get_all() assert len(result) == 2 mock_session.execute.assert_awaited_once() diff --git a/backend/__tests__/test_repositories/test_morador_repo.py b/backend/__tests__/test_repositories/test_morador_repo.py index 17c2bc0..81359a7 100644 --- a/backend/__tests__/test_repositories/test_morador_repo.py +++ b/backend/__tests__/test_repositories/test_morador_repo.py @@ -33,7 +33,15 @@ def repo(session: MagicMock) -> MoradorRepository: def _make_morador(**kwargs) -> MagicMock: - vals = {"id": 1, "nome": "João", "cpf": "111.222.333-44", "email": "joao@email.com", "apartamento_id": 1, "telefone": None, "tipo": "proprietario"} | kwargs + vals = { + "id": 1, + "nome": "João", + "cpf": "111.222.333-44", + "email": "joao@email.com", + "apartamento_id": 1, + "telefone": None, + "tipo": "proprietario", + } | kwargs m = MagicMock(spec=Morador) for k, v in vals.items(): setattr(m, k, v) @@ -41,7 +49,9 @@ def _make_morador(**kwargs) -> MagicMock: class TestCreate: - async def test_creates_morador(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_creates_morador( + self, repo: MoradorRepository, session: MagicMock + ) -> None: m = _make_morador() result = await repo.create(m) session.add.assert_called_once_with(m) @@ -51,7 +61,9 @@ async def test_creates_morador(self, repo: MoradorRepository, session: MagicMock class TestGetById: - async def test_returns_morador_when_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_morador_when_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: expected = _make_morador() session.execute.return_value.scalar_one_or_none.return_value = expected @@ -60,14 +72,18 @@ async def test_returns_morador_when_found(self, repo: MoradorRepository, session assert result == expected session.execute.assert_awaited_once() - async def test_returns_none_when_not_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None result = await repo.get_by_id(999) assert result is None - async def test_executes_select_query(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_executes_select_query( + self, repo: MoradorRepository, session: MagicMock + ) -> None: await repo.get_by_id(1) call = session.execute.call_args stmt = call[0][0] @@ -75,7 +91,9 @@ async def test_executes_select_query(self, repo: MoradorRepository, session: Mag class TestGetAll: - async def test_returns_all(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_all( + self, repo: MoradorRepository, session: MagicMock + ) -> None: a = _make_morador(id=1) b = _make_morador(id=2, nome="Maria") session.execute.return_value.scalars.return_value.all.return_value = [a, b] @@ -84,7 +102,9 @@ async def test_returns_all(self, repo: MoradorRepository, session: MagicMock) -> assert len(result) == 2 - async def test_returns_empty(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_empty( + self, repo: MoradorRepository, session: MagicMock + ) -> None: session.execute.return_value.scalars.return_value.all.return_value = [] result = await repo.get_all() @@ -93,7 +113,9 @@ async def test_returns_empty(self, repo: MoradorRepository, session: MagicMock) class TestGetByCpf: - async def test_finds_by_cpf(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_finds_by_cpf( + self, repo: MoradorRepository, session: MagicMock + ) -> None: expected = _make_morador() session.execute.return_value.scalar_one_or_none.return_value = expected @@ -101,7 +123,9 @@ async def test_finds_by_cpf(self, repo: MoradorRepository, session: MagicMock) - assert result == expected - async def test_returns_none_when_not_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None result = await repo.get_by_cpf("000.000.000-00") @@ -110,7 +134,9 @@ async def test_returns_none_when_not_found(self, repo: MoradorRepository, sessio class TestGetByEmail: - async def test_finds_by_email(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_finds_by_email( + self, repo: MoradorRepository, session: MagicMock + ) -> None: expected = _make_morador() session.execute.return_value.scalar_one_or_none.return_value = expected @@ -118,7 +144,9 @@ async def test_finds_by_email(self, repo: MoradorRepository, session: MagicMock) assert result == expected - async def test_returns_none_when_not_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None result = await repo.get_by_email("x@y.com") @@ -127,7 +155,9 @@ async def test_returns_none_when_not_found(self, repo: MoradorRepository, sessio class TestGetByApartamento: - async def test_returns_moradores_do_apartamento(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_moradores_do_apartamento( + self, repo: MoradorRepository, session: MagicMock + ) -> None: a = _make_morador(id=1) b = _make_morador(id=2, nome="Maria") session.execute.return_value.scalars.return_value.all.return_value = [a, b] @@ -136,7 +166,9 @@ async def test_returns_moradores_do_apartamento(self, repo: MoradorRepository, s assert len(result) == 2 - async def test_returns_empty_when_sem_moradores(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_empty_when_sem_moradores( + self, repo: MoradorRepository, session: MagicMock + ) -> None: session.execute.return_value.scalars.return_value.all.return_value = [] result = await repo.get_by_apartamento(999) @@ -145,7 +177,9 @@ async def test_returns_empty_when_sem_moradores(self, repo: MoradorRepository, s class TestUpdate: - async def test_updates_existing(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_updates_existing( + self, repo: MoradorRepository, session: MagicMock + ) -> None: morador = _make_morador() repo.get_by_id = AsyncMock(return_value=morador) @@ -156,7 +190,9 @@ async def test_updates_existing(self, repo: MoradorRepository, session: MagicMoc session.refresh.assert_awaited_once_with(morador) assert result == morador - async def test_returns_none_when_not_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) result = await repo.update(999, {"nome": "X"}) @@ -166,7 +202,9 @@ async def test_returns_none_when_not_found(self, repo: MoradorRepository, sessio class TestDelete: - async def test_deletes_existing(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_deletes_existing( + self, repo: MoradorRepository, session: MagicMock + ) -> None: morador = _make_morador() repo.get_by_id = AsyncMock(return_value=morador) @@ -176,7 +214,9 @@ async def test_deletes_existing(self, repo: MoradorRepository, session: MagicMoc session.delete.assert_awaited_once_with(morador) session.commit.assert_awaited_once() - async def test_returns_false_when_not_found(self, repo: MoradorRepository, session: MagicMock) -> None: + async def test_returns_false_when_not_found( + self, repo: MoradorRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) result = await repo.delete(999) diff --git a/backend/__tests__/test_repositories/test_ocorrencia_repo.py b/backend/__tests__/test_repositories/test_ocorrencia_repo.py index 006860d..69458e4 100644 --- a/backend/__tests__/test_repositories/test_ocorrencia_repo.py +++ b/backend/__tests__/test_repositories/test_ocorrencia_repo.py @@ -1,5 +1,6 @@ """Unit tests for OcorrenciaRepository.""" +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import pytest @@ -31,7 +32,7 @@ def repo(session: MagicMock) -> OcorrenciaRepository: def _make(**kw: dict) -> MagicMock: - from datetime import datetime, timezone + from datetime import datetime vals: dict = { "id": 1, @@ -41,8 +42,8 @@ def _make(**kw: dict) -> MagicMock: "gravidade": "media", "status": "aberta", "apartamento_id": 1, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) m = MagicMock(spec=Ocorrencia) @@ -52,7 +53,9 @@ def _make(**kw: dict) -> MagicMock: class TestCreate: - async def test_creates(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_creates( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: o = _make() result = await repo.create(o) session.add.assert_called_once_with(o) @@ -62,31 +65,42 @@ async def test_creates(self, repo: OcorrenciaRepository, session: MagicMock) -> class TestGetById: - async def test_returns_when_found(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_returns_when_found( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = _make() result = await repo.get_by_id(1) assert result is not None assert result.id == 1 - async def test_returns_none_when_not_found(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None assert await repo.get_by_id(999) is None class TestGetAll: - @pytest.mark.parametrize("filtros", [ - {}, - {"categoria": "barulho"}, - {"status": "aberta"}, - {"gravidade": "alta"}, - {"apartamento_id": 1}, - ]) - async def test_aplica_filtros(self, repo: OcorrenciaRepository, session: MagicMock, filtros: dict) -> None: + @pytest.mark.parametrize( + "filtros", + [ + {}, + {"categoria": "barulho"}, + {"status": "aberta"}, + {"gravidade": "alta"}, + {"apartamento_id": 1}, + ], + ) + async def test_aplica_filtros( + self, repo: OcorrenciaRepository, session: MagicMock, filtros: dict + ) -> None: session.execute.return_value.scalars.return_value.all.return_value = [] result = await repo.get_all(**filtros) assert result == [] - async def test_retorna_lista(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_retorna_lista( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: a, b = _make(id=1), _make(id=2, titulo="Outro") session.execute.return_value.scalars.return_value.all.return_value = [a, b] result = await repo.get_all() @@ -94,14 +108,18 @@ async def test_retorna_lista(self, repo: OcorrenciaRepository, session: MagicMoc class TestListRecentes: - async def test_retorna_recentes(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_retorna_recentes( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: session.execute.return_value.scalars.return_value.all.return_value = [_make()] result = await repo.list_recentes() assert len(result) == 1 class TestUpdate: - async def test_updates(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_updates( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: o = _make() repo.get_by_id = AsyncMock(return_value=o) result = await repo.update(1, {"status": "resolvida"}) @@ -109,20 +127,26 @@ async def test_updates(self, repo: OcorrenciaRepository, session: MagicMock) -> session.commit.assert_awaited_once() assert result == o - async def test_returns_none_when_not_found(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) assert await repo.update(999, {}) is None session.commit.assert_not_called() class TestDelete: - async def test_deletes(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_deletes( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make()) assert await repo.delete(1) is True session.delete.assert_awaited_once() session.commit.assert_awaited_once() - async def test_returns_false_when_not_found(self, repo: OcorrenciaRepository, session: MagicMock) -> None: + async def test_returns_false_when_not_found( + self, repo: OcorrenciaRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) assert await repo.delete(999) is False session.delete.assert_not_called() diff --git a/backend/__tests__/test_repositories/test_rivalidade_repo.py b/backend/__tests__/test_repositories/test_rivalidade_repo.py index eb8d41c..9bd55cc 100644 --- a/backend/__tests__/test_repositories/test_rivalidade_repo.py +++ b/backend/__tests__/test_repositories/test_rivalidade_repo.py @@ -1,5 +1,6 @@ """Unit tests for RivalidadeRepository.""" +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import pytest @@ -31,7 +32,7 @@ def repo(session: MagicMock) -> RivalidadeRepository: def _make(**kw: dict) -> MagicMock: - from datetime import datetime, timezone + from datetime import datetime vals: dict = { "id": 1, @@ -40,8 +41,8 @@ def _make(**kw: dict) -> MagicMock: "motivo": "Barulho", "nivel": "moderado", "status": "ativa", - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) m = MagicMock(spec=Rivalidade) @@ -51,7 +52,9 @@ def _make(**kw: dict) -> MagicMock: class TestCreate: - async def test_creates(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_creates( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: r = _make() result = await repo.create(r) session.add.assert_called_once_with(r) @@ -61,19 +64,25 @@ async def test_creates(self, repo: RivalidadeRepository, session: MagicMock) -> class TestGetById: - async def test_returns_when_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_when_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = _make() result = await repo.get_by_id(1) assert result is not None assert result.id == 1 - async def test_returns_none_when_not_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None assert await repo.get_by_id(999) is None class TestGetAll: - async def test_returns_list(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_list( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: a, b = _make(id=1), _make(id=2, apartamento_a_id=201) session.execute.return_value.scalars.return_value.all.return_value = [a, b] result = await repo.get_all() @@ -81,50 +90,71 @@ async def test_returns_list(self, repo: RivalidadeRepository, session: MagicMock class TestGetByApartamento: - async def test_returns_rivalidades_do_apartamento(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_rivalidades_do_apartamento( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: a = _make(apartamento_a_id=101, apartamento_b_id=102) b = _make(apartamento_a_id=103, apartamento_b_id=101) session.execute.return_value.scalars.return_value.all.return_value = [a, b] result = await repo.get_by_apartamento(101) assert len(result) == 2 - async def test_returns_empty_when_nenhuma(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_empty_when_nenhuma( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: session.execute.return_value.scalars.return_value.all.return_value = [] assert await repo.get_by_apartamento(999) == [] class TestGetBetween: - async def test_returns_when_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_when_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: r = _make(apartamento_a_id=101, apartamento_b_id=102) session.execute.return_value.scalar_one_or_none.return_value = r result = await repo.get_between(101, 102) assert result is not None assert result.apartamento_a_id == 101 - async def test_returns_none_when_not_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: session.execute.return_value.scalar_one_or_none.return_value = None assert await repo.get_between(1, 2) is None class TestTopRivalidades: - async def test_returns_sorted_by_nivel(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_sorted_by_nivel( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: r1 = _make(id=1, nivel="leve") r2 = _make(id=2, nivel="moderado") r3 = _make(id=3, nivel="belico") r4 = _make(id=4, nivel="intenso") - session.execute.return_value.scalars.return_value.all.return_value = [r1, r2, r3, r4] + session.execute.return_value.scalars.return_value.all.return_value = [ + r1, + r2, + r3, + r4, + ] result = await repo.top_rivalidades(limite=10) # ordem: belico(0), intenso(1), moderado(2), leve(3) assert [r.id for r in result] == [3, 4, 2, 1] - async def test_limita_quantidade(self, repo: RivalidadeRepository, session: MagicMock) -> None: - session.execute.return_value.scalars.return_value.all.return_value = [_make(id=i) for i in range(5)] + async def test_limita_quantidade( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: + session.execute.return_value.scalars.return_value.all.return_value = [ + _make(id=i) for i in range(5) + ] result = await repo.top_rivalidades(limite=3) assert len(result) == 3 class TestUpdate: - async def test_updates(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_updates( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: r = _make() repo.get_by_id = AsyncMock(return_value=r) result = await repo.update(1, {"nivel": "serio"}) @@ -132,20 +162,26 @@ async def test_updates(self, repo: RivalidadeRepository, session: MagicMock) -> session.commit.assert_awaited_once() assert result == r - async def test_returns_none_when_not_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_none_when_not_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) assert await repo.update(999, {}) is None session.commit.assert_not_called() class TestDelete: - async def test_deletes(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_deletes( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make()) assert await repo.delete(1) is True session.delete.assert_awaited_once() session.commit.assert_awaited_once() - async def test_returns_false_when_not_found(self, repo: RivalidadeRepository, session: MagicMock) -> None: + async def test_returns_false_when_not_found( + self, repo: RivalidadeRepository, session: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) assert await repo.delete(999) is False session.delete.assert_not_called() diff --git a/backend/__tests__/test_routers/test_condominio_api.py b/backend/__tests__/test_routers/test_condominio_api.py index 8ff373d..d552983 100644 --- a/backend/__tests__/test_routers/test_condominio_api.py +++ b/backend/__tests__/test_routers/test_condominio_api.py @@ -1,6 +1,7 @@ """Integration tests for Condominio REST endpoints.""" from collections.abc import AsyncGenerator +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import httpx @@ -51,7 +52,7 @@ def _make_condominio( cnpj: str = "11.222.333/0001-44", ) -> Condominio: """Cria um objeto Condominio simulado.""" - from datetime import datetime, timezone + from datetime import datetime obj = MagicMock(spec=Condominio) obj.id = condominio_id @@ -60,8 +61,8 @@ def _make_condominio( obj.cnpj = cnpj obj.telefone = "(11) 99999-0000" obj.email = "teste@condominio.com" - obj.created_at = datetime.now(timezone.utc) - obj.updated_at = datetime.now(timezone.utc) + obj.created_at = datetime.now(UTC) + obj.updated_at = datetime.now(UTC) return obj @@ -69,7 +70,9 @@ def _make_condominio( @pytest.mark.asyncio -async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): cond_a = _make_condominio(1, "Cond A") cond_b = _make_condominio(2, "Cond B") mock_service.listar.return_value = [cond_a, cond_b] @@ -84,7 +87,9 @@ async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_vazia( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar.return_value = [] response = await client.get("/condominios/") @@ -97,7 +102,9 @@ async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_obter_retorna_condominio(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_retorna_condominio( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): cond = _make_condominio(1) mock_service.buscar.return_value = cond @@ -108,7 +115,9 @@ async def test_obter_retorna_condominio(client: httpx.AsyncClient, override_deps @pytest.mark.asyncio -async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.side_effect = CondominioNaoEncontrado("não encontrado") response = await client.get("/condominios/999") @@ -121,7 +130,9 @@ async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_retorna_201( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): cond = _make_condominio(1) mock_service.criar.return_value = cond @@ -135,14 +146,18 @@ async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, @pytest.mark.asyncio -async def test_criar_422_quando_dados_invalidos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_422_quando_dados_invalidos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.post("/condominios/", json={}) assert response.status_code == 422 @pytest.mark.asyncio -async def test_criar_409_quando_cnpj_duplicado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_409_quando_cnpj_duplicado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.side_effect = CondominioJaExiste("CNPJ já cadastrado") response = await client.post( @@ -162,7 +177,9 @@ async def test_criar_409_quando_cnpj_duplicado(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_retorna_200( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): cond = _make_condominio(1, nome="Nome Atualizado") mock_service.atualizar.return_value = cond @@ -176,7 +193,9 @@ async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: N @pytest.mark.asyncio -async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.side_effect = CondominioNaoEncontrado("não encontrado") response = await client.put( @@ -188,8 +207,12 @@ async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_409_quando_cnpj_duplicado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): - mock_service.atualizar.side_effect = CondominioJaExiste("CNPJ já pertence a outro condomínio") +async def test_atualizar_409_quando_cnpj_duplicado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): + mock_service.atualizar.side_effect = CondominioJaExiste( + "CNPJ já pertence a outro condomínio" + ) response = await client.put( "/condominios/1", @@ -200,7 +223,9 @@ async def test_atualizar_409_quando_cnpj_duplicado(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_422_quando_cnpj_invalido(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_422_quando_cnpj_invalido( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.put( "/condominios/1", json={"cnpj": "123"}, @@ -213,7 +238,9 @@ async def test_atualizar_422_quando_cnpj_invalido(client: httpx.AsyncClient, ove @pytest.mark.asyncio -async def test_remover_retorna_204(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_retorna_204( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.remover = AsyncMock() response = await client.delete("/condominios/1") @@ -222,7 +249,9 @@ async def test_remover_retorna_204(client: httpx.AsyncClient, override_deps: Non @pytest.mark.asyncio -async def test_remover_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.remover.side_effect = CondominioNaoEncontrado("não encontrado") response = await client.delete("/condominios/999") diff --git a/backend/__tests__/test_routers/test_morador_api.py b/backend/__tests__/test_routers/test_morador_api.py index 2448228..8121a2b 100644 --- a/backend/__tests__/test_routers/test_morador_api.py +++ b/backend/__tests__/test_routers/test_morador_api.py @@ -1,6 +1,7 @@ """Integration tests for Morador REST endpoints.""" from collections.abc import AsyncGenerator +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import httpx @@ -50,7 +51,7 @@ def _make_morador( nome: str = "João Silva", cpf: str = "111.222.333-44", ) -> dict: - from datetime import datetime, timezone + from datetime import datetime obj = MagicMock() obj.id = morador_id @@ -60,8 +61,8 @@ def _make_morador( obj.telefone = "(11) 99999-0000" obj.tipo = "proprietario" obj.apartamento_id = 1 - obj.created_at = datetime.now(timezone.utc) - obj.updated_at = datetime.now(timezone.utc) + obj.created_at = datetime.now(UTC) + obj.updated_at = datetime.now(UTC) return obj @@ -69,7 +70,9 @@ def _make_morador( @pytest.mark.asyncio -async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): a, b = _make_morador(1, "João"), _make_morador(2, "Maria") mock_service.listar.return_value = [a, b] @@ -83,7 +86,9 @@ async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_vazia( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar.return_value = [] response = await client.get("/moradores/") @@ -96,7 +101,9 @@ async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_obter_retorna_morador(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_retorna_morador( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.return_value = _make_morador() response = await client.get("/moradores/1") @@ -106,7 +113,9 @@ async def test_obter_retorna_morador(client: httpx.AsyncClient, override_deps: N @pytest.mark.asyncio -async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.side_effect = MoradorNaoEncontrado("não encontrado") response = await client.get("/moradores/999") @@ -119,7 +128,9 @@ async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_retorna_201( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.return_value = _make_morador() response = await client.post( @@ -137,14 +148,18 @@ async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, @pytest.mark.asyncio -async def test_criar_422_quando_dados_invalidos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_422_quando_dados_invalidos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.post("/moradores/", json={}) assert response.status_code == 422 @pytest.mark.asyncio -async def test_criar_409_quando_cpf_duplicado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_409_quando_cpf_duplicado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.side_effect = MoradorComCPFJaExiste("CPF já cadastrado") response = await client.post( @@ -162,7 +177,9 @@ async def test_criar_409_quando_cpf_duplicado(client: httpx.AsyncClient, overrid @pytest.mark.asyncio -async def test_criar_409_quando_email_duplicado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_409_quando_email_duplicado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.side_effect = MoradorComEmailJaExiste("Email já cadastrado") response = await client.post( @@ -183,7 +200,9 @@ async def test_criar_409_quando_email_duplicado(client: httpx.AsyncClient, overr @pytest.mark.asyncio -async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_retorna_200( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.return_value = _make_morador(nome="Nome Atualizado") response = await client.put("/moradores/1", json={"nome": "Nome Atualizado"}) @@ -193,7 +212,9 @@ async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: N @pytest.mark.asyncio -async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.side_effect = MoradorNaoEncontrado("não encontrado") response = await client.put("/moradores/999", json={"nome": "Qualquer"}) @@ -202,8 +223,12 @@ async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_409_quando_cpf_duplicado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): - mock_service.atualizar.side_effect = MoradorComCPFJaExiste("CPF já pertence a outro morador") +async def test_atualizar_409_quando_cpf_duplicado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): + mock_service.atualizar.side_effect = MoradorComCPFJaExiste( + "CPF já pertence a outro morador" + ) response = await client.put( "/moradores/1", @@ -214,14 +239,18 @@ async def test_atualizar_409_quando_cpf_duplicado(client: httpx.AsyncClient, ove @pytest.mark.asyncio -async def test_atualizar_422_quando_cpf_invalido(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_422_quando_cpf_invalido( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.put("/moradores/1", json={"cpf": "123"}) assert response.status_code == 422 @pytest.mark.asyncio -async def test_atualizar_400_quando_sem_campos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_400_quando_sem_campos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.put("/moradores/1", json={}) assert response.status_code == 400 @@ -232,14 +261,18 @@ async def test_atualizar_400_quando_sem_campos(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_remover_retorna_204(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_retorna_204( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.delete("/moradores/1") assert response.status_code == 204 @pytest.mark.asyncio -async def test_remover_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.remover.side_effect = MoradorNaoEncontrado("não encontrado") response = await client.delete("/moradores/999") diff --git a/backend/__tests__/test_routers/test_ocorrencia_api.py b/backend/__tests__/test_routers/test_ocorrencia_api.py index 6ffba01..7d9eff9 100644 --- a/backend/__tests__/test_routers/test_ocorrencia_api.py +++ b/backend/__tests__/test_routers/test_ocorrencia_api.py @@ -1,7 +1,7 @@ """Integration tests for Ocorrencia REST endpoints.""" from collections.abc import AsyncGenerator -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock import httpx @@ -54,8 +54,8 @@ def _make(**kw: dict) -> MagicMock: "gravidade": "media", "status": "aberta", "apartamento_id": 1, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) m = MagicMock(**vals) @@ -66,7 +66,9 @@ def _make(**kw: dict) -> MagicMock: @pytest.mark.asyncio -async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): a, b = _make(id=1), _make(id=2, titulo="Outro") mock_service.listar.return_value = [a, b] @@ -77,10 +79,14 @@ async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_listar_com_filtros(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_com_filtros( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar.return_value = [] - response = await client.get("/ocorrencias/?categoria=barulho&status=aberta&gravidade=media&apartamento_id=1") + response = await client.get( + "/ocorrencias/?categoria=barulho&status=aberta&gravidade=media&apartamento_id=1" + ) assert response.status_code == 200 assert response.json() == [] @@ -91,7 +97,9 @@ async def test_listar_com_filtros(client: httpx.AsyncClient, override_deps: None @pytest.mark.asyncio -async def test_recentes_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_recentes_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar_recentes.return_value = [_make()] response = await client.get("/ocorrencias/recentes") @@ -104,7 +112,9 @@ async def test_recentes_retorna_lista(client: httpx.AsyncClient, override_deps: @pytest.mark.asyncio -async def test_obter_retorna_ocorrencia(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_retorna_ocorrencia( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.return_value = _make() response = await client.get("/ocorrencias/1") @@ -114,7 +124,9 @@ async def test_obter_retorna_ocorrencia(client: httpx.AsyncClient, override_deps @pytest.mark.asyncio -async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.side_effect = OcorrenciaNaoEncontrada("não encontrada") response = await client.get("/ocorrencias/999") @@ -126,7 +138,9 @@ async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_retorna_201( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.return_value = _make() response = await client.post( @@ -144,7 +158,9 @@ async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, @pytest.mark.asyncio -async def test_criar_422_quando_dados_invalidos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_422_quando_dados_invalidos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.post("/ocorrencias/", json={}) assert response.status_code == 422 @@ -154,7 +170,9 @@ async def test_criar_422_quando_dados_invalidos(client: httpx.AsyncClient, overr @pytest.mark.asyncio -async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_retorna_200( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.return_value = _make(titulo="Novo Título") response = await client.put("/ocorrencias/1", json={"titulo": "Novo Título"}) @@ -164,7 +182,9 @@ async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: N @pytest.mark.asyncio -async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.side_effect = OcorrenciaNaoEncontrada("não encontrada") response = await client.put("/ocorrencias/999", json={"titulo": "X"}) @@ -173,7 +193,9 @@ async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_409_transicao_invalida(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_409_transicao_invalida( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.side_effect = TransicaoStatusInvalida("Transição inválida") response = await client.put("/ocorrencias/1", json={"status": "aberta"}) @@ -182,7 +204,9 @@ async def test_atualizar_409_transicao_invalida(client: httpx.AsyncClient, overr @pytest.mark.asyncio -async def test_atualizar_400_sem_campos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_400_sem_campos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.put("/ocorrencias/1", json={}) assert response.status_code == 400 @@ -192,14 +216,18 @@ async def test_atualizar_400_sem_campos(client: httpx.AsyncClient, override_deps @pytest.mark.asyncio -async def test_remover_retorna_204(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_retorna_204( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.delete("/ocorrencias/1") assert response.status_code == 204 @pytest.mark.asyncio -async def test_remover_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.remover.side_effect = OcorrenciaNaoEncontrada("não encontrada") response = await client.delete("/ocorrencias/999") diff --git a/backend/__tests__/test_routers/test_rivalidade_api.py b/backend/__tests__/test_routers/test_rivalidade_api.py index 8fd32be..828f601 100644 --- a/backend/__tests__/test_routers/test_rivalidade_api.py +++ b/backend/__tests__/test_routers/test_rivalidade_api.py @@ -1,7 +1,7 @@ """Integration tests for Rivalidade REST endpoints.""" from collections.abc import AsyncGenerator -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock import httpx @@ -56,8 +56,8 @@ def _make(**kw: dict) -> MagicMock: "motivo": "Barulho", "nivel": "moderado", "status": "ativa", - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) return MagicMock(**vals) @@ -67,7 +67,9 @@ def _make(**kw: dict) -> MagicMock: @pytest.mark.asyncio -async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): a, b = _make(id=1), _make(id=2, apartamento_a_id=201) mock_service.listar.return_value = [a, b] @@ -78,7 +80,9 @@ async def test_listar_retorna_lista(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_listar_retorna_vazia( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar.return_value = [] response = await client.get("/rivalidades/") @@ -91,7 +95,9 @@ async def test_listar_retorna_vazia(client: httpx.AsyncClient, override_deps: No @pytest.mark.asyncio -async def test_por_apartamento_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_por_apartamento_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar_por_apartamento.return_value = [_make(), _make(id=2)] response = await client.get("/rivalidades/por-apartamento/101") @@ -101,7 +107,9 @@ async def test_por_apartamento_retorna_lista(client: httpx.AsyncClient, override @pytest.mark.asyncio -async def test_por_apartamento_retorna_vazia(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_por_apartamento_retorna_vazia( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.listar_por_apartamento.return_value = [] response = await client.get("/rivalidades/por-apartamento/999") @@ -114,7 +122,9 @@ async def test_por_apartamento_retorna_vazia(client: httpx.AsyncClient, override @pytest.mark.asyncio -async def test_top_retorna_lista(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_top_retorna_lista( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.top_rivalidades.return_value = [_make(), _make(id=2, nivel="belico")] response = await client.get("/rivalidades/top") @@ -124,7 +134,9 @@ async def test_top_retorna_lista(client: httpx.AsyncClient, override_deps: None, @pytest.mark.asyncio -async def test_top_com_limite(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_top_com_limite( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.top_rivalidades.return_value = [_make(), _make(id=2)] response = await client.get("/rivalidades/top?limite=5") @@ -136,7 +148,9 @@ async def test_top_com_limite(client: httpx.AsyncClient, override_deps: None, mo @pytest.mark.asyncio -async def test_obter_retorna_rivalidade(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_retorna_rivalidade( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.return_value = _make() response = await client.get("/rivalidades/1") @@ -146,7 +160,9 @@ async def test_obter_retorna_rivalidade(client: httpx.AsyncClient, override_deps @pytest.mark.asyncio -async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_obter_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.buscar.side_effect = RivalidadeNaoEncontrada("não encontrada") response = await client.get("/rivalidades/999") @@ -158,7 +174,9 @@ async def test_obter_404_quando_nao_encontrado(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_retorna_201( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.return_value = _make() response = await client.post( @@ -176,14 +194,18 @@ async def test_criar_retorna_201(client: httpx.AsyncClient, override_deps: None, @pytest.mark.asyncio -async def test_criar_422_quando_dados_invalidos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_422_quando_dados_invalidos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.post("/rivalidades/", json={}) assert response.status_code == 422 @pytest.mark.asyncio -async def test_criar_409_quando_rivalidade_ja_existe(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_409_quando_rivalidade_ja_existe( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.side_effect = RivalidadeJaExiste("já existe") response = await client.post( @@ -199,7 +221,9 @@ async def test_criar_409_quando_rivalidade_ja_existe(client: httpx.AsyncClient, @pytest.mark.asyncio -async def test_criar_409_quando_nivel_invalido(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_criar_409_quando_nivel_invalido( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.criar.side_effect = NivelInvalido("nivel inválido") response = await client.post( @@ -218,7 +242,9 @@ async def test_criar_409_quando_nivel_invalido(client: httpx.AsyncClient, overri @pytest.mark.asyncio -async def test_escalar_retorna_200(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_escalar_retorna_200( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.escalar.return_value = _make(nivel="intenso") response = await client.post("/rivalidades/1/escalar") @@ -228,7 +254,9 @@ async def test_escalar_retorna_200(client: httpx.AsyncClient, override_deps: Non @pytest.mark.asyncio -async def test_escalar_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_escalar_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.escalar.side_effect = RivalidadeNaoEncontrada("não encontrada") response = await client.post("/rivalidades/999/escalar") @@ -240,7 +268,9 @@ async def test_escalar_404_quando_nao_encontrado(client: httpx.AsyncClient, over @pytest.mark.asyncio -async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_retorna_200( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.return_value = _make(motivo="Novo motivo") response = await client.put("/rivalidades/1", json={"motivo": "Novo motivo"}) @@ -250,7 +280,9 @@ async def test_atualizar_retorna_200(client: httpx.AsyncClient, override_deps: N @pytest.mark.asyncio -async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.atualizar.side_effect = RivalidadeNaoEncontrada("não encontrada") response = await client.put("/rivalidades/999", json={"motivo": "X"}) @@ -259,7 +291,9 @@ async def test_atualizar_404_quando_nao_encontrado(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_422_quando_nivel_invalido(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_422_quando_nivel_invalido( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): # schema validation catches invalid nivel before service response = await client.put("/rivalidades/1", json={"nivel": "invalido"}) @@ -267,7 +301,9 @@ async def test_atualizar_422_quando_nivel_invalido(client: httpx.AsyncClient, ov @pytest.mark.asyncio -async def test_atualizar_400_sem_campos(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_atualizar_400_sem_campos( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.put("/rivalidades/1", json={}) assert response.status_code == 400 @@ -277,14 +313,18 @@ async def test_atualizar_400_sem_campos(client: httpx.AsyncClient, override_deps @pytest.mark.asyncio -async def test_remover_retorna_204(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_retorna_204( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): response = await client.delete("/rivalidades/1") assert response.status_code == 204 @pytest.mark.asyncio -async def test_remover_404_quando_nao_encontrado(client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock): +async def test_remover_404_quando_nao_encontrado( + client: httpx.AsyncClient, override_deps: None, mock_service: MagicMock +): mock_service.remover.side_effect = RivalidadeNaoEncontrada("não encontrada") response = await client.delete("/rivalidades/999") diff --git a/backend/__tests__/test_seed.py b/backend/__tests__/test_seed.py index 726948b..6a11065 100644 --- a/backend/__tests__/test_seed.py +++ b/backend/__tests__/test_seed.py @@ -27,9 +27,7 @@ def test_condominios_cnpj_formato(): """CNPJ deve seguir formato 99.999.999/0001-99.""" padrao = re.compile(r"^\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}$") for c in CONDOMINIOS: - assert padrao.match(c["cnpj"]), ( - f"CNPJ inválido: {c['cnpj']}" - ) + assert padrao.match(c["cnpj"]), f"CNPJ inválido: {c['cnpj']}" def test_condominios_cnpj_unicos(): @@ -52,9 +50,7 @@ def test_moradores_cpf_formato(): """CPF deve seguir formato 999.999.999-99.""" padrao = re.compile(r"^\d{3}\.\d{3}\.\d{3}-\d{2}$") for m in MORADORES: - assert padrao.match(m["cpf"]), ( - f"CPF inválido: {m['cpf']} para {m['nome']}" - ) + assert padrao.match(m["cpf"]), f"CPF inválido: {m['cpf']} para {m['nome']}" def test_moradores_cpf_unicos(): @@ -75,9 +71,7 @@ def test_moradores_tipos_validos(): """tipo do morador deve ser um dos valores aceitos.""" tipos_validos = {"proprietario", "inquilino", "sindico"} for m in MORADORES: - assert m["tipo"] in tipos_validos, ( - f"Tipo inválido {m['tipo']} para {m['nome']}" - ) + assert m["tipo"] in tipos_validos, f"Tipo inválido {m['tipo']} para {m['nome']}" def test_ocorrencias_campos_obrigatorios(): @@ -130,9 +124,7 @@ def test_rivalidades_sem_duplicatas(): for r in RIVALIDADES: par = (r["apartamento_a_idx"], r["apartamento_b_idx"]) par_inv = (r["apartamento_b_idx"], r["apartamento_a_idx"]) - assert par not in pares and par_inv not in pares, ( - f"Rivalidade duplicada: {par}" - ) + assert par not in pares and par_inv not in pares, f"Rivalidade duplicada: {par}" pares.add(par) diff --git a/backend/__tests__/test_services/test_condominio_service.py b/backend/__tests__/test_services/test_condominio_service.py index 3915a39..b2cfb2e 100644 --- a/backend/__tests__/test_services/test_condominio_service.py +++ b/backend/__tests__/test_services/test_condominio_service.py @@ -26,9 +26,7 @@ def service(repo_mock): class TestCriar: async def test_cria_condominio_sem_cnpj(self, service, repo_mock): - data = CondominioCreate( - nome="Condomínio Teste", endereco="Rua Teste, 100" - ) + data = CondominioCreate(nome="Condomínio Teste", endereco="Rua Teste, 100") repo_mock.create.return_value = Condominio( id=1, nome="Condomínio Teste", endereco="Rua Teste, 100" ) @@ -102,9 +100,7 @@ async def test_lanca_erro_nao_encontrado(self, service, repo_mock): class TestAtualizar: async def test_atualiza_sem_cnpj(self, service, repo_mock): - repo_mock.update.return_value = Condominio( - id=1, nome="Novo", endereco="Rua X" - ) + repo_mock.update.return_value = Condominio(id=1, nome="Novo", endereco="Rua X") data = CondominioUpdate(nome="Novo") result = await service.atualizar(1, data) assert result.nome == "Novo" diff --git a/backend/__tests__/test_services/test_morador_service.py b/backend/__tests__/test_services/test_morador_service.py index 932f7bd..7f377ea 100644 --- a/backend/__tests__/test_services/test_morador_service.py +++ b/backend/__tests__/test_services/test_morador_service.py @@ -24,7 +24,9 @@ def service(repo: MagicMock) -> MoradorService: class TestCriar: - async def test_cria_morador_sem_cpf_email_repetido(self, service: MoradorService, repo: MagicMock) -> None: + async def test_cria_morador_sem_cpf_email_repetido( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_cpf = AsyncMock(return_value=None) repo.get_by_email = AsyncMock(return_value=None) repo.create = AsyncMock(return_value=MagicMock(id=1)) @@ -39,7 +41,9 @@ async def test_cria_morador_sem_cpf_email_repetido(self, service: MoradorService assert result.id == 1 repo.create.assert_awaited_once() - async def test_rejeita_cpf_duplicado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_rejeita_cpf_duplicado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_cpf = AsyncMock(return_value=MagicMock()) repo.get_by_email = AsyncMock(return_value=None) @@ -52,7 +56,9 @@ async def test_rejeita_cpf_duplicado(self, service: MoradorService, repo: MagicM ) repo.create.assert_not_called() - async def test_rejeita_email_duplicado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_rejeita_email_duplicado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_cpf = AsyncMock(return_value=None) repo.get_by_email = AsyncMock(return_value=MagicMock()) @@ -83,14 +89,18 @@ async def test_lista_vazio(self, service: MoradorService, repo: MagicMock) -> No class TestBuscar: - async def test_retorna_morador_quando_encontrado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_retorna_morador_quando_encontrado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=MagicMock(id=1)) result = await service.buscar(1) assert result.id == 1 - async def test_lanca_excecao_quando_nao_encontrado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(MoradorNaoEncontrado): @@ -98,14 +108,18 @@ async def test_lanca_excecao_quando_nao_encontrado(self, service: MoradorService class TestListarPorApartamento: - async def test_retorna_moradores_do_apto(self, service: MoradorService, repo: MagicMock) -> None: + async def test_retorna_moradores_do_apto( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_apartamento = AsyncMock(return_value=[MagicMock(), MagicMock()]) result = await service.listar_por_apartamento(1) assert len(result) == 2 - async def test_retorna_vazio_quando_sem_moradores(self, service: MoradorService, repo: MagicMock) -> None: + async def test_retorna_vazio_quando_sem_moradores( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_apartamento = AsyncMock(return_value=[]) result = await service.listar_por_apartamento(999) @@ -114,7 +128,9 @@ async def test_retorna_vazio_quando_sem_moradores(self, service: MoradorService, class TestAtualizar: - async def test_atualiza_morador(self, service: MoradorService, repo: MagicMock) -> None: + async def test_atualiza_morador( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=MagicMock(id=1, cpf="111.222.333-44")) repo.update = AsyncMock(return_value=MagicMock(id=1, nome="Novo")) @@ -122,7 +138,9 @@ async def test_atualiza_morador(self, service: MoradorService, repo: MagicMock) assert result.id == 1 - async def test_nao_permite_cpf_ja_existente(self, service: MoradorService, repo: MagicMock) -> None: + async def test_nao_permite_cpf_ja_existente( + self, service: MoradorService, repo: MagicMock + ) -> None: existente = MagicMock(id=1, cpf="111.222.333-44") repo.get_by_id = AsyncMock(return_value=existente) repo.get_by_cpf = AsyncMock(return_value=MagicMock(id=2)) @@ -130,7 +148,9 @@ async def test_nao_permite_cpf_ja_existente(self, service: MoradorService, repo: with pytest.raises(MoradorComCPFJaExiste): await service.atualizar(1, {"cpf": "999.999.999-99"}) - async def test_nao_permite_email_ja_existente(self, service: MoradorService, repo: MagicMock) -> None: + async def test_nao_permite_email_ja_existente( + self, service: MoradorService, repo: MagicMock + ) -> None: existente = MagicMock(id=1, email="joao@email.com") repo.get_by_id = AsyncMock(return_value=existente) repo.get_by_email = AsyncMock(return_value=MagicMock(id=2)) @@ -138,7 +158,9 @@ async def test_nao_permite_email_ja_existente(self, service: MoradorService, rep with pytest.raises(MoradorComEmailJaExiste): await service.atualizar(1, {"email": "outro@email.com"}) - async def test_lanca_excecao_quando_nao_encontrado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(MoradorNaoEncontrado): @@ -146,14 +168,18 @@ async def test_lanca_excecao_quando_nao_encontrado(self, service: MoradorService class TestRemover: - async def test_remove_morador(self, service: MoradorService, repo: MagicMock) -> None: + async def test_remove_morador( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.delete = AsyncMock(return_value=True) await service.remover(1) repo.delete.assert_awaited_once_with(1) - async def test_lanca_excecao_quando_nao_encontrado(self, service: MoradorService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: MoradorService, repo: MagicMock + ) -> None: repo.delete = AsyncMock(return_value=False) with pytest.raises(MoradorNaoEncontrado): diff --git a/backend/__tests__/test_services/test_ocorrencia_service.py b/backend/__tests__/test_services/test_ocorrencia_service.py index e2f1d60..a554f1e 100644 --- a/backend/__tests__/test_services/test_ocorrencia_service.py +++ b/backend/__tests__/test_services/test_ocorrencia_service.py @@ -1,5 +1,6 @@ """Unit tests for OcorrenciaService.""" +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import pytest @@ -23,7 +24,7 @@ def service(repo: MagicMock) -> OcorrenciaService: def _make_ocorrencia(**kw: dict) -> MagicMock: - from datetime import datetime, timezone + from datetime import datetime vals: dict = { "id": 1, @@ -33,15 +34,17 @@ def _make_ocorrencia(**kw: dict) -> MagicMock: "gravidade": "media", "status": "aberta", "apartamento_id": 1, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) return MagicMock(**vals) class TestCriar: - async def test_cria_ocorrencia(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_cria_ocorrencia( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.create = AsyncMock(return_value=MagicMock(id=1)) result = await service.criar( titulo="Barulho", @@ -54,38 +57,50 @@ async def test_cria_ocorrencia(self, service: OcorrenciaService, repo: MagicMock class TestListar: - async def test_lista_com_filtros(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_lista_com_filtros( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_all = AsyncMock(return_value=[MagicMock(), MagicMock()]) result = await service.listar(categoria="barulho", status="aberta") assert len(result) == 2 repo.get_all.assert_awaited_once_with("barulho", "aberta", None, None) - async def test_lista_vazio(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_lista_vazio( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_all = AsyncMock(return_value=[]) assert await service.listar() == [] class TestBuscar: - async def test_retorna_quando_encontrado(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_retorna_quando_encontrado( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make_ocorrencia()) result = await service.buscar(1) assert result.id == 1 - async def test_lanca_excecao_quando_nao_encontrado(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(OcorrenciaNaoEncontrada): await service.buscar(999) class TestListarRecentes: - async def test_retorna_recentes(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_retorna_recentes( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.list_recentes = AsyncMock(return_value=[MagicMock()]) result = await service.listar_recentes() assert len(result) == 1 class TestAtualizarStatus: - async def test_transicao_valida(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_transicao_valida( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: ocorrencia = _make_ocorrencia(status="aberta") repo.get_by_id = AsyncMock(return_value=ocorrencia) repo.update = AsyncMock(return_value=_make_ocorrencia(status="investigando")) @@ -94,26 +109,35 @@ async def test_transicao_valida(self, service: OcorrenciaService, repo: MagicMoc assert result.status == "investigando" - async def test_transicao_invalida(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_transicao_invalida( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: ocorrencia = _make_ocorrencia(status="arquivada") repo.get_by_id = AsyncMock(return_value=ocorrencia) with pytest.raises(TransicaoStatusInvalida): await service.atualizar_status(1, "aberta") - @pytest.mark.parametrize("de,para,valido", [ - ("aberta", "investigando", True), - ("aberta", "resolvida", False), - ("aberta", "arquivada", True), - ("investigando", "resolvida", True), - ("investigando", "aberta", True), - ("resolvida", "arquivada", True), - ("resolvida", "aberta", False), - ("arquivada", "aberta", False), - ]) + @pytest.mark.parametrize( + "de,para,valido", + [ + ("aberta", "investigando", True), + ("aberta", "resolvida", False), + ("aberta", "arquivada", True), + ("investigando", "resolvida", True), + ("investigando", "aberta", True), + ("resolvida", "arquivada", True), + ("resolvida", "aberta", False), + ("arquivada", "aberta", False), + ], + ) async def test_todas_transicoes( - self, service: OcorrenciaService, repo: MagicMock, - de: str, para: str, valido: bool, + self, + service: OcorrenciaService, + repo: MagicMock, + de: str, + para: str, + valido: bool, ) -> None: repo.get_by_id = AsyncMock(return_value=_make_ocorrencia(status=de)) if valido: @@ -126,13 +150,17 @@ async def test_todas_transicoes( class TestAtualizar: - async def test_atualiza_campos(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_atualiza_campos( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make_ocorrencia()) repo.update = AsyncMock(return_value=_make_ocorrencia(titulo="Novo")) result = await service.atualizar(1, {"titulo": "Novo"}) assert result.titulo == "Novo" - async def test_lanca_excecao_quando_nao_encontrado(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(OcorrenciaNaoEncontrada): await service.atualizar(999, {"titulo": "X"}) @@ -144,7 +172,9 @@ async def test_remove(self, service: OcorrenciaService, repo: MagicMock) -> None await service.remover(1) repo.delete.assert_awaited_once_with(1) - async def test_lanca_excecao_quando_nao_encontrado(self, service: OcorrenciaService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: OcorrenciaService, repo: MagicMock + ) -> None: repo.delete = AsyncMock(return_value=False) with pytest.raises(OcorrenciaNaoEncontrada): await service.remover(999) diff --git a/backend/__tests__/test_services/test_rivalidade_service.py b/backend/__tests__/test_services/test_rivalidade_service.py index 92ca8bd..e0c7596 100644 --- a/backend/__tests__/test_services/test_rivalidade_service.py +++ b/backend/__tests__/test_services/test_rivalidade_service.py @@ -1,5 +1,6 @@ """Unit tests for RivalidadeService.""" +from datetime import UTC from unittest.mock import AsyncMock, MagicMock import pytest @@ -24,7 +25,7 @@ def service(repo: MagicMock) -> RivalidadeService: def _make_rivalidade(**kw: dict) -> MagicMock: - from datetime import datetime, timezone + from datetime import datetime vals: dict = { "id": 1, @@ -33,33 +34,43 @@ def _make_rivalidade(**kw: dict) -> MagicMock: "motivo": "Barulho", "nivel": "moderado", "status": "ativa", - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), } vals.update(kw) return MagicMock(**vals) class TestCriar: - async def test_cria_rivalidade(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_cria_rivalidade( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_between = AsyncMock(return_value=None) repo.create = AsyncMock(return_value=MagicMock(id=1)) result = await service.criar(101, 102, motivo="Barulho", nivel="moderado") assert result.id == 1 repo.create.assert_awaited_once() - async def test_rejeita_apartamentos_iguais(self, service: RivalidadeService, repo: MagicMock) -> None: - with pytest.raises(RivalidadeJaExiste, match="Não é possível criar rivalidade consigo mesmo"): + async def test_rejeita_apartamentos_iguais( + self, service: RivalidadeService, repo: MagicMock + ) -> None: + with pytest.raises( + RivalidadeJaExiste, match="Não é possível criar rivalidade consigo mesmo" + ): await service.criar(101, 101) repo.create.assert_not_called() - async def test_rejeita_rivalidade_ja_existente(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_rejeita_rivalidade_ja_existente( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_between = AsyncMock(return_value=MagicMock()) with pytest.raises(RivalidadeJaExiste): await service.criar(101, 102) repo.create.assert_not_called() - async def test_rejeita_nivel_invalido(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_rejeita_nivel_invalido( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_between = AsyncMock(return_value=None) with pytest.raises(NivelInvalido): await service.criar(101, 102, nivel="invalido") @@ -67,41 +78,55 @@ async def test_rejeita_nivel_invalido(self, service: RivalidadeService, repo: Ma class TestListar: - async def test_lista_todos(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_lista_todos( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_all = AsyncMock(return_value=[MagicMock(), MagicMock()]) result = await service.listar() assert len(result) == 2 - async def test_lista_vazio(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_lista_vazio( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_all = AsyncMock(return_value=[]) assert await service.listar() == [] class TestBuscar: - async def test_retorna_quando_encontrado(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_retorna_quando_encontrado( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make_rivalidade()) result = await service.buscar(1) assert result.id == 1 - async def test_lanca_excecao_quando_nao_encontrado(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(RivalidadeNaoEncontrada): await service.buscar(999) class TestListarPorApartamento: - async def test_retorna_rivalidades_do_apto(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_retorna_rivalidades_do_apto( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_apartamento = AsyncMock(return_value=[MagicMock(), MagicMock()]) result = await service.listar_por_apartamento(101) assert len(result) == 2 - async def test_retorna_vazio_quando_nenhuma(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_retorna_vazio_quando_nenhuma( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_apartamento = AsyncMock(return_value=[]) assert await service.listar_por_apartamento(999) == [] class TestTopRivalidades: - async def test_retorna_top_rivalidades(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_retorna_top_rivalidades( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.top_rivalidades = AsyncMock(return_value=[MagicMock(), MagicMock()]) result = await service.top_rivalidades(limite=5) assert len(result) == 2 @@ -109,14 +134,18 @@ async def test_retorna_top_rivalidades(self, service: RivalidadeService, repo: M class TestEscalar: - async def test_escala_nivel(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_escala_nivel( + self, service: RivalidadeService, repo: MagicMock + ) -> None: rivalidade = _make_rivalidade(nivel="moderado") repo.get_by_id = AsyncMock(return_value=rivalidade) repo.update = AsyncMock(return_value=_make_rivalidade(nivel="intenso")) result = await service.escalar(1) assert result.nivel == "intenso" - async def test_nao_escala_quando_ja_no_maximo(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_nao_escala_quando_ja_no_maximo( + self, service: RivalidadeService, repo: MagicMock + ) -> None: rivalidade = _make_rivalidade(nivel="belico") repo.get_by_id = AsyncMock(return_value=rivalidade) result = await service.escalar(1) @@ -125,19 +154,25 @@ async def test_nao_escala_quando_ja_no_maximo(self, service: RivalidadeService, class TestAtualizar: - async def test_atualiza_campos(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_atualiza_campos( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make_rivalidade()) repo.update = AsyncMock(return_value=_make_rivalidade(motivo="Novo motivo")) result = await service.atualizar(1, {"motivo": "Novo motivo"}) assert result.motivo == "Novo motivo" - async def test_rejeita_nivel_invalido(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_rejeita_nivel_invalido( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=_make_rivalidade()) with pytest.raises(NivelInvalido): await service.atualizar(1, {"nivel": "invalido"}) repo.update.assert_not_awaited() - async def test_lanca_excecao_quando_nao_encontrado(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.get_by_id = AsyncMock(return_value=None) with pytest.raises(RivalidadeNaoEncontrada): await service.atualizar(999, {"motivo": "X"}) @@ -150,7 +185,9 @@ async def test_remove(self, service: RivalidadeService, repo: MagicMock) -> None await service.remover(1) repo.delete.assert_awaited_once_with(1) - async def test_lanca_excecao_quando_nao_encontrado(self, service: RivalidadeService, repo: MagicMock) -> None: + async def test_lanca_excecao_quando_nao_encontrado( + self, service: RivalidadeService, repo: MagicMock + ) -> None: repo.delete = AsyncMock(return_value=False) with pytest.raises(RivalidadeNaoEncontrada): await service.remover(999) diff --git a/backend/__tests__/test_websocket.py b/backend/__tests__/test_websocket.py index ffafaea..723ba9b 100644 --- a/backend/__tests__/test_websocket.py +++ b/backend/__tests__/test_websocket.py @@ -1,7 +1,7 @@ """Tests for WebSocket /ws/ocorrencias feed.""" import json -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient @@ -72,7 +72,7 @@ def test_connect_with_valid_token(client: TestClient): token = jwt.encode( { "sub": "admin@condocombat.com", - "exp": datetime.now(timezone.utc) + timedelta(hours=1), + "exp": datetime.now(UTC) + timedelta(hours=1), }, settings.SECRET_KEY, algorithm=settings.ALGORITHM, diff --git a/backend/alembic/env.py b/backend/alembic/env.py index b1a4d06..1311f68 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,15 +1,21 @@ import asyncio from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.ext.asyncio import create_async_engine +from alembic import context from app.config import settings from app.database import Base # Import all models so Base.metadata is fully populated -from app.models import Condominio, Apartamento, Morador, Ocorrencia, Rivalidade # noqa: F401 +from app.models import ( # noqa: F401 + Apartamento, + Condominio, + Morador, + Ocorrencia, + Rivalidade, +) config = context.config if config.config_file_name is not None: diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py index 5ca7fe3..6086a6e 100644 --- a/backend/alembic/versions/001_initial_schema.py +++ b/backend/alembic/versions/001_initial_schema.py @@ -5,16 +5,16 @@ Create Date: 2026-05-31 12:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op revision: str = "001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: @@ -65,9 +65,7 @@ def upgrade() -> None: name="uq_apartamento_identificacao", ), ) - op.create_index( - "ix_apartamento_condominio", "apartamentos", ["condominio_id"] - ) + op.create_index("ix_apartamento_condominio", "apartamentos", ["condominio_id"]) # --- moradores --- op.create_table( @@ -77,9 +75,7 @@ def upgrade() -> None: sa.Column("cpf", sa.String(14), nullable=False, unique=True), sa.Column("email", sa.String(200), nullable=False, unique=True), sa.Column("telefone", sa.String(20), nullable=True), - sa.Column( - "tipo", sa.String(20), nullable=False, server_default="proprietario" - ), + sa.Column("tipo", sa.String(20), nullable=False, server_default="proprietario"), sa.Column("apartamento_id", sa.Integer(), nullable=False), sa.Column( "created_at", @@ -94,9 +90,7 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), ) - op.create_index( - "ix_morador_apartamento", "moradores", ["apartamento_id"] - ) + op.create_index("ix_morador_apartamento", "moradores", ["apartamento_id"]) # --- ocorrencias --- op.create_table( @@ -105,12 +99,8 @@ def upgrade() -> None: sa.Column("titulo", sa.String(200), nullable=False), sa.Column("descricao", sa.Text(), nullable=False), sa.Column("categoria", sa.String(50), nullable=False), - sa.Column( - "gravidade", sa.String(20), nullable=False, server_default="media" - ), - sa.Column( - "status", sa.String(20), nullable=False, server_default="aberta" - ), + sa.Column("gravidade", sa.String(20), nullable=False, server_default="media"), + sa.Column("status", sa.String(20), nullable=False, server_default="aberta"), sa.Column("apartamento_id", sa.Integer(), nullable=False), sa.Column( "created_at", @@ -131,18 +121,10 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), ) - op.create_index( - "ix_ocorrencia_categoria", "ocorrencias", ["categoria"] - ) - op.create_index( - "ix_ocorrencia_status", "ocorrencias", ["status"] - ) - op.create_index( - "ix_ocorrencia_apartamento", "ocorrencias", ["apartamento_id"] - ) - op.create_index( - "ix_ocorrencia_created_at", "ocorrencias", ["created_at"] - ) + op.create_index("ix_ocorrencia_categoria", "ocorrencias", ["categoria"]) + op.create_index("ix_ocorrencia_status", "ocorrencias", ["status"]) + op.create_index("ix_ocorrencia_apartamento", "ocorrencias", ["apartamento_id"]) + op.create_index("ix_ocorrencia_created_at", "ocorrencias", ["created_at"]) # --- rivalidades --- op.create_table( @@ -151,12 +133,8 @@ def upgrade() -> None: sa.Column("apartamento_a_id", sa.Integer(), nullable=False), sa.Column("apartamento_b_id", sa.Integer(), nullable=False), sa.Column("motivo", sa.String(200), nullable=True), - sa.Column( - "nivel", sa.String(20), nullable=False, server_default="moderado" - ), - sa.Column( - "status", sa.String(20), nullable=False, server_default="ativa" - ), + sa.Column("nivel", sa.String(20), nullable=False, server_default="moderado"), + sa.Column("status", sa.String(20), nullable=False, server_default="ativa"), sa.Column( "created_at", sa.DateTime(timezone=True), diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index 87722d7..f73d661 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -1,22 +1,22 @@ +from app.auth.dependencies import get_current_user, oauth2_scheme from app.auth.schemas import LoginRequest, TokenResponse, UserRead from app.auth.utils import ( - verify_password, - get_password_hash, + MOCK_USERS, create_access_token, decode_access_token, - MOCK_USERS, + get_password_hash, + verify_password, ) -from app.auth.dependencies import get_current_user, oauth2_scheme __all__ = [ + "MOCK_USERS", "LoginRequest", "TokenResponse", "UserRead", - "verify_password", - "get_password_hash", "create_access_token", "decode_access_token", - "MOCK_USERS", "get_current_user", + "get_password_hash", "oauth2_scheme", + "verify_password", ] diff --git a/backend/app/auth/utils.py b/backend/app/auth/utils.py index b479e85..8056520 100644 --- a/backend/app/auth/utils.py +++ b/backend/app/auth/utils.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext @@ -35,9 +35,7 @@ def get_password_hash(password: str) -> str: def create_access_token(data: dict) -> str: to_encode = data.copy() - expire = datetime.now(timezone.utc) + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) + expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) diff --git a/backend/app/config.py b/backend/app/config.py index aeac779..55eab32 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,3 @@ -from app.config.settings import settings, Settings +from app.config.settings import Settings, settings -__all__ = ["settings", "Settings"] +__all__ = ["Settings", "settings"] diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py index aeac779..55eab32 100644 --- a/backend/app/config/__init__.py +++ b/backend/app/config/__init__.py @@ -1,3 +1,3 @@ -from app.config.settings import settings, Settings +from app.config.settings import Settings, settings -__all__ = ["settings", "Settings"] +__all__ = ["Settings", "settings"] diff --git a/backend/app/main.py b/backend/app/main.py index 710806d..10e8143 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,13 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager - from app.config.settings import settings from app.routers import auth, condominio, morador, ocorrencia, rivalidade, ws from app.services.ws_manager import manager + @asynccontextmanager async def lifespan(_app: FastAPI): yield diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 37bad87..4abadb9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,14 +1,14 @@ from app.database import Base -from app.models.condominio import Condominio from app.models.apartamento import Apartamento +from app.models.condominio import Condominio from app.models.morador import Morador from app.models.ocorrencia import Ocorrencia from app.models.rivalidade import Rivalidade __all__ = [ + "Apartamento", "Base", "Condominio", - "Apartamento", "Morador", "Ocorrencia", "Rivalidade", diff --git a/backend/app/models/apartamento.py b/backend/app/models/apartamento.py index d0344eb..6c87d9e 100644 --- a/backend/app/models/apartamento.py +++ b/backend/app/models/apartamento.py @@ -11,7 +11,10 @@ class Apartamento(Base): __table_args__ = ( UniqueConstraint( - "numero", "bloco", "torre", "condominio_id", + "numero", + "bloco", + "torre", + "condominio_id", name="uq_apartamento_identificacao", ), ) @@ -25,9 +28,9 @@ class Apartamento(Base): ForeignKey("condominios.id"), nullable=False, index=True ) - condominio: Mapped["Condominio"] = relationship( # noqa: F821 + condominio: Mapped[Condominio] = relationship( # noqa: F821 "Condominio", back_populates="apartamentos" ) - moradores: Mapped[list["Morador"]] = relationship( # noqa: F821 + moradores: Mapped[list[Morador]] = relationship( # noqa: F821 "Morador", back_populates="apartamento" ) diff --git a/backend/app/models/condominio.py b/backend/app/models/condominio.py index e0257a3..2ce9ce8 100644 --- a/backend/app/models/condominio.py +++ b/backend/app/models/condominio.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -17,12 +17,12 @@ class Condominio(Base): email: Mapped[str | None] = mapped_column(String(200), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), ) apartamentos: Mapped[list["Apartamento"]] = relationship( # noqa: F821 diff --git a/backend/app/models/morador.py b/backend/app/models/morador.py index 554c851..daf8a5b 100644 --- a/backend/app/models/morador.py +++ b/backend/app/models/morador.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import DateTime, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -24,9 +24,9 @@ class Morador(Base): ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), ) - apartamento: Mapped["Apartamento"] = relationship( # noqa: F821 + apartamento: Mapped[Apartamento] = relationship( # noqa: F821 "Apartamento", back_populates="moradores" ) diff --git a/backend/app/models/ocorrencia.py b/backend/app/models/ocorrencia.py index 12ccf48..b7aacf0 100644 --- a/backend/app/models/ocorrencia.py +++ b/backend/app/models/ocorrencia.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -12,12 +12,8 @@ class Ocorrencia(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) titulo: Mapped[str] = mapped_column(String(200), nullable=False) descricao: Mapped[str] = mapped_column(Text, nullable=False) - categoria: Mapped[str] = mapped_column( - String(50), nullable=False, index=True - ) - gravidade: Mapped[str] = mapped_column( - String(20), default="media", nullable=False - ) + categoria: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + gravidade: Mapped[str] = mapped_column(String(20), default="media", nullable=False) status: Mapped[str] = mapped_column( String(20), default="aberta", nullable=False, index=True ) @@ -26,13 +22,13 @@ class Ocorrencia(Base): ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), index=True, ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), ) apartamento_origem: Mapped["Apartamento"] = relationship( # noqa: F821 diff --git a/backend/app/models/rivalidade.py b/backend/app/models/rivalidade.py index 235f8a1..cbff36f 100644 --- a/backend/app/models/rivalidade.py +++ b/backend/app/models/rivalidade.py @@ -1,5 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone + +from datetime import UTC, datetime from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -12,7 +13,8 @@ class Rivalidade(Base): __table_args__ = ( UniqueConstraint( - "apartamento_a_id", "apartamento_b_id", + "apartamento_a_id", + "apartamento_b_id", name="uq_rivalidade_apartamentos", ), ) @@ -25,25 +27,21 @@ class Rivalidade(Base): ForeignKey("apartamentos.id"), nullable=False, index=True ) motivo: Mapped[str | None] = mapped_column(String(200), nullable=True) - nivel: Mapped[str] = mapped_column( - String(20), default="moderado", nullable=False - ) - status: Mapped[str] = mapped_column( - String(20), default="ativa", nullable=False - ) + nivel: Mapped[str] = mapped_column(String(20), default="moderado", nullable=False) + status: Mapped[str] = mapped_column(String(20), default="ativa", nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), ) - apartamento_a: Mapped["Apartamento"] = relationship( # noqa: F821 + apartamento_a: Mapped[Apartamento] = relationship( # noqa: F821 "Apartamento", foreign_keys=[apartamento_a_id], backref="rivalidades_como_a" ) - apartamento_b: Mapped["Apartamento"] = relationship( # noqa: F821 + apartamento_b: Mapped[Apartamento] = relationship( # noqa: F821 "Apartamento", foreign_keys=[apartamento_b_id], backref="rivalidades_como_b" ) diff --git a/backend/app/repositories/morador.py b/backend/app/repositories/morador.py index 5856c20..9781870 100644 --- a/backend/app/repositories/morador.py +++ b/backend/app/repositories/morador.py @@ -25,9 +25,7 @@ async def get_all(self) -> list[Morador]: return list(result.scalars().all()) async def get_by_cpf(self, cpf: str) -> Morador | None: - result = await self.session.execute( - select(Morador).where(Morador.cpf == cpf) - ) + result = await self.session.execute(select(Morador).where(Morador.cpf == cpf)) return result.scalar_one_or_none() async def get_by_email(self, email: str) -> Morador | None: diff --git a/backend/app/repositories/ocorrencia.py b/backend/app/repositories/ocorrencia.py index ac1725a..c674c07 100644 --- a/backend/app/repositories/ocorrencia.py +++ b/backend/app/repositories/ocorrencia.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,10 +29,7 @@ async def get_all( gravidade: str | None = None, apartamento_id: int | None = None, ) -> list[Ocorrencia]: - stmt = ( - select(Ocorrencia) - .order_by(Ocorrencia.created_at.desc()) - ) + stmt = select(Ocorrencia).order_by(Ocorrencia.created_at.desc()) if categoria is not None: stmt = stmt.where(Ocorrencia.categoria == categoria) if status is not None: @@ -45,7 +42,7 @@ async def get_all( return list(result.scalars().all()) async def list_recentes(self) -> list[Ocorrencia]: - limite = datetime.now(timezone.utc) - timedelta(hours=24) + limite = datetime.now(UTC) - timedelta(hours=24) result = await self.session.execute( select(Ocorrencia) .where(Ocorrencia.created_at >= limite) diff --git a/backend/app/repositories/rivalidade.py b/backend/app/repositories/rivalidade.py index 7b15ede..9001808 100644 --- a/backend/app/repositories/rivalidade.py +++ b/backend/app/repositories/rivalidade.py @@ -35,9 +35,7 @@ async def get_by_apartamento(self, apartamento_id: int) -> list[Rivalidade]: ) return list(result.scalars().all()) - async def get_between( - self, apto_a: int, apto_b: int - ) -> Rivalidade | None: + async def get_between(self, apto_a: int, apto_b: int) -> Rivalidade | None: result = await self.session.execute( select(Rivalidade).where( or_( @@ -50,20 +48,14 @@ async def get_between( ) return result.scalar_one_or_none() - async def top_rivalidades( - self, limite: int = 10 - ) -> list[Rivalidade]: + async def top_rivalidades(self, limite: int = 10) -> list[Rivalidade]: ordem = ["belico", "intenso", "moderado", "leve"] result = await self.session.execute(select(Rivalidade)) todas = list(result.scalars().all()) - todas.sort( - key=lambda r: ordem.index(r.nivel) if r.nivel in ordem else 99 - ) + todas.sort(key=lambda r: ordem.index(r.nivel) if r.nivel in ordem else 99) return todas[:limite] - async def update( - self, rivalidade_id: int, dados: dict - ) -> Rivalidade | None: + async def update(self, rivalidade_id: int, dados: dict) -> Rivalidade | None: rivalidade = await self.get_by_id(rivalidade_id) if rivalidade is None: return None diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 916ddeb..1dee426 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -1,3 +1,3 @@ from app.routers import auth -__all__ = ["auth"] \ No newline at end of file +__all__ = ["auth"] diff --git a/backend/app/routers/condominio.py b/backend/app/routers/condominio.py index 0ff7550..075ea3b 100644 --- a/backend/app/routers/condominio.py +++ b/backend/app/routers/condominio.py @@ -15,7 +15,9 @@ router = APIRouter(prefix="/condominios", tags=["condominios"]) -async def _get_service(session: AsyncSession = Depends(get_session)) -> CondominioService: +async def _get_service( + session: AsyncSession = Depends(get_session), +) -> CondominioService: return CondominioService(CondominioRepository(session)) @@ -38,7 +40,9 @@ async def obter(condominio_id: int, service: CondominioService = Depends(_get_se @router.post("/", response_model=CondominioRead, status_code=status.HTTP_201_CREATED) -async def criar(data: CondominioCreate, service: CondominioService = Depends(_get_service)): +async def criar( + data: CondominioCreate, service: CondominioService = Depends(_get_service) +): """Cria um novo condomínio.""" try: return await service.criar(data) @@ -71,7 +75,9 @@ async def atualizar( @router.delete("/{condominio_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remover(condominio_id: int, service: CondominioService = Depends(_get_service)): +async def remover( + condominio_id: int, service: CondominioService = Depends(_get_service) +): """Remove um condomínio.""" try: await service.remover(condominio_id) diff --git a/backend/app/routers/morador.py b/backend/app/routers/morador.py index c2293bf..c3ec55b 100644 --- a/backend/app/routers/morador.py +++ b/backend/app/routers/morador.py @@ -24,7 +24,9 @@ async def listar_moradores(service: MoradorService = Depends(_get_service)): @router.get("/{morador_id}", response_model=MoradorRead) -async def obter_morador(morador_id: int, service: MoradorService = Depends(_get_service)): +async def obter_morador( + morador_id: int, service: MoradorService = Depends(_get_service) +): try: return await service.buscar(morador_id) except MoradorNaoEncontrado as e: @@ -32,7 +34,9 @@ async def obter_morador(morador_id: int, service: MoradorService = Depends(_get_ @router.post("/", response_model=MoradorRead, status_code=status.HTTP_201_CREATED) -async def criar_morador(data: MoradorCreate, service: MoradorService = Depends(_get_service)): +async def criar_morador( + data: MoradorCreate, service: MoradorService = Depends(_get_service) +): try: return await service.criar( nome=data.nome, @@ -47,10 +51,17 @@ async def criar_morador(data: MoradorCreate, service: MoradorService = Depends(_ @router.put("/{morador_id}", response_model=MoradorRead) -async def atualizar_morador(morador_id: int, data: MoradorUpdate, service: MoradorService = Depends(_get_service)): +async def atualizar_morador( + morador_id: int, + data: MoradorUpdate, + service: MoradorService = Depends(_get_service), +): update_data = data.model_dump(exclude_unset=True) if not update_data: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Nenhum campo para atualizar") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Nenhum campo para atualizar", + ) try: return await service.atualizar(morador_id, update_data) except MoradorNaoEncontrado as e: @@ -60,7 +71,9 @@ async def atualizar_morador(morador_id: int, data: MoradorUpdate, service: Morad @router.delete("/{morador_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remover_morador(morador_id: int, service: MoradorService = Depends(_get_service)): +async def remover_morador( + morador_id: int, service: MoradorService = Depends(_get_service) +): try: await service.remover(morador_id) except MoradorNaoEncontrado as e: diff --git a/backend/app/routers/ocorrencia.py b/backend/app/routers/ocorrencia.py index be2b1f2..db4b747 100644 --- a/backend/app/routers/ocorrencia.py +++ b/backend/app/routers/ocorrencia.py @@ -17,7 +17,9 @@ router = APIRouter(prefix="/ocorrencias", tags=["ocorrencias"]) -async def _get_service(session: AsyncSession = Depends(get_session)) -> OcorrenciaService: +async def _get_service( + session: AsyncSession = Depends(get_session), +) -> OcorrenciaService: return OcorrenciaService(OcorrenciaRepository(session)) @@ -55,7 +57,9 @@ async def obter(ocorrencia_id: int, service: OcorrenciaService = Depends(_get_se @router.post("/", response_model=OcorrenciaRead, status_code=status.HTTP_201_CREATED) -async def criar(data: OcorrenciaCreate, service: OcorrenciaService = Depends(_get_service)): +async def criar( + data: OcorrenciaCreate, service: OcorrenciaService = Depends(_get_service) +): ocorrencia = await service.criar( titulo=data.titulo, descricao=data.descricao, @@ -93,14 +97,20 @@ async def atualizar( await _broadcast_event( EventType.OCORRENCIA_ATUALIZADA, - {"ocorrencia_id": ocorrencia.id, "status": ocorrencia.status, "titulo": ocorrencia.titulo}, + { + "ocorrencia_id": ocorrencia.id, + "status": ocorrencia.status, + "titulo": ocorrencia.titulo, + }, ocorrencia_id=ocorrencia.id, ) return ocorrencia @router.delete("/{ocorrencia_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remover(ocorrencia_id: int, service: OcorrenciaService = Depends(_get_service)): +async def remover( + ocorrencia_id: int, service: OcorrenciaService = Depends(_get_service) +): try: await service.remover(ocorrencia_id) except OcorrenciaNaoEncontrada as e: diff --git a/backend/app/routers/rivalidade.py b/backend/app/routers/rivalidade.py index 9e63c74..a911319 100644 --- a/backend/app/routers/rivalidade.py +++ b/backend/app/routers/rivalidade.py @@ -16,7 +16,9 @@ router = APIRouter(prefix="/rivalidades", tags=["rivalidades"]) -async def _get_service(session: AsyncSession = Depends(get_session)) -> RivalidadeService: +async def _get_service( + session: AsyncSession = Depends(get_session), +) -> RivalidadeService: return RivalidadeService(RivalidadeRepository(session)) @@ -50,7 +52,9 @@ async def obter(rivalidade_id: int, service: RivalidadeService = Depends(_get_se @router.post("/", response_model=RivalidadeRead, status_code=status.HTTP_201_CREATED) -async def criar(data: RivalidadeCreate, service: RivalidadeService = Depends(_get_service)): +async def criar( + data: RivalidadeCreate, service: RivalidadeService = Depends(_get_service) +): try: return await service.criar( apartamento_a_id=data.apartamento_a_id, @@ -63,7 +67,9 @@ async def criar(data: RivalidadeCreate, service: RivalidadeService = Depends(_ge @router.post("/{rivalidade_id}/escalar", response_model=RivalidadeRead) -async def escalar(rivalidade_id: int, service: RivalidadeService = Depends(_get_service)): +async def escalar( + rivalidade_id: int, service: RivalidadeService = Depends(_get_service) +): try: return await service.escalar(rivalidade_id) except RivalidadeNaoEncontrada as e: @@ -91,7 +97,9 @@ async def atualizar( @router.delete("/{rivalidade_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remover(rivalidade_id: int, service: RivalidadeService = Depends(_get_service)): +async def remover( + rivalidade_id: int, service: RivalidadeService = Depends(_get_service) +): try: await service.remover(rivalidade_id) except RivalidadeNaoEncontrada as e: diff --git a/backend/app/routers/ws.py b/backend/app/routers/ws.py index 4ba5033..187e9d9 100644 --- a/backend/app/routers/ws.py +++ b/backend/app/routers/ws.py @@ -50,8 +50,8 @@ async def ws_ocorrencias(websocket: WebSocket): except json.JSONDecodeError: pong = WSMessage( - type=EventType.PONG, - data={"received": raw}, + type=EventType.PONG, + data={"received": raw}, ) await manager.send_personal(pong, websocket) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 2c82aef..1e7de81 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,16 +1,20 @@ +from app.schemas.apartamento import ( + ApartamentoCreate, + ApartamentoRead, + ApartamentoUpdate, +) from app.schemas.condominio import CondominioCreate, CondominioRead, CondominioUpdate -from app.schemas.apartamento import ApartamentoCreate, ApartamentoRead, ApartamentoUpdate from app.schemas.morador import MoradorCreate, MoradorRead, MoradorUpdate from app.schemas.ocorrencia import OcorrenciaCreate, OcorrenciaRead, OcorrenciaUpdate from app.schemas.rivalidade import RivalidadeCreate, RivalidadeRead, RivalidadeUpdate __all__ = [ - "CondominioCreate", - "CondominioRead", - "CondominioUpdate", "ApartamentoCreate", "ApartamentoRead", "ApartamentoUpdate", + "CondominioCreate", + "CondominioRead", + "CondominioUpdate", "MoradorCreate", "MoradorRead", "MoradorUpdate", diff --git a/backend/app/schemas/condominio.py b/backend/app/schemas/condominio.py index c582660..886691c 100644 --- a/backend/app/schemas/condominio.py +++ b/backend/app/schemas/condominio.py @@ -1,7 +1,7 @@ +import re from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, field_validator -import re class CondominioBase(BaseModel): diff --git a/backend/app/schemas/morador.py b/backend/app/schemas/morador.py index 02e80ae..de81dbb 100644 --- a/backend/app/schemas/morador.py +++ b/backend/app/schemas/morador.py @@ -1,7 +1,7 @@ +import re from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, field_validator -import re class MoradorBase(BaseModel): @@ -55,9 +55,7 @@ def validate_tipo(cls, v: str | None) -> str | None: if v is not None: allowed = {"proprietario", "inquilino", "sindico"} if v not in allowed: - raise ValueError( - f"tipo deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"tipo deve ser um de: {', '.join(sorted(allowed))}") return v diff --git a/backend/app/schemas/ocorrencia.py b/backend/app/schemas/ocorrencia.py index 6d80948..796803b 100644 --- a/backend/app/schemas/ocorrencia.py +++ b/backend/app/schemas/ocorrencia.py @@ -16,9 +16,7 @@ class OcorrenciaBase(BaseModel): def validate_categoria(cls, v: str) -> str: allowed = {"barulho", "briga", "festa", "obra", "animal", "outra"} if v not in allowed: - raise ValueError( - f"categoria deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"categoria deve ser um de: {', '.join(sorted(allowed))}") return v @field_validator("gravidade") @@ -26,9 +24,7 @@ def validate_categoria(cls, v: str) -> str: def validate_gravidade(cls, v: str) -> str: allowed = {"baixa", "media", "alta", "critica"} if v not in allowed: - raise ValueError( - f"gravidade deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"gravidade deve ser um de: {', '.join(sorted(allowed))}") return v @field_validator("status") @@ -36,9 +32,7 @@ def validate_gravidade(cls, v: str) -> str: def validate_status(cls, v: str) -> str: allowed = {"aberta", "investigando", "resolvida", "arquivada"} if v not in allowed: - raise ValueError( - f"status deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"status deve ser um de: {', '.join(sorted(allowed))}") return v @@ -81,9 +75,7 @@ def validate_status(cls, v: str | None) -> str | None: if v is not None: allowed = {"aberta", "investigando", "resolvida", "arquivada"} if v not in allowed: - raise ValueError( - f"status deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"status deve ser um de: {', '.join(sorted(allowed))}") return v diff --git a/backend/app/schemas/rivalidade.py b/backend/app/schemas/rivalidade.py index 4e2cef8..cddc907 100644 --- a/backend/app/schemas/rivalidade.py +++ b/backend/app/schemas/rivalidade.py @@ -15,9 +15,7 @@ class RivalidadeBase(BaseModel): def validate_nivel(cls, v: str) -> str: allowed = {"leve", "moderado", "intenso", "belico"} if v not in allowed: - raise ValueError( - f"nivel deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"nivel deve ser um de: {', '.join(sorted(allowed))}") return v @field_validator("status") @@ -25,9 +23,7 @@ def validate_nivel(cls, v: str) -> str: def validate_status(cls, v: str) -> str: allowed = {"ativa", "congelada", "resolvida"} if v not in allowed: - raise ValueError( - f"status deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"status deve ser um de: {', '.join(sorted(allowed))}") return v @@ -46,9 +42,7 @@ def validate_nivel(cls, v: str | None) -> str | None: if v is not None: allowed = {"leve", "moderado", "intenso", "belico"} if v not in allowed: - raise ValueError( - f"nivel deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"nivel deve ser um de: {', '.join(sorted(allowed))}") return v @field_validator("status") @@ -57,9 +51,7 @@ def validate_status(cls, v: str | None) -> str | None: if v is not None: allowed = {"ativa", "congelada", "resolvida"} if v not in allowed: - raise ValueError( - f"status deve ser um de: {', '.join(sorted(allowed))}" - ) + raise ValueError(f"status deve ser um de: {', '.join(sorted(allowed))}") return v diff --git a/backend/app/schemas/ws_message.py b/backend/app/schemas/ws_message.py index 4504329..882dc60 100644 --- a/backend/app/schemas/ws_message.py +++ b/backend/app/schemas/ws_message.py @@ -1,6 +1,6 @@ """WebSocket message schemas for real-time Ocorrencia feed.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from pydantic import BaseModel, Field @@ -19,5 +19,4 @@ class WSMessage(BaseModel): type: EventType data: dict | None = None - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/backend/app/services/condominio.py b/backend/app/services/condominio.py index c05d152..cba1687 100644 --- a/backend/app/services/condominio.py +++ b/backend/app/services/condominio.py @@ -36,14 +36,10 @@ async def listar(self) -> Sequence[Condominio]: async def buscar(self, condominio_id: int) -> Condominio: condominio = await self.repository.get_by_id(condominio_id) if condominio is None: - raise CondominioNaoEncontrado( - f"Condomínio {condominio_id} não encontrado" - ) + raise CondominioNaoEncontrado(f"Condomínio {condominio_id} não encontrado") return condominio - async def atualizar( - self, condominio_id: int, data: CondominioUpdate - ) -> Condominio: + async def atualizar(self, condominio_id: int, data: CondominioUpdate) -> Condominio: if data.cnpj: existente = await self.repository.get_by_cnpj(data.cnpj) if existente is not None and existente.id != condominio_id: @@ -52,14 +48,10 @@ async def atualizar( ) condominio = await self.repository.update(condominio_id, data) if condominio is None: - raise CondominioNaoEncontrado( - f"Condomínio {condominio_id} não encontrado" - ) + raise CondominioNaoEncontrado(f"Condomínio {condominio_id} não encontrado") return condominio async def remover(self, condominio_id: int) -> None: removido = await self.repository.delete(condominio_id) if not removido: - raise CondominioNaoEncontrado( - f"Condomínio {condominio_id} não encontrado" - ) + raise CondominioNaoEncontrado(f"Condomínio {condominio_id} não encontrado") diff --git a/backend/app/services/morador.py b/backend/app/services/morador.py index 1fb43a6..2405cde 100644 --- a/backend/app/services/morador.py +++ b/backend/app/services/morador.py @@ -65,12 +65,16 @@ async def atualizar(self, morador_id: int, dados: dict) -> Morador: if "cpf" in dados and dados["cpf"] != existente.cpf: outro = await self.repo.get_by_cpf(dados["cpf"]) if outro is not None: - raise MoradorComCPFJaExiste(f"CPF {dados['cpf']} já pertence a outro morador") + raise MoradorComCPFJaExiste( + f"CPF {dados['cpf']} já pertence a outro morador" + ) if "email" in dados and dados["email"] != existente.email: outro = await self.repo.get_by_email(dados["email"]) if outro is not None: - raise MoradorComEmailJaExiste(f"Email {dados['email']} já pertence a outro morador") + raise MoradorComEmailJaExiste( + f"Email {dados['email']} já pertence a outro morador" + ) atualizado = await self.repo.update(morador_id, dados) if atualizado is None: diff --git a/backend/app/services/ocorrencia.py b/backend/app/services/ocorrencia.py index bb2b1da..f633a06 100644 --- a/backend/app/services/ocorrencia.py +++ b/backend/app/services/ocorrencia.py @@ -60,7 +60,9 @@ async def buscar(self, ocorrencia_id: int) -> Ocorrencia: async def listar_recentes(self) -> list[Ocorrencia]: return await self.repo.list_recentes() - async def atualizar_status(self, ocorrencia_id: int, novo_status: str) -> Ocorrencia: + async def atualizar_status( + self, ocorrencia_id: int, novo_status: str + ) -> Ocorrencia: ocorrencia = await self.buscar(ocorrencia_id) transicoes = STATUS_VALIDAS.get(ocorrencia.status, set()) if novo_status not in transicoes: diff --git a/backend/app/services/rivalidade.py b/backend/app/services/rivalidade.py index b760758..eac1658 100644 --- a/backend/app/services/rivalidade.py +++ b/backend/app/services/rivalidade.py @@ -37,7 +37,9 @@ async def criar( ) if nivel not in NIVEIS: - raise NivelInvalido(f"Nível '{nivel}' inválido. Válidos: {', '.join(NIVEIS)}") + raise NivelInvalido( + f"Nível '{nivel}' inválido. Válidos: {', '.join(NIVEIS)}" + ) rivalidade = Rivalidade( apartamento_a_id=apartamento_a_id, diff --git a/backend/app/services/ws_manager.py b/backend/app/services/ws_manager.py index 87603fe..ce54dd0 100644 --- a/backend/app/services/ws_manager.py +++ b/backend/app/services/ws_manager.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from fastapi import WebSocket +from starlette.websockets import WebSocketDisconnect from app.schemas.ws_message import EventType, WSMessage @@ -15,6 +16,17 @@ PONG_TIMEOUT = 10 # seconds to wait for PONG before disconnecting +# Exceptions raised on send failures we want to handle explicitly. +# Keep this list narrow to avoid catching unrelated errors. +WS_SEND_EXCEPTIONS = ( + WebSocketDisconnect, # normal client disconnect via starlette/fastapi + RuntimeError, # sometimes raised on closed transports + ConnectionResetError, # connection reset + BrokenPipeError, # broken pipe on send + OSError, # lower-level socket errors +) + + @dataclass class ConnectionInfo: """State for a single WebSocket connection.""" @@ -26,7 +38,11 @@ class ConnectionInfo: class WSConnectionManager: """Gerencia conexões WebSocket com broadcast e heartbeat.""" - def __init__(self, heartbeat_interval: int = HEARTBEAT_INTERVAL, pong_timeout: int = PONG_TIMEOUT) -> None: + def __init__( + self, + heartbeat_interval: int = HEARTBEAT_INTERVAL, + pong_timeout: int = PONG_TIMEOUT, + ) -> None: self._connections: dict[WebSocket, ConnectionInfo] = {} self._heartbeat_tasks: dict[WebSocket, asyncio.Task] = {} self._heartbeat_interval = heartbeat_interval @@ -73,22 +89,27 @@ async def broadcast(self, message: WSMessage) -> None: """Envia mensagem para todas as conexões ativas.""" payload = message.model_dump(mode="json") disconnected: list[WebSocket] = [] - for ws in self._connections: + # iterate over a snapshot of the keys to avoid runtime changes + for ws in list(self._connections.keys()): try: await ws.send_json(payload) - except Exception: + except WS_SEND_EXCEPTIONS as exc: + logger.debug("WS broadcast send failed, scheduling disconnect: %s", exc) disconnected.append(ws) for ws in disconnected: self.disconnect(ws) - async def send_personal( - self, message: WSMessage, websocket: WebSocket - ) -> None: + async def send_personal(self, message: WSMessage, websocket: WebSocket) -> None: """Envia mensagem para uma conexão específica.""" payload = message.model_dump(mode="json") try: await websocket.send_json(payload) - except Exception: + except WS_SEND_EXCEPTIONS as exc: + logger.debug( + "WS send_personal failed for %s: %s", + getattr(websocket, "client", None), + exc, + ) self.disconnect(websocket) async def _heartbeat_loop(self, websocket: WebSocket) -> None: @@ -106,7 +127,8 @@ async def _heartbeat_loop(self, websocket: WebSocket) -> None: ping = WSMessage(type=EventType.PING) try: await websocket.send_json(ping.model_dump(mode="json")) - except Exception: + except WS_SEND_EXCEPTIONS as exc: + logger.debug("WS heartbeat ping failed, disconnecting: %s", exc) self.disconnect(websocket) break @@ -118,12 +140,11 @@ async def _heartbeat_loop(self, websocket: WebSocket) -> None: break if info.last_pong == before: - logger.warning( - "WS heartbeat timeout — no PONG, disconnecting" - ) + logger.warning("WS heartbeat timeout — no PONG, disconnecting") self.disconnect(websocket) break except asyncio.CancelledError: + # Let cancellation propagate silently pass async def shutdown(self) -> None: diff --git a/backend/conftest.py b/backend/conftest.py index c214c13..85c2fcd 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -21,7 +21,7 @@ def engine(): connect_args={"check_same_thread": False}, poolclass=StaticPool, ) - + # Enable foreign key constraints for SQLite @event.listens_for(engine.sync_engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): @@ -29,7 +29,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA unique_check=ON") cursor.close() - + yield engine @@ -46,7 +46,9 @@ async def create_tables(engine): @pytest.fixture async def async_session(engine, create_tables): """Create async session for integration tests.""" - async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async_session_maker = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) async with async_session_maker() as session: yield session @@ -60,6 +62,7 @@ def mock_session(): sync como scalar_one_or_none() e scalars().all() funcionem. """ from unittest.mock import AsyncMock, MagicMock + from sqlalchemy.ext.asyncio import AsyncSession session = AsyncMock(spec=AsyncSession) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..d14f003 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# Valida SECRET_KEY obrigatória +if [ -z "$SECRET_KEY" ]; then + echo "❌ ERRO: SECRET_KEY não está definida!" + echo " Gere uma com: python -c 'import secrets; print(secrets.token_urlsafe(32))'" + exit 1 +fi + +echo "🔧 Rodando migrations do Alembic..." +alembic upgrade head + +echo "🚀 Iniciando servidor FastAPI..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index 3d3271b..6bf05ba 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -59,8 +59,18 @@ {"numero": "05", "bloco": "Leste", "torre": "Torre do Fundão", "condominio_idx": 1}, {"numero": "06", "bloco": "Leste", "torre": "Torre do Fundão", "condominio_idx": 1}, # Edifício Disputa&Guerra (cond_id=3) - {"numero": "1A", "bloco": "Bloco dos Apontamentos", "torre": None, "condominio_idx": 2}, - {"numero": "1B", "bloco": "Bloco dos Apontamentos", "torre": None, "condominio_idx": 2}, + { + "numero": "1A", + "bloco": "Bloco dos Apontamentos", + "torre": None, + "condominio_idx": 2, + }, + { + "numero": "1B", + "bloco": "Bloco dos Apontamentos", + "torre": None, + "condominio_idx": 2, + }, {"numero": "2A", "bloco": "Bloco das Disputas", "torre": None, "condominio_idx": 2}, {"numero": "2B", "bloco": "Bloco das Disputas", "torre": None, "condominio_idx": 2}, {"numero": "3A", "bloco": "Bloco da Trégua", "torre": None, "condominio_idx": 2}, @@ -69,9 +79,24 @@ {"numero": "1201", "bloco": "A", "torre": "Torre dos Atritos", "condominio_idx": 3}, {"numero": "1202", "bloco": "A", "torre": "Torre dos Atritos", "condominio_idx": 3}, {"numero": "1301", "bloco": "A", "torre": "Torre dos Atritos", "condominio_idx": 3}, - {"numero": "1401", "bloco": "B", "torre": "Torre das Desavenças", "condominio_idx": 3}, - {"numero": "1402", "bloco": "B", "torre": "Torre das Desavenças", "condominio_idx": 3}, - {"numero": "1501", "bloco": "B", "torre": "Torre das Desavenças", "condominio_idx": 3}, + { + "numero": "1401", + "bloco": "B", + "torre": "Torre das Desavenças", + "condominio_idx": 3, + }, + { + "numero": "1402", + "bloco": "B", + "torre": "Torre das Desavenças", + "condominio_idx": 3, + }, + { + "numero": "1501", + "bloco": "B", + "torre": "Torre das Desavenças", + "condominio_idx": 3, + }, # Conjunto Residencial Quem Cala (cond_id=5) {"numero": "A1", "bloco": "Silêncio", "torre": None, "condominio_idx": 4}, {"numero": "A2", "bloco": "Silêncio", "torre": None, "condominio_idx": 4}, @@ -565,27 +590,27 @@ }, # Cross-condomínio (brigas que atravessam fronteiras) { - "apartamento_a_idx": 5, # DJ Paredão (01 Norte — Barulho) + "apartamento_a_idx": 5, # DJ Paredão (01 Norte — Barulho) "apartamento_b_idx": 18, # Influencer do Condomínio (1301 A — Château) "motivo": "Disputa de quem faz mais barulho: som alto vs desafios de dança às 3h", "nivel": "intenso", }, { - "apartamento_a_idx": 0, # Dona Fofoqueira (101 A — Fofoca Feliz) + "apartamento_a_idx": 0, # Dona Fofoqueira (101 A — Fofoca Feliz) "apartamento_b_idx": 22, # Fiscal de Portaria (A1 — Quem Cala) "motivo": "Dona Fofoqueira descobriu que Fiscal de Portaria tem um caso e espalhou pelo condomínio inteiro", "nivel": "belico", }, { "apartamento_a_idx": 20, # Madame do Andar de Cima (1402 B — Château) - "apartamento_b_idx": 3, # Tia do Andar de Cima (202 B — Fofoca Feliz) + "apartamento_b_idx": 3, # Tia do Andar de Cima (202 B — Fofoca Feliz) "motivo": "Disputa pelo título não-oficial de 'Madame mais chique do condomínio'", "nivel": "leve", }, { - "apartamento_a_idx": 1, # Maria Barulho (102 A — Fofoca Feliz) - "apartamento_b_idx": 5, # DJ Paredão (01 Norte — Barulho) + "apartamento_a_idx": 1, # Maria Barulho (102 A — Fofoca Feliz) + "apartamento_b_idx": 5, # DJ Paredão (01 Norte — Barulho) "motivo": "Qual dos dois faz mais barulho? Maria com o gato, DJ com a caixa de som. Vizinhaça já fez aposta.", "nivel": "moderado", }, -] \ No newline at end of file +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..25cabc2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# ============================================================================= +# CondoCombat — Docker Compose (Backend + Frontend + Database) +# ============================================================================= + +services: + # PostgreSQL Database + db: + image: postgres:16-alpine + container_name: condocombat-db + environment: + POSTGRES_USER: ${POSTGRES_USER:-condocombat} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-condocombat} + POSTGRES_DB: ${POSTGRES_DB:-condocombat} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-condocombat}" ] + interval: 10s + timeout: 5s + retries: 5 + + # Backend API (FastAPI) + api: + image: augustonogueira01/condocombat-backend:latest + container_name: condocombat-api + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-condocombat}:${POSTGRES_PASSWORD:-condocombat}@db:5432/${POSTGRES_DB:-condocombat} + SECRET_KEY: ${SECRET_KEY} + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + # Frontend (Next.js) + web: + image: augustonogueira01/condocombat-frontend:latest + container_name: condocombat-web + environment: + NEXT_PUBLIC_API_URL: http://localhost:8000 + ports: + - "3000:3000" + depends_on: + - api + restart: unless-stopped + +volumes: + pgdata: + driver: local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f1d6aa3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,60 @@ +# ============================================================================= +# Stage 1: Dependencies — instala dependências +# ============================================================================= +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copia apenas os arquivos de dependências (aproveita cache do Docker) +COPY package.json package-lock.json ./ + +# Instala dependências exatas do lockfile +RUN npm ci + +# ============================================================================= +# Stage 2: Builder — compila a aplicação +# ============================================================================= +FROM node:20-alpine AS builder +WORKDIR /app + +# Copia dependências do stage deps +COPY --from=deps /app/node_modules ./node_modules + +# Copia código fonte +COPY . . + +# Desativa telemetria durante o build +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build da aplicação Next.js (gera os artefatos em .next/standalone e .next/static) +RUN npm run build + +# ============================================================================= +# Stage 3: Runner — imagem de produção +# ============================================================================= +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Cria usuário não-root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copia os artefatos compilados pelo Next.js no modo standalone +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Troca para usuário não-root +USER nextjs + +# Expõe porta e health check +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000 || exit 1 + +# Inicia servidor Next.js standalone +CMD ["node", "server.js"] \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ec5a0a..cc1b826 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9643,4 +9643,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 022d6fe..b4eef9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,4 +44,4 @@ "typescript": "^5.4.0", "vitest": "^4.1.7" } -} +} \ No newline at end of file diff --git a/landing/netlify.toml b/landing/netlify.toml new file mode 100644 index 0000000..081b68b --- /dev/null +++ b/landing/netlify.toml @@ -0,0 +1,11 @@ +[build] + command = "npm run build" + publish = "dist" + +[build.environment] + NODE_VERSION = "20" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file