feat: add test framework, unit tests, and GitLab CI pipeline #1
@@ -0,0 +1,104 @@
|
||||
# CLAUDE.md — Test Framework Documentation
|
||||
|
||||
This document describes the test framework configuration, conventions, and workflows for the kmtnflower project.
|
||||
|
||||
## Overview
|
||||
|
||||
The project uses two test frameworks:
|
||||
- **pytest** for the FastAPI backend (Python)
|
||||
- **Vitest** for the Angular frontend (TypeScript)
|
||||
|
||||
## Backend Testing (pytest)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements-test.txt
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# Run a single test file
|
||||
python -m pytest tests/test_log_parser.py -v
|
||||
|
||||
# Run with coverage report
|
||||
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=html
|
||||
|
||||
# Enforce minimum coverage threshold
|
||||
python -m pytest tests/ --cov=app --cov-fail-under=10
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_auth.py # Authentication & JWT tests
|
||||
├── test_log_parser.py # Log parsing & geolocation tests
|
||||
├── test_storage.py # Database operation tests
|
||||
├── test_theme_export.py # Theme export tests
|
||||
├── test_config.py # Configuration tests
|
||||
└── test_models.py # SQLAlchemy model tests
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Use `pytest` fixtures in `conftest.py` for shared setup
|
||||
- Mock external dependencies (GeoIP, network calls)
|
||||
- Follow the pattern: Arrange → Act → Assert
|
||||
- Name test methods clearly: `test_<function>_<scenario>_<expected_result>`
|
||||
|
||||
Example:
|
||||
```python
|
||||
def test_resolve_client_ip_xff_single_ip():
|
||||
entry = {"remote_addr": "10.0.0.1", "x_forwarded_for": "203.0.113.50"}
|
||||
assert resolve_client_ip(entry) == "203.0.113.50"
|
||||
```
|
||||
|
||||
## Frontend Testing (Vitest)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
npm install --save-dev vitest @vitest/coverage-v8 jsdom
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx vitest run
|
||||
|
||||
# Watch mode
|
||||
npx vitest
|
||||
|
||||
# With coverage
|
||||
npx vitest run --coverage
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The Vitest config is in `vitest.config.ts` at the project root.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The Gitea Actions workflow (`.gitea/workflows/ci.yaml`) runs:
|
||||
1. `backend-test` — pytest with coverage enforcement
|
||||
2. `frontend-test` — Vitest unit tests
|
||||
3. `backend-lint` — ruff linting
|
||||
4. `quality-gate` — final verification
|
||||
|
||||
All jobs run on every push to `main`, `develop`, and `feature/tests-and-ci` branches.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Tests should be deterministic and isolated
|
||||
- Mock external dependencies (GeoIP DB, network, file system)
|
||||
- Use fixtures for repeated setup code
|
||||
- Keep tests fast — avoid slow operations in test suites
|
||||
- Document test coverage in commit messages when adding new tests
|
||||
@@ -192,10 +192,45 @@ For production deployment, serve the built static files with Nginx and run the b
|
||||
|
||||
## Testing
|
||||
|
||||
This project uses a dual test framework: **pytest** for the Python backend and **Vitest** for the Angular frontend.
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
```bash
|
||||
ng test # Angular + Vitest
|
||||
cd backend
|
||||
pip install -r requirements-test.txt
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
The test suite covers:
|
||||
- **auth** — JWT token generation, validation, and bootstrap admin login
|
||||
- **log_parser** — IP resolution (X-Forwarded-For), log line parsing, geo lookups, and file parsing
|
||||
- **storage** — database operations and session management
|
||||
- **theme_export** — theme generation and serialization
|
||||
- **config** — environment variable loading and defaults
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=10
|
||||
```
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
```bash
|
||||
npm install --save-dev vitest @vitest/coverage-v8 jsdom
|
||||
npx vitest run --reporter=verbose
|
||||
```
|
||||
|
||||
The frontend test suite uses Vitest with jsdom for DOM-level component tests. Config is in `vitest.config.ts`.
|
||||
|
||||
### CI
|
||||
|
||||
The project uses Gitea Actions (`.gitea/workflows/ci.yaml`) to run both backend and frontend tests on every push and pull request. The pipeline includes:
|
||||
- `backend-test` — pytest with coverage enforcement (>= 10%)
|
||||
- `frontend-test` — Vitest unit tests
|
||||
- `backend-lint` — ruff linting on the backend code
|
||||
- `quality-gate` — final verification stage
|
||||
|
||||
The backend API is self-documented at [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints.
|
||||
|
||||
## Admin Dashboard — Visitor Stats
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.log_parser import parse_log_line, resolve_client_ip, _COUNTRY_CENTROIDS
|
||||
from app.log_parser import (
|
||||
parse_log_line,
|
||||
resolve_client_ip,
|
||||
_COUNTRY_CENTROIDS,
|
||||
lookup_geo,
|
||||
)
|
||||
|
||||
|
||||
class TestParseLogLine:
|
||||
@@ -138,3 +144,84 @@ class TestParseLogFile:
|
||||
result = parse_log_file(str(log_file))
|
||||
assert "203.0.113.1" in result["unique_ips"]
|
||||
assert "10.0.0.1" not in result["unique_ips"]
|
||||
|
||||
|
||||
class TestLookupGeo:
|
||||
"""Tests for lookup_geo() — verify IPs map to countries via our APIs."""
|
||||
|
||||
def _reset_geo_reader(self):
|
||||
"""Clear the cached reader so mocks take effect."""
|
||||
import app.log_parser as lp
|
||||
|
||||
lp._geo_reader = None
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_ip_maps_to_country(self, mock_settings):
|
||||
"""Verify a known IP resolves to a country code via the GeoLite2 reader."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
# Create a fake reader that returns US data
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = {
|
||||
"country": {"iso_code": "US", "names": {"en": "United States"}},
|
||||
"city": {"names": {"en": "New York"}},
|
||||
"location": {"latitude": 40.7128, "longitude": -74.006},
|
||||
}
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("8.8.8.8")
|
||||
|
||||
assert result["country_code"] == "US"
|
||||
assert result["country"] == "United States"
|
||||
assert result["city"] == "New York"
|
||||
assert result["latitude"] == 40.7128
|
||||
assert result["longitude"] == -74.006
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_ip_without_coordinates_uses_centroid_fallback(self, mock_settings):
|
||||
"""When the free GeoLite2 DB lacks coordinates, centroid fallback kicks in."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
# Simulate free GeoLite2 response — has country but no coordinates
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = {
|
||||
"country": {"iso_code": "DE", "names": {"en": "Germany"}},
|
||||
"city": {"names": {"en": ""}},
|
||||
"location": {}, # No lat/lon
|
||||
}
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("1.1.1.1")
|
||||
|
||||
assert result["country_code"] == "DE"
|
||||
assert result["country"] == "Germany"
|
||||
# Falls back to _COUNTRY_CENTROIDS["DE"]
|
||||
assert result["latitude"] == _COUNTRY_CENTROIDS["DE"][0]
|
||||
assert result["longitude"] == _COUNTRY_CENTROIDS["DE"][1]
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_unknown_ip_returns_empty_dict(self, mock_settings):
|
||||
"""An IP not in the database returns an empty dict."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = None
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("255.255.255.255")
|
||||
|
||||
assert result == {}
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_reader_error_returns_empty_dict(self, mock_settings):
|
||||
"""When the reader raises an exception, lookup_geo() returns safely."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
with patch("maxminddb.open_database", side_effect=Exception("reader failed")):
|
||||
result = lookup_geo("1.2.3.4")
|
||||
|
||||
assert result == {}
|
||||
|
||||
Reference in New Issue
Block a user