diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..40f910e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,55 @@ +# Config file for GitHub Actions + +name: Backend Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest # Fastest option + + steps: + - name: Checkout code + uses: actions/checkout@v6 # Copies code on VM + + - name: Set up Python + uses: actions/setup-python@v5 # Official GitHub tool + with: + python-version: '3.12' + + # Creating .env file for GitHub Actions + - name: Create .env + run: | + echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env + echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env + echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env + echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env + echo "JWT_ALGORITHM=${{ secrets.JWT_ALGORITHM }}" >> .env + echo "REFRESH_ACCESS_TOKEN_EXPIRE=${{ secrets.REFRESH_ACCESS_TOKEN_EXPIRE }}" >> .env + echo "ACCESS_TOKEN_EXPIRE=${{ secrets.ACCESS_TOKEN_EXPIRE }}" >> .env + echo "REFRESH_TOKEN_KEY=${{ secrets.REFRESH_TOKEN_KEY }}" >> .env + + - name: Start Database (Docker) + run: docker compose up -d + + - name: Install dependencies + run: | # Everything below will be executed one by one + python -m pip install --upgrade pip + pip install -r requirements.txt + + # Wait for DB setup, Docker takes care of it, not needed + # - name: Wait for DB + # run: | + # for i in {1..30}; do + # pg_isready -h localhost && break + # echo "Waiting for DB... $i" + # sleep 1 + # done + + - name: Run tests + env: + PYTHONPATH: ${{ github.workspace }} # github.workspace is a root dir + ENV: testing + run: | + pytest --cov=app --cov-report=term-missing --cov-fail-under=80 + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d953e1..82ef584 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Thumbs.db # Database postgres_data/ + +# My files +app/services/temp_test_service.py \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py index c5353ab..3471ffc 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/api/v1/endpoints/auth.py @@ -60,7 +60,7 @@ async def register_user(user_in: UserCreate, db: AsyncSession = Depends(get_db)) @router.post("/token", summary="Login with email as username if no nickname provided") -@limiter.limit("5/minute") +@limiter.limit("10/minute") async def login_for_access_token( request: Request, # Limiter needs an access to request response: Response, diff --git a/app/core/limiter.py b/app/core/limiter.py index 84a0689..4a95108 100644 --- a/app/core/limiter.py +++ b/app/core/limiter.py @@ -1,6 +1,10 @@ from slowapi import Limiter from slowapi.util import get_remote_address +import os -limiter = Limiter(key_func=get_remote_address, headers_enabled=True) # Identifies user IP +limiter = Limiter( + key_func=get_remote_address, # Identifies user IP + headers_enabled=True, + enabled=os.getenv("ENV") != 'testing') # Rate limited disabled for pytest # For PRO version limiter will have added ID verification \ No newline at end of file diff --git a/app/services/test_service.py b/app/services/test_service.py deleted file mode 100644 index 6ecb643..0000000 --- a/app/services/test_service.py +++ /dev/null @@ -1,45 +0,0 @@ -import httpx -from zeep import Client -from zeep.helpers import serialize_object -# Function for sending raw XML file -# def send_request(country: str, nip: str): -# URL = "http://ec.europa.eu/taxation_customs/vies/services/checkVatService" -# body = f""" -# -# -# -# -# {country} -# {nip} -# -# -# -# """.strip() -# headers = { -# "Content-Type": "text/xml; charset=UTF-8" -# } - -# # Client needs to be closed after request -# with httpx.Client() as client: -# response = client.post(URL, content=body, headers=headers) - -# response = response.text -# return response - - -# Using this to receive response as a dict -def check_vies_vat(country: str, nip: str): - WSDL = "https://ec.europa.eu/taxation_customs/vies/services/checkVatService.wsdl" - - client = Client(wsdl=WSDL) - - result = client.service.checkVat(countryCode=country, vatNumber=nip) - - result_dict = serialize_object(result) - - return result_dict - - -data = check_vies_vat('PL', "9551464208") - -print(data) \ No newline at end of file diff --git a/app/services/vies_service.py b/app/services/vies_service.py index 242203d..f336895 100644 --- a/app/services/vies_service.py +++ b/app/services/vies_service.py @@ -2,7 +2,7 @@ from zeep.helpers import serialize_object from zeep.exceptions import TransportError, Fault from zeep.transports import AsyncTransport -import httpx # +import httpx # TODO: think about async if there is more requests diff --git a/docker-compose.yml b/docker-compose.yml index 2236555..f29450b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data # Bridge for DB + # /docker-entrypoint-initdb.d everything in this location will be initiated during container start + - ./init.sql:/docker-entrypoint-initdb.d/init.sql # Creating DB for test purpose # Container is healthy if command returns 0 healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] # CMD-SHELL needs one argument after itself diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..4f2bb2c --- /dev/null +++ b/init.sql @@ -0,0 +1 @@ +CREATE DATABASE business_db_test; \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..06db77b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,33 @@ +# Config file for pytest + +# Official header for pytest.ini +[pytest] +# async fixtures and tests are handled automatically without @pytest.mark.asyncio +asyncio_mode = auto +# Location of app +pythonpath = . +# Shows tests folder +testpaths = ./tests +# Files only that starts with test_ +python_files = test_*.py +# Only functions that start with test_ +python_functions = test_* +# Additional options +# v (verbose) - more details while running tests +# strict-markers - Python will alert about using unregistered marker +# tb=short - traceback for error, short version +addopts = + -v + --strict-markers + --tb=short + --cov=app + --cov-report=term-missing + --cov-fail-under=80 + +# Section for registering markers +markers = + auth: users authentication endpoints test + business: business validation endpoints test + services: test for services + status: test for status endpoints + validators: test for validators \ No newline at end of file diff --git a/readme.md b/readme.md index ac2e127..3890015 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,10 @@ # Business Verification API +[![Backend Tests](https://github.com/NoobCoder12/BusinessValidator/actions/workflows/tests.yml/badge.svg)](https://github.com/NoobCoder12/BusinessValidator/actions/workflows/tests.yml) + A REST API that lets users verify whether a business is an active VAT taxpayer using its NIP (tax ID). Built with FastAPI and integrated with the official VIES SOAP service. -> **Version:** 1.6.0 +> **Version:** 1.7.0 --- @@ -34,6 +36,7 @@ I built this to get hands-on experience with FastAPI and explore how to integrat - **Redis caching** — cache VIES responses to avoid redundant external calls - **GUI** — for redis and pgadmin - **Sentry** — see [Monitoring](#monitoring) +- **Pytest** - see [Testing](#testing) --- @@ -55,6 +58,7 @@ The raw key is returned once — store it securely. Only a bcrypt hash is saved **Backend:** FastAPI, SQLAlchemy, Pydantic, Zeep, SlowAPI, Alembic **Database:** PostgreSQL **Infrastructure:** Docker, Sentry +**Testing:** pytest, pytest-asyncio, httpx --- @@ -164,23 +168,24 @@ Add a new database: ## Testing -Currently verified through manual end-to-end testing. A full automated suite is planned for v1.7: - -- Integration tests using FastAPI's `TestClient` -- Isolated test database -- GitHub Actions CI/CD pipeline -- Full pytest coverage (unit + integration) - ---- - -## Roadmap (v1.7) +The project includes an automated test suite built with **pytest** and **pytest-asyncio**. -- **Pytest suite** — unit and integration test coverage +- **Integration tests** — endpoint testing using `httpx.AsyncClient` with an isolated PostgreSQL test database, created fresh and dropped after each test +- **Unit tests** — validators and Pydantic schemas tested directly without HTTP layer +- **Mocking** — external services (VIES, httpx) are mocked to avoid real API calls and ensure deterministic results +- **CI/CD** — GitHub Actions runs the full suite on every push and pull request +- **Coverage** — minimum 80% enforced via `pytest-cov` --- ## Changelog +### v1.7.0 +- Automated test suite with pytest and pytest-asyncio +- Integration tests with isolated test database +- GitHub Actions CI/CD pipeline +- 80% coverage enforced via pytest-cov + ### v.1.6.0 - Asynchronous requests for Zeep diff --git a/requirements.txt b/requirements.txt index fc24d39..9469219 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ certifi==2026.1.4 cffi==2.0.0 charset-normalizer==3.4.4 click==8.3.1 +coverage==7.13.4 cryptography==46.0.5 Deprecated==1.3.1 dnspython==2.8.0 @@ -24,6 +25,7 @@ httpcore==1.0.9 httptools==0.7.1 httpx==0.28.1 idna==3.11 +iniconfig==2.3.0 isodate==0.7.2 Jinja2==3.1.6 limits==5.8.0 @@ -35,6 +37,7 @@ mdurl==0.1.2 packaging==26.0 passlib==1.7.4 platformdirs==4.9.2 +pluggy==1.6.0 pyasn1==0.6.2 pycparser==3.0 pydantic==2.12.5 @@ -42,6 +45,10 @@ pydantic-extra-types==2.11.0 pydantic-settings==2.12.0 pydantic_core==2.41.5 Pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-cov==7.0.0 +pytest-mock==3.15.1 python-dotenv==1.2.1 python-jose==3.5.0 python-multipart==0.0.22 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/__init__.py b/tests/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/endpoints/auth/__init__.py b/tests/api/v1/endpoints/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/endpoints/auth/test_api_key.py b/tests/api/v1/endpoints/auth/test_api_key.py new file mode 100644 index 0000000..fcd382b --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_api_key.py @@ -0,0 +1,29 @@ +from httpx import AsyncClient +import pytest + + +@pytest.mark.auth +async def test_api_key( + client: AsyncClient, + logged_user: dict +): + """ + Test for getting api key + """ + response = await client.post("/api/v1/auth/me/api-key", headers=logged_user) + assert response.status_code == 200 + data = response.json() + assert data is not None + key = data.get("api_key") + assert isinstance(key, str) + + +@pytest.mark.auth +async def test_api_key_unauthorized( + client: AsyncClient +): + """ + Test for getting api key as non logged user + """ + response = await client.post("/api/v1/auth/me/api-key") + assert response.status_code == 401 diff --git a/tests/api/v1/endpoints/auth/test_logout.py b/tests/api/v1/endpoints/auth/test_logout.py new file mode 100644 index 0000000..f623987 --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_logout.py @@ -0,0 +1,33 @@ +from httpx import AsyncClient +import pytest + + +@pytest.mark.auth +async def test_logout( + client: AsyncClient, + logged_user: dict, + registered_user: dict, + user_data: dict +): + """ + Test for logout endpoint + """ + login_params = { + "username": user_data.get("username"), + "password": user_data.get("password") + } + + # Creating refresh token + response = await client.post("/api/v1/auth/token", data=login_params) + assert response.status_code == 200 + assert response.cookies.get("refresh_token") is not None + + # Deleting refresh token + response_logout = await client.post("/api/v1/auth/logout", headers=logged_user) + assert response_logout.status_code == 200 + cookie = response_logout.cookies.get("refresh_token") + assert cookie is None or cookie == "" # may leave empty string + + # Testing if deleted + response_deleted = await client.post("/api/v1/auth/refresh", headers=logged_user) + assert response_deleted.status_code == 401 \ No newline at end of file diff --git a/tests/api/v1/endpoints/auth/test_me.py b/tests/api/v1/endpoints/auth/test_me.py new file mode 100644 index 0000000..fc117be --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_me.py @@ -0,0 +1,22 @@ +from httpx import AsyncClient +import pytest + + +@pytest.mark.auth +async def test_me( + client: AsyncClient, + logged_user: dict, # get headers as logged user + registered_user: dict +): + """ + Test for getting logged user data + """ + + response = await client.get("/api/v1/auth/me", headers=logged_user) + + assert response.status_code == 200 + data = response.json() + + assert data.get("id") == str(registered_user.get("id")) + assert data.get("email") == registered_user.get("email") + assert data.get("username") == registered_user.get("username") diff --git a/tests/api/v1/endpoints/auth/test_refresh.py b/tests/api/v1/endpoints/auth/test_refresh.py new file mode 100644 index 0000000..2a2ed4b --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_refresh.py @@ -0,0 +1,110 @@ +from httpx import AsyncClient +import pytest +from app.core.auth import decode_access_token +from datetime import datetime, timezone, timedelta +from jose import jwt +from app.core.config import settings +import uuid + + +@pytest.mark.auth +async def test_refresh( + client: AsyncClient, + logged_user: dict, # get headers as logged user + registered_user: dict +): + response = await client.post("/api/v1/auth/refresh", headers=logged_user) + assert response.status_code == 200 + data = response.json() + token = data.get("access_token") + + assert isinstance(token, str) + + decoded_data = dict(decode_access_token(token)) + + assert isinstance(decoded_data, dict) + assert decoded_data.get("sub") == str(registered_user.get("id")) + assert data.get("token_type") == "bearer" + assert decoded_data.get("type") == "access" + + exp = decoded_data.get("exp") + assert exp is not None + assert datetime.fromtimestamp(exp, tz=timezone.utc) > datetime.now(tz=timezone.utc) + + +@pytest.mark.auth +async def test_refresh_empty( + client: AsyncClient, + registered_user: dict +): + """ + Test for endpoint with no refresh token + """ + response = await client.post("/api/v1/auth/refresh") + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == "No refresh token found" + + +@pytest.mark.auth +async def test_refresh_expired( + client: AsyncClient, + user_data: dict +): + """ + Test for endpoint with expired refresh token + """ + # Create user for this test + response_user = await client.post("/api/v1/auth/register", json=user_data) + assert response_user.status_code == 201 + data = response_user.json() + + # Create refresh token with expired date + expired_payload = { + "sub": str(data.get("id")), + "exp": 1000000000, # year 2001 - expired + "type": "refresh" + } + + expired_refresh_token = jwt.encode( + expired_payload, + settings.REFRESH_TOKEN_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + # Set new cookie with expired refresh token + client.cookies.set("refresh_token", expired_refresh_token) + + response = await client.post("/api/v1/auth/refresh") + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == "Token expired or is not valid" + + +@pytest.mark.auth +async def test_refresh_no_user( + client: AsyncClient +): + """ + Test for getting refresh token as non existing user + """ + # Create refresh token with non-existing user + expired_payload = { + "sub": str(uuid.UUID("550e8400-e29b-41d4-a716-446655440000")), # random ID + "exp": datetime.now(tz=timezone.utc) + timedelta(days=1), + "type": "refresh" + } + + refresh_token = jwt.encode( + expired_payload, + settings.REFRESH_TOKEN_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + # Set new cookie with non-existing user + client.cookies.set("refresh_token", refresh_token) + + response = await client.post("/api/v1/auth/refresh") + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == "User does not exist" diff --git a/tests/api/v1/endpoints/auth/test_register.py b/tests/api/v1/endpoints/auth/test_register.py new file mode 100644 index 0000000..3b78fee --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_register.py @@ -0,0 +1,58 @@ +from httpx import AsyncClient +import pytest +import uuid + + +@pytest.mark.auth +async def test_register_user( + client: AsyncClient, + user_data: dict +): + """ + Testing user registration + """ + response = await client.post("/api/v1/auth/register", json=user_data) + + assert response.status_code == 201 + + data = dict(response.json()) + user_id = data.get("id") + assert uuid.UUID(user_id) # If user_id not UUID - ValueError + assert data.get("email") == user_data.get("email") + assert data.get("username") == user_data.get("username") + assert "password" not in data + + +@pytest.mark.auth +async def test_register_duplicate_email( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing error for email duplication + """ + + user_data["username"] = "testing_email" + + response_email_error = await client.post("/api/v1/auth/register", json=user_data) + assert response_email_error.status_code == 400 + data_email_error = dict(response_email_error.json()) + assert data_email_error.get("detail") == 'Email already registered' + + +@pytest.mark.auth +async def test_register_duplicate_username( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing error for username duplication + """ + user_data["email"] = "test@testing.com" + + response_email_error = await client.post("/api/v1/auth/register", json=user_data) + assert response_email_error.status_code == 400 + data_email_error = dict(response_email_error.json()) + assert data_email_error.get("detail") == 'Username already taken' diff --git a/tests/api/v1/endpoints/auth/test_token.py b/tests/api/v1/endpoints/auth/test_token.py new file mode 100644 index 0000000..18bec3c --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_token.py @@ -0,0 +1,121 @@ +from httpx import AsyncClient +import pytest +from app.core.auth import decode_access_token, verify_refresh_token +from datetime import datetime, timezone + + +@pytest.mark.auth +async def test_token_with_username( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing user login with username for token + """ + + login_params = { + "username": user_data.get("username"), + "password": user_data.get("password") + } + + response_token = await client.post("/api/v1/auth/token", data=login_params) + assert response_token.status_code == 200 + data = response_token.json() + + # Test for JWT + token_value = data.get("access_token") + decoded_data = decode_access_token(token_value) + assert isinstance(decoded_data, dict) + assert decoded_data.get("sub") == str(registered_user.get("id")) + assert data.get("token_type") == "bearer" + exp = decoded_data.get("exp") + assert exp is not None + assert datetime.fromtimestamp(exp, tz=timezone.utc) > datetime.now(tz=timezone.utc) + + # Test for refresh token + assert "refresh_token" in response_token.cookies + refresh_token_value = response_token.cookies.get("refresh_token") + assert refresh_token_value is not None + decoded_refresh_token = verify_refresh_token(refresh_token_value) + assert decoded_refresh_token is not None + assert decoded_refresh_token.get("type") == "refresh" + + +@pytest.mark.auth +async def test_token_with_email( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing user login with email as username for token + """ + + login_params = { + "username": user_data.get("email"), + "password": user_data.get("password") + } + + response_token = await client.post("/api/v1/auth/token", data=login_params) + assert response_token.status_code == 200 + data = response_token.json() + + # Test for JWT + token_value = data.get("access_token") + decoded_data = decode_access_token(token_value) + assert isinstance(decoded_data, dict) + assert decoded_data.get("sub") == str(registered_user.get("id")) + assert data.get("token_type") == "bearer" + + # Test for refresh token + assert "refresh_token" in response_token.cookies + refresh_token_value = response_token.cookies.get("refresh_token") + assert refresh_token_value is not None + decoded_refresh_token = verify_refresh_token(refresh_token_value) + assert decoded_refresh_token is not None + assert decoded_refresh_token.get("type") == "refresh" + + +@pytest.mark.auth +async def test_token_wrong_username( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing user login with wrong username + """ + + login_params = { + "username": "not_existing_username", + "password": user_data.get("password") + } + + response_token = await client.post("/api/v1/auth/token", data=login_params) + assert response_token.status_code == 401 + data = response_token.json() + assert data.get("detail") == "Incorrect email or password" + assert response_token.headers.get("WWW-Authenticate") == "Bearer" + + +@pytest.mark.auth +async def test_token_wrong_password( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Testing user login with wrong password + """ + + login_params = { + "username": user_data.get("username"), + "password": "not_existing_password123!" + } + + response_token = await client.post("/api/v1/auth/token", data=login_params) + assert response_token.status_code == 401 + data = response_token.json() + assert data.get("detail") == "Incorrect email or password" + assert response_token.headers.get("WWW-Authenticate") == "Bearer" diff --git a/tests/api/v1/endpoints/business/__init__.py b/tests/api/v1/endpoints/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/endpoints/business/test_history.py b/tests/api/v1/endpoints/business/test_history.py new file mode 100644 index 0000000..4dd8fb0 --- /dev/null +++ b/tests/api/v1/endpoints/business/test_history.py @@ -0,0 +1,54 @@ +from httpx import AsyncClient +import pytest + + +@pytest.mark.business +async def test_get_user_history( + client: AsyncClient, + user_with_api_key: str, + example_validation: dict, + mocker +): + """ + Test for getting user history + """ + # Creating another API call for history test + mock_api = mocker.patch("app.api.v1.endpoints.business.check_vies_vat") + mock_api.return_value = { + 'name': '\"NIKE POLAND\" SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '5272184991', + 'country_code': 'PL', + 'address': 'ul. Koszykowa 61, 00-667 Warszawa', + 'is_valid': True + } + headers = {"X-API-KEY": user_with_api_key} + data = {"tax_id": "5272184991"} + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + assert response.status_code == 200 + + # History check + response = await client.get("/api/v1/business/history", headers=headers) + assert response.status_code == 200 + + # Checking stats + data = response.json() + assert isinstance(data, list) + + first_check, second_check = data + + # Test for the first check + # Function fixture first, desc order + assert first_check.get('tax_id') == '5272184991' + assert first_check.get('company_name') == '\"NIKE POLAND\" SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ' + assert first_check.get('is_vat_active') == True + assert first_check.get('created_at') is not None + + # Test for the second check + assert second_check.get('tax_id') == '9111852372' + assert second_check.get('company_name') == 'FABRYKA MEBLI BODZIO BOGDAN SZEWCZYK SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ' + assert second_check.get('is_vat_active') == True + assert second_check.get('created_at') is not None \ No newline at end of file diff --git a/tests/api/v1/endpoints/business/test_stats.py b/tests/api/v1/endpoints/business/test_stats.py new file mode 100644 index 0000000..4b86a2c --- /dev/null +++ b/tests/api/v1/endpoints/business/test_stats.py @@ -0,0 +1,29 @@ +from httpx import AsyncClient +import pytest + + +@pytest.mark.business +async def test_get_user_stats( + client: AsyncClient, + user_with_api_key: str, + example_validation: dict +): + """ + Test for getting user statistics of business checks + """ + + headers = {"X-API-KEY": user_with_api_key} + + response = await client.get("/api/v1/business/stats/me", headers=headers) + assert response.status_code == 200 + + # Checking stats + data = response.json() + assert data.get("total_searches") == 1 + assert data.get("active_vat_pct") == "100.00%" + assert data.get("last_activity") is not None + + # Most searched details + most_searched_data = data.get("most_searched") + assert most_searched_data.get("tax_id") == "9111852372" + assert most_searched_data.get("count") == 1 diff --git a/tests/api/v1/endpoints/business/test_validate_business.py b/tests/api/v1/endpoints/business/test_validate_business.py new file mode 100644 index 0000000..2cace96 --- /dev/null +++ b/tests/api/v1/endpoints/business/test_validate_business.py @@ -0,0 +1,203 @@ +from httpx import AsyncClient +import pytest +import uuid +import json + + +@pytest.mark.business +async def test_validate_business_real( + client: AsyncClient, + user_with_api_key: str, +): + """ + Test for one real request - Smoke Test + """ + headers = { + "X-API-KEY": user_with_api_key + } + + data = { + "tax_id": "5252530705" # Example tax ID + } + + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + + assert response.status_code == 200 + + business_data = response.json() + assert business_data is not None + + # Business check ID + busiess_check_id = business_data.get("id") + assert str(uuid.UUID(busiess_check_id)) + + # Company name + company_name = business_data.get("company_name") + assert company_name == "FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ" or isinstance(company_name, str) # In case of name change + + # Is it an active VAT taxpayer + assert isinstance(business_data.get("is_vat_active"), bool) + + # Is date of business check empty + date = business_data.get("created_at") + assert isinstance(date, str) and date is not None + + +@pytest.mark.business +async def test_validate_business_mock( + client: AsyncClient, + user_with_api_key: str, + mocker # Fixture by default +): + """ + Test for business validation endpoint with mocked data. + Assertions are made using data returned by endpoint. + """ + # Creating mock for API request + # Path to location where service is used + name of service function + mock_api = mocker.patch("app.api.v1.endpoints.business.check_vies_vat") + + # Assigning value to return + # Must be the same data that service returns + mock_api.return_value = { + 'name': 'FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '5252530705', + 'country_code': 'PL', + 'address': 'ul. Koszykowa 61, 00-667 Warszawa', + 'is_valid': True + } + + headers = { + "X-API-KEY": user_with_api_key + } + + data = { + "tax_id": "5252530705" # Example tax ID + } + + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + + assert response.status_code == 200 + + business_data = response.json() + print(f"MOJA DATA: {business_data}") + assert business_data is not None + + # Business check ID + busiess_check_id = business_data.get("id") + assert str(uuid.UUID(busiess_check_id)) + + # Company name + company_name = business_data.get("company_name") + assert company_name == "FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ" or isinstance(company_name, str) # In case of name change + + # Is it an active VAT taxpayer + assert isinstance(business_data.get("is_vat_active"), bool) + + # Is date of business check empty + date = business_data.get("created_at") + assert isinstance(date, str) and date is not None + + +@pytest.mark.business +async def test_validate_business_check_db( + client: AsyncClient, + user_with_api_key: str, + mocker # Fixture by default +): + """ + Test for checking for a result in database. + Assertions are made using data returned by endpoint. + """ + + # Creating mock for API request + # Path to location where service is used + name of service function + mock_api = mocker.patch("app.api.v1.endpoints.business.check_vies_vat") + + # Assigning value to return + # Must be the same data that service returns + mock_api.return_value = { + 'name': 'FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '5252530705', + 'country_code': 'PL', + 'address': 'ul. Koszykowa 61, 00-667 Warszawa', + 'is_valid': True + } + + # Creating request first + headers = {"X-API-KEY": user_with_api_key} + data = {"tax_id": "5252530705"} # Example tax ID + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + assert response.status_code == 200 + assert mock_api.call_count == 1 + + # Calling the same tax ID + response_database = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + + # Checking if another API call was made + assert response_database.status_code == 200 + assert mock_api.call_count == 1 + + +@pytest.mark.business +async def test_validate_business_check_redis( + client: AsyncClient, + user_with_api_key: str, + redis_client, # fixture created in conftest + mocker +): + """ + Test for redis caching + """ + + # Preparing redis element for test + nip = "5252530705" + cache_key = f"bus:v1:{nip}" + data = { + 'name': 'FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '5252530705', + 'is_valid': True, + } + + await redis_client.set(cache_key, json.dumps(data)) + + # Creating mock for API request + # Path to location where service is used + name of service function + mock_api = mocker.patch("app.api.v1.endpoints.business.check_vies_vat") + + # Assigning value to return + # Must be the same data that service returns + mock_api.return_value = { + 'name': 'FACEBOOK POLAND SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '5252530705', + 'country_code': 'PL', + 'address': 'ul. Koszykowa 61, 00-667 Warszawa', + 'is_valid': True + } + + # Creating request to endpoint + headers = {"X-API-KEY": user_with_api_key} + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json={"tax_id": f"{nip}"} + ) + + assert response.status_code == 200 + assert mock_api.call_count == 0 diff --git a/tests/api/v1/endpoints/status/test_status.py b/tests/api/v1/endpoints/status/test_status.py new file mode 100644 index 0000000..537d6f6 --- /dev/null +++ b/tests/api/v1/endpoints/status/test_status.py @@ -0,0 +1,29 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.status +async def test_status( + client: AsyncClient, + mocker +): + """ + Test for health check endpoint. + Exceptions have their own test in services. + """ + mock_status = mocker.patch("app.api.v1.endpoints.status.check_vies_health") + mock_status.return_value = { + "service": "VIES", + "status": "operational", + "latency_ms": 200 + } + + response = await client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert data is not None + assert data.get("service") == "VIES" + assert data.get("status") == "operational" + assert data.get("latency_ms") == 200 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..16518d7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,193 @@ +import pytest +import os +import redis.asyncio as redis + +# Overriding env for test purpose +os.environ["POSTGRES_SERVER"] = "localhost" +os.environ["SENTRY_URL"] = "" +os.environ["ENV"] = 'testing' # For limiter + +from app.core.config import settings +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.db.base import Base +from app.main import app +from app.db.deps import get_db, get_redis +from httpx import AsyncClient, ASGITransport + +TEST_DATABASE_URL = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_SERVER}:5432/{settings.POSTGRES_DB}_test" + +# @pytest.fixture provides context for the tests + + +@pytest.fixture +# Needs to be async because of asyncpg +async def test_db() -> AsyncSession: + ''' + Creates clean database for each test. + After completing database is removed. + ''' + # Engine setup + engine = create_async_engine(TEST_DATABASE_URL, echo=True, future=True) + + # Session setup + AsyncSessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False, autoflush=False) + + # Creating and dropping tables must be done through engine, not session + # .begin() makes sure that transaction is atomic + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + # run_sync - 'this method allows traditional synchronous SQLAlchemy functions to run within the context of an asyncio application' + + async with AsyncSessionLocal() as session: + yield session + + # Clear database after test + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() # Clear the enginge if no longer used + + +@pytest.fixture +async def client(test_db: AsyncSession) -> AsyncClient: + """ + TestClient FastAPI with test database. + Used to test endpoints, simulates user's action. + """ + # Function for overriding to test database + async def override_get_db(): + yield test_db + + app.dependency_overrides[get_db] = override_get_db # For test purpose switching real DB with TEST_DB + + # For endpoint testing client is needed + # app=app means send a request through the app, not web + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://localhost" + ) as c: # For test url can be random, httpx needs it + yield c + + app.dependency_overrides.clear() + + +@pytest.fixture +async def redis_client(): + """ + Fixture for redis client + """ + client = redis.from_url("redis://localhost:6379", decode_responses=True) # Pytest connects from localhost + + async def override_get_redis(): + yield client + + app.dependency_overrides[get_redis] = override_get_redis # Overriding for test purpose + + yield client + + # Clearing overrides after test + app.dependency_overrides.pop(get_redis, None) + + # Cleaning base after test + await client.flushdb() + await client.aclose() + + +@pytest.fixture +def user_data(): + """ + Data for registration/JWT + """ + return { + "email": "test@email.com", + "password": "PasswordTest.123!", + "username": "tester123" + } + + +@pytest.fixture +async def registered_user( + client: AsyncClient, + user_data: dict +): + """ + Fixture for user registration. + Pass as argument for silent POST, in code user_data may be used. + """ + response = await client.post("/api/v1/auth/register", json=user_data) + + assert response.status_code == 201 + return response.json() + + +@pytest.fixture +async def logged_user( + client: AsyncClient, + registered_user: dict, + user_data: dict +): + """ + Fixture for logged user header with JWT. + """ + login_params = { + # .get("email") may be used for email as username + "username": user_data.get("username"), + "password": user_data.get("password") + } + + response = await client.post("/api/v1/auth/token", data=login_params) + + assert response.status_code == 200 + data = response.json() + token = data.get("access_token") + + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +async def user_with_api_key( + client: AsyncClient, + logged_user: dict +): + """ + Fixture for user with generated API Key + """ + response = await client.post("/api/v1/auth/me/api-key", headers=logged_user) + assert response.status_code == 200 + data = response.json() + return data.get("api_key") + + +@pytest.fixture +async def example_validation( + client: AsyncClient, + user_with_api_key: str, + mocker +): + """ + Fixture with mocked API call + """ + mock_api = mocker.patch("app.api.v1.endpoints.business.check_vies_vat") + mock_api.return_value = { + 'name': 'FABRYKA MEBLI BODZIO BOGDAN SZEWCZYK SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ', + 'vat_number': '9111852372', + 'country_code': 'PL', + 'address': 'ul. Koszykowa 61, 00-667 Warszawa', + 'is_valid': True + } + + headers = { + "X-API-KEY": user_with_api_key + } + + data = { + "tax_id": "9111852372" # Example tax ID + } + + response = await client.post( + "/api/v1/business/validate", + headers=headers, + json=data + ) + + assert response.status_code == 200 diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/test_health_service.py b/tests/services/test_health_service.py new file mode 100644 index 0000000..6a1e506 --- /dev/null +++ b/tests/services/test_health_service.py @@ -0,0 +1,75 @@ +import pytest +from app.services.health_service import check_vies_health + + +@pytest.mark.services +async def test_health_service(mocker): + """ + Test for health service + """ + + # Async because basic funcion uses AsyncClient + mock_client = mocker.AsyncMock() + + # async with AsyncClient() as client → __aenter__.return_value to "client" + # await client.get() → .get.return_value to "response" + # response.status_code → .status_code = 200 + mock_client.__aenter__.return_value.get.return_value.status_code = 200 + + mocker.patch( + "app.services.health_service.httpx.AsyncClient", + return_value=mock_client + ) + + result = await check_vies_health() + assert result["status"] == "operational" + assert result["service"] == "VIES" + assert isinstance(result["latency_ms"], int) + + +@pytest.mark.services +async def test_health_service_wrong_status(mocker): + """ + Test for health service with wrong status code. + """ + + # Async because basic funcion uses AsyncClient + mock_client = mocker.AsyncMock() + + # async with AsyncClient() as client → __aenter__.return_value to "client" + # await client.get() → .get.return_value to "response" + # response.status_code → .status_code = 200 + mock_client.__aenter__.return_value.get.return_value.status_code = 404 + + mocker.patch( + "app.services.health_service.httpx.AsyncClient", + return_value=mock_client + ) + + result = await check_vies_health() + assert result["status"] == "degraded" + assert result["service"] == "VIES" + assert isinstance(result["latency_ms"], int) + + +@pytest.mark.services +async def test_health_service_exception(mocker): + """ + Test for health service with Exception. + """ + + # Async because basic funcion uses AsyncClient + mock_client = mocker.AsyncMock() + + # async with AsyncClient() as client → __aenter__.return_value to "client" + mock_client.__aenter__.return_value.get.side_effect = Exception() + + mocker.patch( + "app.services.health_service.httpx.AsyncClient", + return_value=mock_client + ) + + result = await check_vies_health() + assert result["status"] == "down" + assert result["service"] == "VIES" + assert result["latency_ms"] is None diff --git a/tests/services/test_vies_service.py b/tests/services/test_vies_service.py new file mode 100644 index 0000000..2a2b3d0 --- /dev/null +++ b/tests/services/test_vies_service.py @@ -0,0 +1,137 @@ +import pytest +from app.services.vies_service import check_vies_vat +from zeep.exceptions import TransportError, Fault +import httpx +import app.services.vies_service as vies_module + + +@pytest.mark.services +async def test_vies_service_TransportError(mocker): + """ + Testing Transport Error in VIES service API. + Happy path is tested in endpoints. + Mocking client not to create external API call. + """ + + # Closing client before test + vies_module._client = None + + mock_client = mocker.AsyncMock() + mock_client.service.checkVat.side_effect = TransportError() + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert result == {"error": "VIES connection error"} + + +@pytest.mark.services +async def test_vies_service_ConnectionError(mocker): + """ + Testing Connection Error in VIES service API. + Happy path is tested in endpoints. + Mocking client not to create external API call. + """ + # Closing client before test + vies_module._client = None + + mock_client = mocker.AsyncMock() + mock_client.service.checkVat.side_effect = ConnectionError() + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert result == {"error": "VIES connection error"} + + +@pytest.mark.services +async def test_vies_service_HTTPError(mocker): + """ + Testing HTTP Error in VIES service API. + Happy path is tested in endpoints. + Mocking client not to create external API call. + """ + + # Closing client before test + vies_module._client = None + + mock_client = mocker.AsyncMock() + mock_client.service.checkVat.side_effect = httpx.HTTPError("error") # Needs a message as arg + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert result == {"error": "VIES connection error"} + + +@pytest.mark.services +async def test_vies_service_Fault(mocker): + """ + Testing Fault for SOAP in VIES service API. + Happy path is tested in endpoints. + Mocking client not to create external API call. + """ + # Closing client before test + vies_module._client = None + + mock_client = mocker.AsyncMock() + mock_client.service.checkVat.side_effect = Fault("error") + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert result == {"error": "Wrong data provided"} + + +@pytest.mark.services +async def test_vies_service_Exception(mocker): + """ + Testing Exception in VIES service API. + Happy path is tested in endpoints. + Mocking client not to create external API call. + """ + # Closing client before test + vies_module._client = None + + mock_client = mocker.AsyncMock() + mock_client.service.checkVat.side_effect = Exception() + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert "error" in result + assert "Error fetching data: " in result.get("error") + + +@pytest.mark.services +async def test_vies_services_error_client_cleanup(mocker): + """ + Test for client cleanup during error. + Must be assigned before function starts + """ + mock_client = mocker.AsyncMock() + + # Assign client + vies_module._client = mock_client + mock_client.service.checkVat.side_effect = TransportError() + + mocker.patch( + "app.services.vies_service.get_client", + return_value=mock_client + ) + + result = await check_vies_vat("PL", "1234567899") + assert "error" in result + + # Check the cleanup + assert vies_module._client is None diff --git a/tests/validators/__init__.py b/tests/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/validators/test_nip_validator.py b/tests/validators/test_nip_validator.py new file mode 100644 index 0000000..6899e72 --- /dev/null +++ b/tests/validators/test_nip_validator.py @@ -0,0 +1,39 @@ +import pytest +from app.core.validators import validate_nip + + +@pytest.mark.validators +def test_validate_nip(): + """ + Test for nip validator + """ + + result = validate_nip("5272184991") + assert result == "5272184991" + + +@pytest.mark.validators +def test_validate_nip_wrong_length(): + """ + Test for nip validator. + ValueError trigger. + """ + + with pytest.raises(ValueError) as error: + validate_nip("12345678") + + assert "NIP must be 10 digits" in str(error.value) + + +@pytest.mark.validators +def test_validate_nip_ValueError(): + """ + Test for nip validator. + ValueError trigger. + """ + + with pytest.raises(ValueError) as error: + validate_nip("1234567899") + + assert "Entered NIP is not valid number" in str(error.value) + diff --git a/tests/validators/test_password_validator.py b/tests/validators/test_password_validator.py new file mode 100644 index 0000000..779106e --- /dev/null +++ b/tests/validators/test_password_validator.py @@ -0,0 +1,35 @@ +import pytest +from pydantic import ValidationError +from app.schemas.user import UserCreate + + +@pytest.mark.validators +def test_password_no_lowercase(): + with pytest.raises(ValidationError) as error: + UserCreate(email="test@test.com", password="TESTING.123!") + + assert "Password must contain at least one lowercase letter" in str(error.value) + + +@pytest.mark.validators +def test_password_no_uppercase(): + with pytest.raises(ValidationError) as error: + UserCreate(email="test@test.com", password="testing.123!") + + assert "Password must contain at least one uppercase letter" in str(error.value) + + +@pytest.mark.validators +def test_password_no_digit(): + with pytest.raises(ValidationError) as error: + UserCreate(email="test@test.com", password="Testing!") + + assert "Password must contain at least one digit" in str(error.value) + + +@pytest.mark.validators +def test_password_no_special(): + with pytest.raises(ValidationError) as error: + UserCreate(email="test@test.com", password="Testing123") + + assert "Password must contain at least one special character" in str(error.value) \ No newline at end of file diff --git a/tests/validators/test_regon_validator.py b/tests/validators/test_regon_validator.py new file mode 100644 index 0000000..095fdd5 --- /dev/null +++ b/tests/validators/test_regon_validator.py @@ -0,0 +1,38 @@ +import pytest +from app.core.validators import validate_regon + + +@pytest.mark.validators +def test_validate_regon(): + """ + Test for regon validator + """ + + result = validate_regon("146108856") + assert result == "146108856" + + +@pytest.mark.validators +def test_validate_regon_wrong_length(): + """ + Test for regon validator. + Length != 9 + """ + + with pytest.raises(ValueError) as error: + validate_regon("14610885116") + + assert "REGON must be 9 digits" in str(error.value) + + +@pytest.mark.validators +def test_validate_regon_invalid(): + """ + Test for regon validator. + Invalid number + """ + + with pytest.raises(ValueError) as error: + validate_regon("123456789") + + assert "REGON is not valid" in str(error.value)