Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ Thumbs.db

# Database
postgres_data/

# My files
app/services/temp_test_service.py
2 changes: 1 addition & 1 deletion app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion app/core/limiter.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 0 additions & 45 deletions app/services/test_service.py

This file was deleted.

2 changes: 1 addition & 1 deletion app/services/vies_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE DATABASE business_db_test;
33 changes: 33 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -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
29 changes: 17 additions & 12 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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

---

Expand Down Expand Up @@ -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)

---

Expand All @@ -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

---

Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -35,13 +37,18 @@ 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
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
Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/api/v1/__init__.py
Empty file.
Empty file.
29 changes: 29 additions & 0 deletions tests/api/v1/endpoints/auth/test_api_key.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions tests/api/v1/endpoints/auth/test_logout.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions tests/api/v1/endpoints/auth/test_me.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading