From 3bd970afcc920ccf3e361040825d982c4193c599 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 3 Mar 2026 22:44:11 +0100 Subject: [PATCH 01/17] Pytest config --- .../{test_service.py => temp_test_service.py} | 0 docker-compose.yml | 2 + init.sql | 1 + pytest.ini | 27 +++++++ requirements.txt | 3 + tests/__init__.py | 0 tests/api/v1/test_auth.py | 15 ++++ tests/api/v1/test_business.py | 2 + tests/conftest.py | 78 +++++++++++++++++++ 9 files changed, 128 insertions(+) rename app/services/{test_service.py => temp_test_service.py} (100%) create mode 100644 init.sql create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/api/v1/test_auth.py create mode 100644 tests/api/v1/test_business.py create mode 100644 tests/conftest.py diff --git a/app/services/test_service.py b/app/services/temp_test_service.py similarity index 100% rename from app/services/test_service.py rename to app/services/temp_test_service.py 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..d47c989 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,27 @@ +# 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 + +# Section for registering markers +markers = + auth: users authentication endpoints test + business: business validation endpoints test \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fc24d39..f52132e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,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 +36,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 +44,7 @@ pydantic-extra-types==2.11.0 pydantic-settings==2.12.0 pydantic_core==2.41.5 Pygments==2.19.2 +pytest==9.0.2 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/test_auth.py b/tests/api/v1/test_auth.py new file mode 100644 index 0000000..e686f6e --- /dev/null +++ b/tests/api/v1/test_auth.py @@ -0,0 +1,15 @@ +from httpx import AsyncClient +import pytest + + +@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 diff --git a/tests/api/v1/test_business.py b/tests/api/v1/test_business.py new file mode 100644 index 0000000..fe64b98 --- /dev/null +++ b/tests/api/v1/test_business.py @@ -0,0 +1,2 @@ +from httpx import AsyncClient +import pytest \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..51f8b9e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,78 @@ +import pytest +import os + +# Overriding env for test purpose +os.environ["POSTGRES_SERVER"] = "localhost" +os.environ["SENTRY_URL"] = "" + +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 +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 + 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 +def user_data(): + return { + "email": "test@email.com", + "password": "PasswordTest.123!", + "username": "tester123" + } \ No newline at end of file From f7d4b7108b62ed73959bc9fd865b877166186616 Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 4 Mar 2026 22:31:02 +0100 Subject: [PATCH 02/17] creating tests for auth/register endpoint --- app/services/temp_test_service.py | 2 +- tests/api/v1/endpoints/auth/test_register.py | 61 +++++++++++++++++++ .../{ => endpoints/business}/test_business.py | 0 tests/api/v1/test_auth.py | 15 ----- tests/conftest.py | 2 +- 5 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 tests/api/v1/endpoints/auth/test_register.py rename tests/api/v1/{ => endpoints/business}/test_business.py (100%) delete mode 100644 tests/api/v1/test_auth.py diff --git a/app/services/temp_test_service.py b/app/services/temp_test_service.py index 6ecb643..ca6b87d 100644 --- a/app/services/temp_test_service.py +++ b/app/services/temp_test_service.py @@ -34,7 +34,7 @@ def check_vies_vat(country: str, nip: str): client = Client(wsdl=WSDL) result = client.service.checkVat(countryCode=country, vatNumber=nip) - + result_dict = serialize_object(result) return result_dict 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..7e1ff2c --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_register.py @@ -0,0 +1,61 @@ +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, + user_data: dict +): + """ + Testing error for email duplication + """ + response = await client.post("/api/v1/auth/register", json=user_data) + assert response.status_code == 201 + + 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, + user_data: dict +): + """ + Testing error for username duplication + """ + response = await client.post("/api/v1/auth/register", json=user_data) + assert response.status_code == 201 + + 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/test_business.py b/tests/api/v1/endpoints/business/test_business.py similarity index 100% rename from tests/api/v1/test_business.py rename to tests/api/v1/endpoints/business/test_business.py diff --git a/tests/api/v1/test_auth.py b/tests/api/v1/test_auth.py deleted file mode 100644 index e686f6e..0000000 --- a/tests/api/v1/test_auth.py +++ /dev/null @@ -1,15 +0,0 @@ -from httpx import AsyncClient -import pytest - - -@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 diff --git a/tests/conftest.py b/tests/conftest.py index 51f8b9e..ce69e68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,7 @@ async def client(test_db: AsyncSession) -> AsyncClient: Used to test endpoints, simulates user's action. """ # Function for overriding to test database - def override_get_db(): + 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 From efc17d91b949a3b058c55252f8c72cd3e9e3db0a Mon Sep 17 00:00:00 2001 From: kuba Date: Sat, 7 Mar 2026 10:52:11 +0100 Subject: [PATCH 03/17] fixtures for commomn actions --- tests/api/v1/endpoints/auth/test_me.py | 22 ++++ tests/api/v1/endpoints/auth/test_refresh.py | 2 + tests/api/v1/endpoints/auth/test_register.py | 7 +- tests/api/v1/endpoints/auth/test_token.py | 117 +++++++++++++++++++ tests/conftest.py | 45 ++++++- 5 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 tests/api/v1/endpoints/auth/test_me.py create mode 100644 tests/api/v1/endpoints/auth/test_refresh.py create mode 100644 tests/api/v1/endpoints/auth/test_token.py 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..48b6b89 --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_refresh.py @@ -0,0 +1,2 @@ +from httpx import AsyncClient +import pytest diff --git a/tests/api/v1/endpoints/auth/test_register.py b/tests/api/v1/endpoints/auth/test_register.py index 7e1ff2c..3b78fee 100644 --- a/tests/api/v1/endpoints/auth/test_register.py +++ b/tests/api/v1/endpoints/auth/test_register.py @@ -26,13 +26,12 @@ async def test_register_user( @pytest.mark.auth async def test_register_duplicate_email( client: AsyncClient, + registered_user: dict, user_data: dict ): """ Testing error for email duplication """ - response = await client.post("/api/v1/auth/register", json=user_data) - assert response.status_code == 201 user_data["username"] = "testing_email" @@ -45,14 +44,12 @@ async def test_register_duplicate_email( @pytest.mark.auth async def test_register_duplicate_username( client: AsyncClient, + registered_user: dict, user_data: dict ): """ Testing error for username duplication """ - response = await client.post("/api/v1/auth/register", json=user_data) - assert response.status_code == 201 - user_data["email"] = "test@testing.com" response_email_error = await client.post("/api/v1/auth/register", json=user_data) 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..2294b71 --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_token.py @@ -0,0 +1,117 @@ +from httpx import AsyncClient +import pytest +from app.core.auth import decode_access_token, verify_refresh_token + + +@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" + + # 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/conftest.py b/tests/conftest.py index ce69e68..eafb7b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,8 +71,51 @@ async def override_get_db(): @pytest.fixture def user_data(): + """ + Data for registration/JWT + """ return { "email": "test@email.com", "password": "PasswordTest.123!", "username": "tester123" - } \ No newline at end of file + } + + +@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}"} + From aeebb68930d3b754c6ccfa81baac65e33b6e2e48 Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 9 Mar 2026 22:56:40 +0100 Subject: [PATCH 04/17] test for refresh token --- app/api/v1/endpoints/auth.py | 2 +- tests/api/v1/endpoints/auth/test_refresh.py | 99 +++++++++++++++++++++ tests/api/v1/endpoints/auth/test_token.py | 4 + 3 files changed, 104 insertions(+), 1 deletion(-) 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/tests/api/v1/endpoints/auth/test_refresh.py b/tests/api/v1/endpoints/auth/test_refresh.py index 48b6b89..00e50ce 100644 --- a/tests/api/v1/endpoints/auth/test_refresh.py +++ b/tests/api/v1/endpoints/auth/test_refresh.py @@ -1,2 +1,101 @@ 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 +): + 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 +): + # 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 +): + # 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_token.py b/tests/api/v1/endpoints/auth/test_token.py index 2294b71..18bec3c 100644 --- a/tests/api/v1/endpoints/auth/test_token.py +++ b/tests/api/v1/endpoints/auth/test_token.py @@ -1,6 +1,7 @@ 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 @@ -28,6 +29,9 @@ async def test_token_with_username( 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 From 3a6040bcf6768ca34b0ec150be2366a26640f7e5 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 10 Mar 2026 10:26:46 +0100 Subject: [PATCH 05/17] pytest for auth endpoint --- tests/api/v1/endpoints/auth/test_api_key.py | 29 +++++++++++++++++++++ tests/api/v1/endpoints/auth/test_logout.py | 29 +++++++++++++++++++++ tests/api/v1/endpoints/auth/test_refresh.py | 6 +++++ 3 files changed, 64 insertions(+) create mode 100644 tests/api/v1/endpoints/auth/test_api_key.py create mode 100644 tests/api/v1/endpoints/auth/test_logout.py 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..0444038 --- /dev/null +++ b/tests/api/v1/endpoints/auth/test_logout.py @@ -0,0 +1,29 @@ +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 == "" diff --git a/tests/api/v1/endpoints/auth/test_refresh.py b/tests/api/v1/endpoints/auth/test_refresh.py index 00e50ce..3f8811e 100644 --- a/tests/api/v1/endpoints/auth/test_refresh.py +++ b/tests/api/v1/endpoints/auth/test_refresh.py @@ -37,6 +37,9 @@ 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() @@ -48,6 +51,9 @@ 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 From 8741fe107cffc58e886d161efe5056ca71374f19 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 10 Mar 2026 10:28:03 +0100 Subject: [PATCH 06/17] fix: doctring for test functions --- tests/api/v1/endpoints/auth/test_logout.py | 6 +++++- tests/api/v1/endpoints/auth/test_refresh.py | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/api/v1/endpoints/auth/test_logout.py b/tests/api/v1/endpoints/auth/test_logout.py index 0444038..f623987 100644 --- a/tests/api/v1/endpoints/auth/test_logout.py +++ b/tests/api/v1/endpoints/auth/test_logout.py @@ -26,4 +26,8 @@ async def test_logout( 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 == "" + 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_refresh.py b/tests/api/v1/endpoints/auth/test_refresh.py index 3f8811e..2a2ed4b 100644 --- a/tests/api/v1/endpoints/auth/test_refresh.py +++ b/tests/api/v1/endpoints/auth/test_refresh.py @@ -85,6 +85,9 @@ async def test_refresh_expired( 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 From 12f45a846524fe952e276bd2032135f5d1dd21b5 Mon Sep 17 00:00:00 2001 From: kuba Date: Thu, 12 Mar 2026 21:59:57 +0100 Subject: [PATCH 07/17] initializing tests for business endpoints --- .github/workflows/tests.yml | 35 ++++ requirements.txt | 1 + .../v1/endpoints/business/test_business.py | 153 +++++++++++++++++- tests/conftest.py | 13 ++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6c13bbd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +# # Config file for GitHub Actions +# TODO +# 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' + +# - 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 + +# - name: Wait for DB +# run: sleep 5 # Wait for DB setup + +# - name: Run tests +# env: +# PYTHONPATH: ${{ github.workspace }} # github.workspace is a root dir +# run: +# pytest --cov=app --cov-report=term-missing \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f52132e..2cd7cbc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,6 +45,7 @@ pydantic-settings==2.12.0 pydantic_core==2.41.5 Pygments==2.19.2 pytest==9.0.2 +pytest-asyncio==1.3.0 python-dotenv==1.2.1 python-jose==3.5.0 python-multipart==0.0.22 diff --git a/tests/api/v1/endpoints/business/test_business.py b/tests/api/v1/endpoints/business/test_business.py index fe64b98..3d4e3cb 100644 --- a/tests/api/v1/endpoints/business/test_business.py +++ b/tests/api/v1/endpoints/business/test_business.py @@ -1,2 +1,153 @@ from httpx import AsyncClient -import pytest \ No newline at end of file +import pytest +import uuid + + +@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() + 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 diff --git a/tests/conftest.py b/tests/conftest.py index eafb7b8..e6b3379 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,3 +119,16 @@ async def logged_user( 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") \ No newline at end of file From 003080059113a7cd304e69dff61d33bb177e94f6 Mon Sep 17 00:00:00 2001 From: kuba Date: Fri, 13 Mar 2026 11:40:54 +0100 Subject: [PATCH 08/17] endpoint tests for business validation --- .../v1/endpoints/business/test_business.py | 50 +++++++++++++++++++ tests/conftest.py | 25 +++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/api/v1/endpoints/business/test_business.py b/tests/api/v1/endpoints/business/test_business.py index 3d4e3cb..2cace96 100644 --- a/tests/api/v1/endpoints/business/test_business.py +++ b/tests/api/v1/endpoints/business/test_business.py @@ -1,6 +1,7 @@ from httpx import AsyncClient import pytest import uuid +import json @pytest.mark.business @@ -87,6 +88,7 @@ async def test_validate_business_mock( assert response.status_code == 200 business_data = response.json() + print(f"MOJA DATA: {business_data}") assert business_data is not None # Business check ID @@ -151,3 +153,51 @@ async def test_validate_business_check_db( # 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/conftest.py b/tests/conftest.py index e6b3379..5183ba0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import pytest import os +import redis.asyncio as redis # Overriding env for test purpose os.environ["POSTGRES_SERVER"] = "localhost" @@ -9,7 +10,7 @@ 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 +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" @@ -69,6 +70,28 @@ async def override_get_db(): 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(): """ From 54ce49f6dd22166df722ac1c33671fdab889fede Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 16:51:36 +0100 Subject: [PATCH 09/17] endpoint tests passed --- app/core/limiter.py | 6 ++- .../api/v1/endpoints/business/test_history.py | 54 +++++++++++++++++++ tests/api/v1/endpoints/business/test_stats.py | 29 ++++++++++ ..._business.py => test_validate_business.py} | 0 tests/conftest.py | 38 ++++++++++++- 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/api/v1/endpoints/business/test_history.py create mode 100644 tests/api/v1/endpoints/business/test_stats.py rename tests/api/v1/endpoints/business/{test_business.py => test_validate_business.py} (100%) 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/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_business.py b/tests/api/v1/endpoints/business/test_validate_business.py similarity index 100% rename from tests/api/v1/endpoints/business/test_business.py rename to tests/api/v1/endpoints/business/test_validate_business.py diff --git a/tests/conftest.py b/tests/conftest.py index 5183ba0..16518d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ # 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 @@ -154,4 +155,39 @@ async def user_with_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") \ No newline at end of file + 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 From 2b443f371c295f0d4bb43706b17d4511b36ef22b Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 20:28:09 +0100 Subject: [PATCH 10/17] Adding GitHub Actions pipeline --- .github/workflows/tests.yml | 89 ++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c13bbd..48ea31b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,35 +1,54 @@ -# # Config file for GitHub Actions -# TODO -# 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' - -# - 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 - -# - name: Wait for DB -# run: sleep 5 # Wait for DB setup - -# - name: Run tests -# env: -# PYTHONPATH: ${{ github.workspace }} # github.workspace is a root dir -# run: -# pytest --cov=app --cov-report=term-missing \ No newline at end of file +# 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 From 23d7ed0de7cc80f011857daa311d86d95b593463 Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 20:48:23 +0100 Subject: [PATCH 11/17] fix: pytest-cov added to requirements.txt --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 2cd7cbc..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 @@ -46,6 +47,8 @@ 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 From 61e12f16baf57c311e08e14a30e2dc931ee083e0 Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 21:08:27 +0100 Subject: [PATCH 12/17] fix: __init__ file added to test for endpoints --- .github/workflows/tests.yml | 3 ++- tests/api/v1/__init__.py | 0 tests/api/v1/endpoints/auth/__init__.py | 0 tests/api/v1/endpoints/business/__init__.py | 0 4 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 tests/api/v1/__init__.py create mode 100644 tests/api/v1/endpoints/auth/__init__.py create mode 100644 tests/api/v1/endpoints/business/__init__.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 48ea31b..40f910e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,4 +51,5 @@ jobs: 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 + pytest --cov=app --cov-report=term-missing --cov-fail-under=80 + \ No newline at end of file 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/business/__init__.py b/tests/api/v1/endpoints/business/__init__.py new file mode 100644 index 0000000..e69de29 From ec47684868af4f6dbecf079df5818017bd487d6c Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 21:54:22 +0100 Subject: [PATCH 13/17] Added vies service test --- app/services/vies_service.py | 2 +- pytest.ini | 3 +- tests/api/v1/services/__init__.py | 0 tests/api/v1/services/test_vies_service.py | 100 +++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 tests/api/v1/services/__init__.py create mode 100644 tests/api/v1/services/test_vies_service.py 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/pytest.ini b/pytest.ini index d47c989..f58e3bb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -24,4 +24,5 @@ addopts = # Section for registering markers markers = auth: users authentication endpoints test - business: business validation endpoints test \ No newline at end of file + business: business validation endpoints test + services: test for services \ No newline at end of file diff --git a/tests/api/v1/services/__init__.py b/tests/api/v1/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/services/test_vies_service.py b/tests/api/v1/services/test_vies_service.py new file mode 100644 index 0000000..845b199 --- /dev/null +++ b/tests/api/v1/services/test_vies_service.py @@ -0,0 +1,100 @@ +import pytest +from app.services.vies_service import check_vies_vat +from zeep.exceptions import TransportError, Fault +import httpx + + +@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. + """ + + 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. + """ + + 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. + """ + + 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. + """ + + 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. + """ + + 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") From e01195c4fd270b9f3ced11a5503d6d7e125765e5 Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 22:01:50 +0100 Subject: [PATCH 14/17] Remove temp test service --- app/services/temp_test_service.py | 45 ------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 app/services/temp_test_service.py diff --git a/app/services/temp_test_service.py b/app/services/temp_test_service.py deleted file mode 100644 index ca6b87d..0000000 --- a/app/services/temp_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 From b07c106199f5bc1b90d3d4f6cd5969bdbfdcbaaa Mon Sep 17 00:00:00 2001 From: kuba Date: Mon, 16 Mar 2026 22:37:24 +0100 Subject: [PATCH 15/17] Services tests added --- .gitignore | 3 + tests/api/v1/services/test_health_service.py | 75 ++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/api/v1/services/test_health_service.py 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/tests/api/v1/services/test_health_service.py b/tests/api/v1/services/test_health_service.py new file mode 100644 index 0000000..6a1e506 --- /dev/null +++ b/tests/api/v1/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 From 67b44051eacc5acf83503ac1fb20feaab6110439 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 17 Mar 2026 08:27:21 +0100 Subject: [PATCH 16/17] Pytest cov limit reached --- pytest.ini | 9 ++++- tests/api/v1/endpoints/status/test_status.py | 29 ++++++++++++++ tests/{api/v1 => }/services/__init__.py | 0 .../v1 => }/services/test_health_service.py | 0 .../v1 => }/services/test_vies_service.py | 37 ++++++++++++++++++ tests/validators/__init__.py | 0 tests/validators/test_nip_validator.py | 39 +++++++++++++++++++ tests/validators/test_password_validator.py | 35 +++++++++++++++++ tests/validators/test_regon_validator.py | 38 ++++++++++++++++++ 9 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 tests/api/v1/endpoints/status/test_status.py rename tests/{api/v1 => }/services/__init__.py (100%) rename tests/{api/v1 => }/services/test_health_service.py (100%) rename tests/{api/v1 => }/services/test_vies_service.py (75%) create mode 100644 tests/validators/__init__.py create mode 100644 tests/validators/test_nip_validator.py create mode 100644 tests/validators/test_password_validator.py create mode 100644 tests/validators/test_regon_validator.py diff --git a/pytest.ini b/pytest.ini index f58e3bb..06db77b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -19,10 +19,15 @@ python_functions = test_* addopts = -v --strict-markers - --tb=short + --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 \ No newline at end of file + services: test for services + status: test for status endpoints + validators: test for validators \ No newline at end of file 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/api/v1/services/__init__.py b/tests/services/__init__.py similarity index 100% rename from tests/api/v1/services/__init__.py rename to tests/services/__init__.py diff --git a/tests/api/v1/services/test_health_service.py b/tests/services/test_health_service.py similarity index 100% rename from tests/api/v1/services/test_health_service.py rename to tests/services/test_health_service.py diff --git a/tests/api/v1/services/test_vies_service.py b/tests/services/test_vies_service.py similarity index 75% rename from tests/api/v1/services/test_vies_service.py rename to tests/services/test_vies_service.py index 845b199..2a2b3d0 100644 --- a/tests/api/v1/services/test_vies_service.py +++ b/tests/services/test_vies_service.py @@ -2,6 +2,7 @@ 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 @@ -12,6 +13,9 @@ async def test_vies_service_TransportError(mocker): 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( @@ -30,6 +34,8 @@ async def test_vies_service_ConnectionError(mocker): 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() @@ -50,6 +56,9 @@ async def test_vies_service_HTTPError(mocker): 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( @@ -68,6 +77,8 @@ async def test_vies_service_Fault(mocker): 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") @@ -87,6 +98,8 @@ async def test_vies_service_Exception(mocker): 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() @@ -98,3 +111,27 @@ async def test_vies_service_Exception(mocker): 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) From 378cc5fe22dd453612a433632a4567b6154b9ec7 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 17 Mar 2026 08:35:58 +0100 Subject: [PATCH 17/17] README updated after pytest --- readme.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) 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