fix: address PR review feedback
CI Pipeline / backend-test (push) Failing after 12s
CI Pipeline / frontend-test (push) Successful in 34s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Failing after 14s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (push) Failing after 12s
CI Pipeline / frontend-test (push) Successful in 34s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Failing after 14s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
- Remove .gitlab-ci.yml (duplicative with Gitea Actions workflow) - Add lookup_geo() unit tests with mocked MaxMind reader (4 tests) - Expand README.md Testing section with backend/frontend/CI docs - Add CLAUDE.md with test framework documentation
This commit is contained in:
@@ -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
|
## Testing
|
||||||
|
|
||||||
|
This project uses a dual test framework: **pytest** for the Python backend and **Vitest** for the Angular frontend.
|
||||||
|
|
||||||
|
### Backend (pytest)
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
## Admin Dashboard — Visitor Stats
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
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:
|
class TestParseLogLine:
|
||||||
@@ -138,3 +144,84 @@ class TestParseLogFile:
|
|||||||
result = parse_log_file(str(log_file))
|
result = parse_log_file(str(log_file))
|
||||||
assert "203.0.113.1" in result["unique_ips"]
|
assert "203.0.113.1" in result["unique_ips"]
|
||||||
assert "10.0.0.1" not 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