You've already forked RekomenciBackend
feat: added e2e, unit tests and improved tests pipeline
This commit is contained in:
+53
-12
@@ -14,12 +14,15 @@ variables:
|
|||||||
TRIVY_USERNAME: $CI_REGISTRY_USER
|
TRIVY_USERNAME: $CI_REGISTRY_USER
|
||||||
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
|
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
|
||||||
TRIVY_REGISTRY: $CI_REGISTRY
|
TRIVY_REGISTRY: $CI_REGISTRY
|
||||||
|
UV_PROJECT_ENVIRONMENT: .venv
|
||||||
|
UV_CACHE_DIR: .cache/uv
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
key: "${CI_COMMIT_REF_SLUG}"
|
key: "${CI_COMMIT_REF_SLUG}"
|
||||||
paths:
|
paths:
|
||||||
- .cache/pip
|
- $TRIVY_CACHE_DIR
|
||||||
- .cache/trivy
|
- $UV_CACHE_DIR
|
||||||
|
- $UV_PROJECT_ENVIRONMENT
|
||||||
policy: pull-push
|
policy: pull-push
|
||||||
|
|
||||||
.docker-job: &docker-job
|
.docker-job: &docker-job
|
||||||
@@ -138,6 +141,21 @@ cache:
|
|||||||
when: manual
|
when: manual
|
||||||
allow_failure: true
|
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:
|
sast-filesystem:
|
||||||
<<: *trivy-fs-scan
|
<<: *trivy-fs-scan
|
||||||
|
|
||||||
@@ -186,7 +204,19 @@ build-migrations:
|
|||||||
CONTAINERFILE: Containerfile
|
CONTAINERFILE: Containerfile
|
||||||
BUILDTARGET: migrations
|
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
|
<<: *docker-job
|
||||||
stage: test
|
stage: test
|
||||||
variables:
|
variables:
|
||||||
@@ -220,13 +250,19 @@ test-e2e:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- |
|
- |
|
||||||
docker compose -f compose.yaml -f compose.prod.yaml $PROFILES down
|
docker compose -f compose.yaml $PROFILES down
|
||||||
|
- cat .cov/coverage.txt
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- ./.cov
|
- ./.cov
|
||||||
- ./compose.log
|
- ./compose.log
|
||||||
|
reports:
|
||||||
|
coverage_report:
|
||||||
|
coverage_format: cobertura
|
||||||
|
path: .cov/coverage.xml
|
||||||
expire_in: 1 week
|
expire_in: 1 week
|
||||||
when: always
|
when: always
|
||||||
|
coverage: /TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||||
@@ -235,22 +271,27 @@ test-e2e:
|
|||||||
- build-tests
|
- build-tests
|
||||||
- build-migrations
|
- build-migrations
|
||||||
|
|
||||||
webhook-backend-deploy:
|
|
||||||
<<: *webhook-config
|
|
||||||
variables:
|
|
||||||
WEBHOOK_URL: $WEBHOOK_URL_BACKEND
|
|
||||||
dependencies:
|
|
||||||
- build-runtime
|
|
||||||
- sast-image-runtime
|
|
||||||
|
|
||||||
webhook-migrations-deploy:
|
webhook-migrations-deploy:
|
||||||
<<: *webhook-config
|
<<: *webhook-config
|
||||||
variables:
|
variables:
|
||||||
WEBHOOK_URL: $WEBHOOK_URL_MIGRATIONS
|
WEBHOOK_URL: $WEBHOOK_URL_MIGRATIONS
|
||||||
|
resource_group: staging
|
||||||
dependencies:
|
dependencies:
|
||||||
- build-migrations
|
- build-migrations
|
||||||
- sast-image-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:
|
workflow:
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ RUN mkdir -p /app/cov
|
|||||||
|
|
||||||
RUN mkdir /app/cov/html
|
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
|
# Stage 4: Migrations
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ ignore = [
|
|||||||
"PLR2004", # do not report magic numbers
|
"PLR2004", # do not report magic numbers
|
||||||
"PLR6301", # do not require classmethod / staticmethod when self not used
|
"PLR6301", # do not require classmethod / staticmethod when self not used
|
||||||
"TRY003", # long exception messages from `tryceratops`
|
"TRY003", # long exception messages from `tryceratops`
|
||||||
|
"N813",
|
||||||
|
"S106",
|
||||||
]
|
]
|
||||||
external = ["WPS"]
|
external = ["WPS"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from http import HTTPStatus as status
|
||||||
|
|
||||||
from dishka import FromDishka
|
from dishka import FromDishka
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
@@ -11,4 +13,5 @@ async def test_healthcheck(
|
|||||||
response = await http_client.get("/healthcheck")
|
response = await http_client.get("/healthcheck")
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
|
|
||||||
|
assert response.status_code == status.OK
|
||||||
assert response_json["ok"]
|
assert response_json["ok"]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user