From f93c5a59665f49d29b95d22d5a699c862bb985ad Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 20 Nov 2025 19:39:20 +0300 Subject: [PATCH] feat: added e2e, unit tests and improved tests pipeline --- .gitlab-ci.yml | 65 +++++++++++++--- Containerfile | 2 +- pyproject.toml | 2 + tests/unit/test_entity.py | 36 +++++++++ tests/web_api/e2e/test_auth.py | 107 +++++++++++++++++++++++++ tests/web_api/e2e/test_healthcheck.py | 3 + tests/web_api/e2e/test_profile.py | 108 ++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_entity.py create mode 100644 tests/web_api/e2e/test_auth.py create mode 100644 tests/web_api/e2e/test_profile.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 63280d3..c8101de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,12 +14,15 @@ variables: TRIVY_USERNAME: $CI_REGISTRY_USER TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD TRIVY_REGISTRY: $CI_REGISTRY + UV_PROJECT_ENVIRONMENT: .venv + UV_CACHE_DIR: .cache/uv cache: key: "${CI_COMMIT_REF_SLUG}" paths: - - .cache/pip - - .cache/trivy + - $TRIVY_CACHE_DIR + - $UV_CACHE_DIR + - $UV_PROJECT_ENVIRONMENT policy: pull-push .docker-job: &docker-job @@ -138,6 +141,21 @@ cache: when: manual allow_failure: true +.uv-job: &uv-job + image: debian:trixie-slim + cache: + key: "${CI_JOB_NAME}-${CI_COMMIT_REF_SLUG}" + paths: + - $UV_PROJECT_ENVIRONMENT + - $UV_CACHE_DIR + policy: pull-push + before_script: + - apt-get update + - apt-get install -y --no-install-recommends ca-certificates curl just + - update-ca-certificates + - curl -LsSf https://astral.sh/uv/install.sh | sh + - export PATH="$HOME/.local/bin:$PATH" + sast-filesystem: <<: *trivy-fs-scan @@ -186,7 +204,19 @@ build-migrations: CONTAINERFILE: Containerfile BUILDTARGET: migrations -test-e2e: +lint: + <<: *uv-job + stage: test + script: + - export PATH="$HOME/.local/bin:$PATH" + - uv sync --group linters --frozen + - just lint + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_PIPELINE_SOURCE == 'merge_request_event' + - if: $CI_COMMIT_TAG + +test: <<: *docker-job stage: test variables: @@ -220,13 +250,19 @@ test-e2e: exit 1 fi - | - docker compose -f compose.yaml -f compose.prod.yaml $PROFILES down + docker compose -f compose.yaml $PROFILES down + - cat .cov/coverage.txt artifacts: paths: - ./.cov - ./compose.log + reports: + coverage_report: + coverage_format: cobertura + path: .cov/coverage.xml expire_in: 1 week when: always + coverage: /TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/ rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_PIPELINE_SOURCE == 'merge_request_event' @@ -235,22 +271,27 @@ test-e2e: - build-tests - build-migrations -webhook-backend-deploy: - <<: *webhook-config - variables: - WEBHOOK_URL: $WEBHOOK_URL_BACKEND - dependencies: - - build-runtime - - sast-image-runtime - webhook-migrations-deploy: <<: *webhook-config variables: WEBHOOK_URL: $WEBHOOK_URL_MIGRATIONS + resource_group: staging dependencies: - build-migrations - sast-image-migrations +webhook-backend-deploy: + <<: *webhook-config + variables: + WEBHOOK_URL: $WEBHOOK_URL_BACKEND + environment: + name: staging + url: https://hackaton.paas.itqdev.xyz + resource_group: staging + dependencies: + - build-runtime + - sast-image-runtime + workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" diff --git a/Containerfile b/Containerfile index 1dee41e..ecac0c2 100644 --- a/Containerfile +++ b/Containerfile @@ -73,7 +73,7 @@ RUN mkdir -p /app/cov RUN mkdir /app/cov/html -CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage json -o /app/cov/coverage.json && coverage html -d /app/cov/html" ] +CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage xml -o /app/cov/coverage.xml && coverage html -d /app/cov/html" ] # Stage 4: Migrations diff --git a/pyproject.toml b/pyproject.toml index 0d25d44..295d538 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,8 @@ ignore = [ "PLR2004", # do not report magic numbers "PLR6301", # do not require classmethod / staticmethod when self not used "TRY003", # long exception messages from `tryceratops` + "N813", + "S106", ] external = ["WPS"] diff --git a/tests/unit/test_entity.py b/tests/unit/test_entity.py new file mode 100644 index 0000000..7ba34c2 --- /dev/null +++ b/tests/unit/test_entity.py @@ -0,0 +1,36 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from template_project.application.common.entity import Entity, to_entity +from template_project.application.common.errors import EntityAlreadyDeletedError + + +@to_entity +class DummyEntity(Entity[UUID]): + name: str + + +def test_entity_allows_not_deleted_entities() -> None: + entity = DummyEntity( + id=uuid4(), + created_at=datetime.now(tz=UTC), + name="Alice", + ) + + entity.ensure_not_deleted() + + +def test_entity_raise_for_deleted_entities() -> None: + entity = DummyEntity( + id=uuid4(), + created_at=datetime.now(tz=UTC), + deleted_at=datetime.now(tz=UTC), + name="Bob", + ) + + with pytest.raises(EntityAlreadyDeletedError) as exc_info: + entity.ensure_not_deleted() + + assert str(exc_info.value) == "Entity 'DummyEntity' already deleted" diff --git a/tests/web_api/e2e/test_auth.py b/tests/web_api/e2e/test_auth.py new file mode 100644 index 0000000..5e242e5 --- /dev/null +++ b/tests/web_api/e2e/test_auth.py @@ -0,0 +1,107 @@ +from http import HTTPStatus as status +from uuid import uuid4 + +from dishka import FromDishka +from httpx import AsyncClient, Response + +from tests.web_api.ioc import DatabaseClearer, inject + +DEFAULT_PASSWORD = "Sup3rSecret" # noqa: S105 + + +async def _sign_up_email(client: AsyncClient, email: str, password: str = DEFAULT_PASSWORD) -> None: + response = await client.post( + "/auth/sign_up/email", + json={"email": email, "password": password}, + ) + assert response.status_code == status.OK, response.text + + +async def _sign_in_email(client: AsyncClient, email: str, password: str = DEFAULT_PASSWORD) -> Response: + return await client.post( + "/auth/sign_in/email", + json={"email": email, "password": password}, + ) + + +def _unique_email() -> str: + return f"user-{uuid4().hex}@example.com" + + +@inject +async def test_email_sign_up_creates_user( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + + response = await http_client.post( + "/auth/sign_up/email", + json={"email": email, "password": DEFAULT_PASSWORD}, + ) + body = response.json() + + assert response.status_code == status.OK + assert isinstance(body["access_token"], str) + assert body["access_token"] + + +@inject +async def test_email_sign_up_existing_user_conflict( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + await _sign_up_email(http_client, email) + + response = await http_client.post( + "/auth/sign_up/email", + json={"email": email, "password": DEFAULT_PASSWORD}, + ) + + assert response.status_code == status.CONFLICT + + +@inject +async def test_email_sign_in_returns_token( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + await _sign_up_email(http_client, email) + + response = await _sign_in_email(http_client, email) + body = response.json() + + assert response.status_code == status.OK + assert isinstance(body["access_token"], str) + assert body["access_token"] + + +@inject +async def test_email_sign_in_invalid_password( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + await _sign_up_email(http_client, email) + + response = await _sign_in_email(http_client, email, password="wrong-password") + + assert response.status_code == status.UNAUTHORIZED + + +@inject +async def test_email_sign_in_user_not_found( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response = await _sign_in_email(http_client, email=_unique_email()) + + assert response.status_code == status.NOT_FOUND diff --git a/tests/web_api/e2e/test_healthcheck.py b/tests/web_api/e2e/test_healthcheck.py index a0ddc13..0b6d0b2 100644 --- a/tests/web_api/e2e/test_healthcheck.py +++ b/tests/web_api/e2e/test_healthcheck.py @@ -1,3 +1,5 @@ +from http import HTTPStatus as status + from dishka import FromDishka from httpx import AsyncClient @@ -11,4 +13,5 @@ async def test_healthcheck( response = await http_client.get("/healthcheck") response_json = response.json() + assert response.status_code == status.OK assert response_json["ok"] diff --git a/tests/web_api/e2e/test_profile.py b/tests/web_api/e2e/test_profile.py new file mode 100644 index 0000000..1c62bab --- /dev/null +++ b/tests/web_api/e2e/test_profile.py @@ -0,0 +1,108 @@ +from collections.abc import Mapping +from http import HTTPStatus as status +from uuid import uuid4 + +from dishka import FromDishka +from httpx import AsyncClient, Response + +from tests.web_api.ioc import DatabaseClearer, inject + +DEFAULT_PASSWORD = "Sup3rSecret" # noqa: S105 + + +def _unique_email() -> str: + return f"user-{uuid4().hex}@example.com" + + +def _auth_headers(token: str) -> Mapping[str, str]: + return {"Authorization": f"Bearer {token}"} + + +async def _sign_up_and_get_token(client: AsyncClient, email: str) -> str: + response = await client.post( + "/auth/sign_up/email", + json={"email": email, "password": DEFAULT_PASSWORD}, + ) + assert response.status_code == status.OK, response.text + body = response.json() + return str(body["access_token"]) + + +async def _get_profile(client: AsyncClient, token: str) -> Response: + return await client.get("/profile", headers=_auth_headers(token)) + + +async def _patch_profile( + client: AsyncClient, + token: str, + payload: Mapping[str, str | None], +) -> Response: + return await client.patch("/profile", headers=_auth_headers(token), json=payload) + + +@inject +async def test_get_profile_returns_current_user( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + token = await _sign_up_and_get_token(http_client, email) + + response = await _get_profile(http_client, token) + body = response.json() + + assert response.status_code == status.OK + assert body["email"] == email + assert body["display_name"] is None + + +@inject +async def test_patch_profile_updates_profile_data( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + email = _unique_email() + token = await _sign_up_and_get_token(http_client, email) + + payload = { + "display_name": "Alice", + "first_name": "Alice", + "last_name": "Smith", + "avatar_url": "https://example.com/avatar.png", + "phone": "+1234567890", + } + patch_response = await _patch_profile(http_client, token, payload) + patch_body = patch_response.json() + + assert patch_response.status_code == status.OK + assert patch_body["display_name"] == payload["display_name"] + assert patch_body["first_name"] == payload["first_name"] + assert patch_body["last_name"] == payload["last_name"] + assert patch_body["avatar_url"] == payload["avatar_url"] + assert patch_body["phone"] == payload["phone"] + assert patch_body["email"] == email + + get_response = await _get_profile(http_client, token) + get_body = get_response.json() + + assert get_body["display_name"] == payload["display_name"] + assert get_body["first_name"] == payload["first_name"] + assert get_body["last_name"] == payload["last_name"] + assert get_body["avatar_url"] == payload["avatar_url"] + assert get_body["phone"] == payload["phone"] + + +@inject +async def test_profile_routes_require_authentication( + http_client: FromDishka[AsyncClient], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response_get = await http_client.get("/profile") + response_patch = await http_client.patch("/profile", json={}) + + assert response_get.status_code == status.FORBIDDEN + assert response_patch.status_code == status.FORBIDDEN