diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..050c41e --- /dev/null +++ b/CLAUDE.md @@ -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___` + +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 diff --git a/README.md b/README.md index 8ef6ca0..b533b50 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/tests/test_log_parser.py b/backend/tests/test_log_parser.py index 0db5a36..d6824e0 100644 --- a/backend/tests/test_log_parser.py +++ b/backend/tests/test_log_parser.py @@ -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 == {}