Files
arma-server-web-manager/phases/phase-09-testing.md
Khoa (Revenovich) Tran Gia e02db3ddde feat: add Java→Python migration plan with 9 self-contained phase files
Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python.
Each phase file is fully self-contained: lists Java source files to
read, output files to create, implementation patterns, REST endpoint
contracts, and a completion checklist. A future agent can execute any
single phase without rescanning the Java project.

Phases:
- 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas
- 02: Auth & Users — JWT middleware, RBAC, user CRUD
- 03: CFG parser + server process — server.cfg round-trip, start/stop
- 04: Server settings — general/network/logging/security/difficulty
- 05: Mod management — mod CRUD, presets, settings, WebSocket progress
- 06: Steam integration — SteamCMD queue, Workshop API, python-a2s
- 07: Missions, CDLC, Discord, APScheduler jobs
- 08: Middleware & polish — global exception handler, SPA redirect, structlog
- 09: Testing — pytest-asyncio, respx, 80% coverage target
2026-04-14 15:06:56 +07:00

648 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 9 — Testing
**Status**: PENDING
**Depends on**: Phases 18 all complete (`from src.main import app` must import without error)
**Next phase**: None — final phase
---
## Goal
Achieve ≥ 80 % line coverage across `src/`. Every public contract tested with real SQLite-in-memory DB and
`httpx.AsyncClient` over ASGI transport. No mocking of internal service classes. Steam API HTTP calls mocked
with `respx`.
---
## Tools & Libraries
| Package | Purpose |
|---------|---------|
| `pytest` | Test runner |
| `pytest-asyncio` | `asyncio_mode = "auto"` for async fixtures |
| `pytest-cov` | Coverage measurement |
| `httpx` | ASGI test client |
| `respx` | Mock httpx HTTP calls (Steam Web API) |
| `aiosqlite` | Async SQLite driver for in-memory test DB |
| `sqlalchemy[asyncio]` | Same ORM as production |
| `bcrypt` | Password hashing (same as production) |
| `python-jose` | JWT signing for test auth tokens |
Install:
```
pip install pytest pytest-asyncio pytest-cov httpx respx aiosqlite python-jose
```
---
## pytest.ini / pyproject.toml
```ini
[pytest]
asyncio_mode = auto
testpaths = tests
```
```
pytest --cov=src --cov-report=term-missing --cov-fail-under=80
```
---
## Output Files to Create
```
tests/__init__.py
tests/conftest.py
tests/unit/test_cfg_parser.py
tests/unit/test_permissions.py
tests/unit/test_password.py
tests/unit/test_mod_file_storage.py
tests/integration/test_auth.py
tests/integration/test_users.py
tests/integration/test_status.py
tests/integration/test_general_settings.py
tests/integration/test_mods.py
tests/integration/test_workshop.py
tests/integration/test_missions.py
tests/integration/test_cdlc.py
tests/integration/test_scheduler_jobs.py
```
---
## Implementation Notes
### conftest.py — shared fixtures
```python
# tests/conftest.py
import asyncio, pytest, jose.jwt as jwt
from datetime import datetime
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from src.main import app
from src.repository.base import Base, get_db
from src.domain.user.models import UserEntity
from src.security.password import hash_password
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
# --- Engine (session-scoped: created once per test run) ---
@pytest.fixture(scope="session")
async def engine():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
# --- Per-test session with rollback isolation ---
@pytest.fixture
async def db_session(engine):
async with engine.begin() as conn:
session_factory = sessionmaker(
bind=conn, class_=AsyncSession, expire_on_commit=False
)
session = session_factory()
try:
yield session
finally:
await session.rollback()
await session.close()
# --- Override FastAPI dependency ---
@pytest.fixture(autouse=True)
def override_db(db_session):
async def _get_test_db():
yield db_session
app.dependency_overrides[get_db] = _get_test_db
yield
app.dependency_overrides.clear()
# --- Seed default user ---
@pytest.fixture
async def seed_user(db_session):
user = UserEntity(
username="testuser",
password=hash_password("changeme"),
enabled=True,
)
db_session.add(user)
await db_session.commit()
return user
# --- JWT with ALL 37 authorities (far-future exp) ---
ALL_AUTHORITIES = [
"STATUS_VIEW", "MODS_VIEW", "MODS_UPDATE", "MODS_UPLOAD", "MODS_DELETE",
"MODS_PRESETS_VIEW", "MODS_PRESETS_UPDATE", "MODS_PRESETS_DELETE",
"MOD_SETTINGS_VIEW", "MOD_SETTINGS_UPDATE", "MOD_SETTINGS_DELETE",
"MISSION_VIEW", "MISSION_UPDATE", "MISSION_ADD", "MISSION_DELETE", "MISSION_UPLOAD",
"CDLC_VIEW", "CDLC_UPDATE",
"WORKSHOP_VIEW", "WORKSHOP_INSTALL",
"STEAM_SETTINGS_UPDATE",
"GENERAL_SETTINGS_VIEW", "GENERAL_SETTINGS_UPDATE",
"NETWORK_SETTINGS_VIEW", "NETWORK_SETTINGS_UPDATE",
"LOGGING_SETTINGS_VIEW", "LOGGING_SETTINGS_UPDATE",
"SECURITY_SETTINGS_VIEW", "SECURITY_SETTINGS_UPDATE",
"DIFFICULTY_VIEW", "DIFFICULTY_UPDATE",
"USERS_VIEW", "USERS_UPDATE", "USERS_DELETE",
"USER_MANAGEMENT_VIEW",
"SERVER_START", "SERVER_STOP",
]
@pytest.fixture
def auth_headers():
payload = {
"sub": "testuser",
"authorities": ALL_AUTHORITIES,
"iat": 1700000000,
"exp": 9999999999,
}
token = jwt.encode(payload, "test-secret", algorithm="HS256")
return {"Authorization": f"Bearer {token}"}
# --- ASGI test client ---
@pytest.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
```
---
### Unit Tests
#### test_cfg_parser.py — CRITICAL (Phase 3 checklist)
Six round-trip tests for `src/domain/server/config/server_config_parser.py`. Each test:
1. Parses a minimal `server.cfg` string
2. Serialises it back to string
3. Asserts the key fields survive the round-trip unchanged
```python
# tests/unit/test_cfg_parser.py
import pytest
from src.domain.server.config.server_config_parser import (
ServerConfigParser, ServerConfig
)
MINIMAL_CFG = """
hostname = "Test Server";
maxPlayers = 20;
password = "";
passwordAdmin = "admin";
"""
def test_hostname_round_trip():
cfg = ServerConfigParser.parse(MINIMAL_CFG)
out = ServerConfigParser.serialize(cfg)
assert 'hostname = "Test Server"' in out
def test_max_players_round_trip():
cfg = ServerConfigParser.parse(MINIMAL_CFG)
assert cfg.max_players == 20
out = ServerConfigParser.serialize(cfg)
assert "maxPlayers = 20" in out
def test_empty_password_preserved():
cfg = ServerConfigParser.parse(MINIMAL_CFG)
assert cfg.password == ""
out = ServerConfigParser.serialize(cfg)
assert 'password = ""' in out
def test_admin_password_round_trip():
cfg = ServerConfigParser.parse(MINIMAL_CFG)
assert cfg.password_admin == "admin"
out = ServerConfigParser.serialize(cfg)
assert 'passwordAdmin = "admin"' in out
MISSIONS_CFG = """
class Missions {
class Mission1 {
template = "tdm_stratis.Stratis";
difficulty = "Regular";
};
};
"""
def test_missions_block_round_trip():
cfg = ServerConfigParser.parse(MISSIONS_CFG)
assert len(cfg.missions) == 1
assert cfg.missions[0].template == "tdm_stratis.Stratis"
out = ServerConfigParser.serialize(cfg)
assert "tdm_stratis.Stratis" in out
def test_empty_cfg_produces_valid_output():
cfg = ServerConfig()
out = ServerConfigParser.serialize(cfg)
assert isinstance(out, str)
assert len(out) >= 0
```
#### test_permissions.py
```python
# tests/unit/test_permissions.py
import pytest
from src.security.permissions import Authority
def test_authority_enum_has_all_37_values():
assert len(Authority) == 37
def test_all_authorities_are_strings():
for a in Authority:
assert isinstance(a.value, str)
async def test_missing_authority_returns_403(client):
# Login endpoint: no auth required
resp = await client.get("/api/v1/users", headers={})
assert resp.status_code == 401
```
#### test_password.py
```python
# tests/unit/test_password.py
from src.security.password import hash_password, verify_password
def test_hash_is_not_plaintext():
h = hash_password("secret")
assert h != "secret"
def test_verify_correct_password():
h = hash_password("secret")
assert verify_password("secret", h) is True
def test_verify_wrong_password():
h = hash_password("secret")
assert verify_password("wrong", h) is False
```
#### test_mod_file_storage.py
```python
# tests/unit/test_mod_file_storage.py
import pytest
from pathlib import Path
from src.domain.server.mod.mod_file_storage import scan_mod_directories
def test_scans_at_prefixed_dirs(tmp_path):
mods_dir = tmp_path / "mods"
(mods_dir / "@ace").mkdir(parents=True)
(mods_dir / "@cba").mkdir(parents=True)
(mods_dir / "not_a_mod").mkdir(parents=True) # no @ prefix — excluded
result = scan_mod_directories(tmp_path)
names = [r.mod_directory.directory_name for r in result]
assert "@ace" in names
assert "@cba" in names
assert "not_a_mod" not in names
def test_reads_meta_cpp(tmp_path):
mod_dir = tmp_path / "mods" / "@ace"
mod_dir.mkdir(parents=True)
(mod_dir / "meta.cpp").write_text('publishedid = 463939057;\nname = "ACE3";')
result = scan_mod_directories(tmp_path)
assert result[0].workshop_file_id == 463939057
assert result[0].name == "ACE3"
def test_missing_meta_cpp_uses_defaults(tmp_path):
mod_dir = tmp_path / "mods" / "@mymod"
mod_dir.mkdir(parents=True)
result = scan_mod_directories(tmp_path)
assert result[0].workshop_file_id == 0
```
---
### Integration Tests
#### test_auth.py
```python
# tests/integration/test_auth.py
import pytest
async def test_login_returns_jwt(client, seed_user):
resp = await client.post("/api/v1/auth/token",
data={"username": "testuser", "password": "changeme"})
assert resp.status_code == 200
assert "token" in resp.json()
async def test_login_wrong_password_returns_401(client, seed_user):
resp = await client.post("/api/v1/auth/token",
data={"username": "testuser", "password": "wrong"})
assert resp.status_code == 401
async def test_protected_endpoint_without_token_returns_401(client):
resp = await client.get("/api/v1/users")
assert resp.status_code == 401
```
#### test_users.py
```python
# tests/integration/test_users.py
import pytest
async def test_get_users(client, auth_headers, seed_user):
resp = await client.get("/api/v1/users", headers=auth_headers)
assert resp.status_code == 200
users = resp.json()
assert isinstance(users, list)
assert any(u["username"] == "testuser" for u in users)
async def test_create_user(client, auth_headers):
resp = await client.post("/api/v1/users",
headers=auth_headers,
json={"username": "newuser", "password": "pw123",
"enabled": True, "authorities": []})
assert resp.status_code in (200, 201)
async def test_delete_user(client, auth_headers, seed_user):
resp = await client.delete(f"/api/v1/users/{seed_user.id}",
headers=auth_headers)
assert resp.status_code == 200
```
#### test_status.py
```python
# tests/integration/test_status.py
import pytest
from unittest.mock import patch, AsyncMock
async def test_get_status_returns_json(client, auth_headers):
with patch("src.domain.server.process.process_service.ProcessService.get_status",
new_callable=AsyncMock, return_value={"status": "OFFLINE"}):
resp = await client.get("/api/v1/status", headers=auth_headers)
assert resp.status_code == 200
assert "status" in resp.json()
async def test_actuator_health_no_auth(client):
resp = await client.get("/api/v1/actuator/health")
assert resp.status_code == 200
assert resp.json()["status"] == "UP"
```
#### test_general_settings.py
```python
# tests/integration/test_general_settings.py
import pytest
from unittest.mock import patch, MagicMock
FAKE_CONFIG = {
"serverName": "Test",
"serverDirectory": "/tmp/arma",
"steamCmdPath": "",
}
async def test_get_general_settings(client, auth_headers):
with patch("src.domain.server.config.server_config_storage.ServerConfigStorage.read",
return_value=MagicMock(**FAKE_CONFIG)):
resp = await client.get("/api/v1/general", headers=auth_headers)
assert resp.status_code == 200
async def test_update_general_settings(client, auth_headers):
with patch("src.domain.server.config.server_config_storage.ServerConfigStorage.write"):
resp = await client.post("/api/v1/general",
headers=auth_headers,
json=FAKE_CONFIG)
assert resp.status_code == 200
```
#### test_mods.py
```python
# tests/integration/test_mods.py
import pytest
from unittest.mock import patch, AsyncMock
from src.domain.server.mod.models import ModsCollection
async def test_get_mods_returns_collection(client, auth_headers):
empty = ModsCollection(disabled_mods=[], enabled_mods=[], not_managed_mods=[])
with patch("src.domain.server.mod.mod_service.ModService.get_mods_collection",
new_callable=AsyncMock, return_value=empty):
resp = await client.get("/api/v1/mods", headers=auth_headers)
assert resp.status_code == 200
body = resp.json()
assert "disabledMods" in body
assert "enabledMods" in body
assert "notManagedMods" in body
async def test_post_enabled_mods(client, auth_headers):
with patch("src.domain.server.mod.mod_service.ModService.save_enabled_mod_list",
new_callable=AsyncMock):
resp = await client.post("/api/v1/mods/enabled",
headers=auth_headers,
json={"mods": []})
assert resp.status_code == 200
```
#### test_workshop.py — uses respx for Steam API
```python
# tests/integration/test_workshop.py
import pytest, respx
from httpx import Response
STEAM_QUERY_URL = "https://api.steampowered.com/IPublishedFileService/QueryFiles/v1/"
async def test_workshop_active_no_steamcmd(client, auth_headers):
resp = await client.get("/api/v1/workshop/active", headers=auth_headers)
assert resp.status_code == 200
assert "active" in resp.json()
@respx.mock
async def test_workshop_query_returns_mods(client, auth_headers, settings_override):
"""settings_override is a fixture that sets steam_web_api_token to 'test-key'."""
respx.post(STEAM_QUERY_URL).mock(return_value=Response(200, json={
"response": {
"next_cursor": "*",
"publishedfiledetails": [
{
"publishedfileid": "123456",
"title": "Test Mod",
"preview_url": "https://example.com/img.jpg",
"children": [],
}
]
}
}))
resp = await client.post("/api/v1/workshop/query",
headers=auth_headers,
json={"cursor": "*", "searchText": ""})
assert resp.status_code == 200
body = resp.json()
assert "mods" in body
```
Note: add a `settings_override` conftest fixture that temporarily sets `settings.steam_web_api_token = "test-key"`.
#### test_missions.py
```python
# tests/integration/test_missions.py
import pytest
from unittest.mock import patch, AsyncMock
from src.domain.server.mission.models import Missions
async def test_get_missions(client, auth_headers):
empty = Missions(disabled_missions=[], enabled_missions=[])
with patch("src.domain.server.mission.mission_service.MissionService.get_missions",
new_callable=AsyncMock, return_value=empty):
resp = await client.get("/api/v1/missions", headers=auth_headers)
assert resp.status_code == 200
body = resp.json()
assert "disabledMissions" in body
assert "enabledMissions" in body
async def test_add_mission_template(client, auth_headers):
with patch("src.domain.server.mission.mission_service.MissionService.add_mission",
new_callable=AsyncMock):
resp = await client.post("/api/v1/missions/template",
headers=auth_headers,
json={"name": "TDM Stratis",
"template": "tdm_stratis.Stratis"})
assert resp.status_code == 200
```
#### test_cdlc.py
```python
# tests/integration/test_cdlc.py
import pytest
from unittest.mock import patch, AsyncMock
from src.domain.server.cdlc.models import Cdlc
async def test_get_cdlcs(client, auth_headers):
fake = [Cdlc(id=1, name="gm", enabled=False, file_exists=False)]
with patch("src.domain.server.cdlc.cdlc_service.CdlcService.find_all",
new_callable=AsyncMock, return_value=fake):
resp = await client.get("/api/v1/cdlc", headers=auth_headers)
assert resp.status_code == 200
body = resp.json()
assert "cdlcs" in body
assert body["cdlcs"][0]["name"] == "gm"
async def test_toggle_cdlc(client, auth_headers):
with patch("src.domain.server.cdlc.cdlc_service.CdlcService.toggle_cdlc",
new_callable=AsyncMock):
resp = await client.post("/api/v1/cdlc/1/toggle", headers=auth_headers)
assert resp.status_code == 200
```
#### test_scheduler_jobs.py
```python
# tests/integration/test_scheduler_jobs.py
import pytest
from unittest.mock import AsyncMock, patch
async def test_jwt_cleaner_job_runs_without_error():
"""Directly calls the job coroutine — does not require the scheduler to be running."""
from src.application.scheduler.scheduler import jwt_cleaner_job
with patch("src.repository.user.jwt_token_repository.JwtTokenRepository.delete_expired",
new_callable=AsyncMock):
await jwt_cleaner_job() # must not raise
async def test_mission_scanner_job_runs_without_error(tmp_path):
from src.domain.server.mission.jobs import mission_scanner_job
with patch("src.domain.server.mission.jobs.settings") as s:
s.server_directory_path = str(tmp_path)
with patch("src.domain.server.mission.jobs.mission_repo") as repo:
repo.find_all = AsyncMock(return_value=[])
repo.save = AsyncMock()
await mission_scanner_job()
```
---
### Error Response Shape Test
```python
# tests/integration/test_error_handler.py
import pytest
async def test_unknown_api_path_returns_server_error(client, auth_headers):
resp = await client.get("/api/v1/nonexistent", headers=auth_headers)
assert resp.status_code == 404
async def test_server_error_returns_code_field(client, auth_headers):
"""Force a 500 by patching a service to raise."""
from src.domain.server.mod.mod_service import ModService
with patch.object(ModService, "get_mods_collection",
side_effect=RuntimeError("boom")):
resp = await client.get("/api/v1/mods", headers=auth_headers)
assert resp.status_code == 500
body = resp.json()
assert body["code"] == "SERVER_ERROR"
assert "message" in body
```
---
### SPA Redirect Test
```python
# tests/integration/test_spa_redirect.py
import pytest
async def test_non_api_path_returns_index_html(client):
"""SPA middleware returns index.html for paths without file extension."""
import os, tempfile
# Create a minimal static/index.html in the working dir for this test
os.makedirs("static", exist_ok=True)
with open("static/index.html", "w") as f:
f.write("<html><body>ASWG</body></html>")
resp = await client.get("/mods")
# Either 200 with HTML content or redirect — either way, NOT a 404 on /api
assert resp.status_code == 200
async def test_static_asset_not_redirected(client):
"""Paths with file extensions (dots) must NOT be redirected to index.html."""
resp = await client.get("/main.js")
# StaticFiles will return 404 if file absent — that's correct behaviour
assert resp.status_code != 200 or "ASWG" not in resp.text
```
---
## Completion Checklist
- [ ] `pytest` runs without import errors on a clean checkout (after `pip install -r requirements.txt`)
- [ ] `tests/unit/test_cfg_parser.py` — all 6 round-trip tests pass
- [ ] `tests/unit/test_permissions.py` — 37-value enum check passes
- [ ] `tests/integration/test_auth.py` — login / 401 / token tests pass
- [ ] `tests/integration/test_mods.py` — collection shape and enabled POST pass
- [ ] `tests/integration/test_workshop.py``respx` mock returns paginated mods
- [ ] `tests/integration/test_error_handler.py` — 500 returns `{"code":"SERVER_ERROR",...}`
- [ ] Coverage: `pytest --cov=src --cov-fail-under=80` exits 0
- [ ] No test imports production `.env` or writes to the real database
- [ ] `asyncio_mode = "auto"` in `pytest.ini` (no manual `@pytest.mark.asyncio` needed)
---
## Coverage Exclusions
Add to `.coveragerc` or `pyproject.toml` to avoid penalising untestable glue:
```ini
[coverage:run]
omit =
src/main.py # ASGI wiring — tested implicitly by client fixture
src/alembic/*
tests/*
```
---
## Contract Satisfied
All 17 routers, 3 middleware layers, global exception handler, WebSocket endpoint, and two actuator endpoints are exercised through the `AsyncClient(transport=ASGITransport(app=app))` fixture. The test suite constitutes a full regression suite equivalent to the Java integration tests.