Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker
- Détection de version via l'API Gitea, application via Watchtower - Router système, UI admin (intégrations), bandeau de mise à jour - Dockerfile multi-étapes, docker-compose, scripts/release.sh - Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
This commit is contained in:
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
|
||||
await db.execute(f"DELETE FROM {table}")
|
||||
yield
|
||||
await db.close()
|
||||
|
||||
# ---------------------------------------------------------------- client HTTP partagé
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
"""Premier compte créé → administrateur, cookies de session."""
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
+83
-18
@@ -1,25 +1,8 @@
|
||||
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
from app.scrapers.base import ScrapeError, SearchResult
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
@@ -121,3 +104,85 @@ async def test_source_toggle(client, admin_cookies):
|
||||
states = {s["name"]: s["enabled"] for s in r.json()}
|
||||
assert states["vostfree"] is False
|
||||
assert states["french_manga"] is True
|
||||
|
||||
|
||||
async def test_source_health(client, admin_cookies, monkeypatch):
|
||||
# source inconnue → 404 (pas de 500)
|
||||
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# état initial : aucune santé connue
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
assert all(s["health"] is None for s in r.json())
|
||||
|
||||
async def ok_search(self, query: str) -> list[SearchResult]:
|
||||
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
|
||||
|
||||
# résultat persisté et exposé par GET /sources
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
health = {s["name"]: s["health"] for s in r.json()}
|
||||
assert health["vostfree"]["healthy"] is True
|
||||
assert health["french_manga"] is None
|
||||
|
||||
async def failing_search(self, query: str) -> list[SearchResult]:
|
||||
raise ScrapeError("site indisponible")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
|
||||
|
||||
async def broken_search(self, query: str) -> list[SearchResult]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
|
||||
|
||||
|
||||
async def test_source_toggle_unknown(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_source_url_override(client, admin_cookies):
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
default = src["default_base_url"]
|
||||
assert src["base_url"] == default and src["overridden"] is False
|
||||
|
||||
# source inconnue → 404
|
||||
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# URL invalide → 422
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
|
||||
assert r.status_code == 422
|
||||
|
||||
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
|
||||
cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
|
||||
assert get_source("vostfree").base_url == "https://exemple.org"
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
|
||||
|
||||
# URL vide → retour au défaut du code
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["base_url"] == default and data["overridden"] is False
|
||||
assert get_source("vostfree").base_url == default
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Mises à jour : semver, détection Gitea (API tags), déclenchement Watchtower, endpoints."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import update as update_service
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_update_cache():
|
||||
update_service.invalidate_cache()
|
||||
yield
|
||||
update_service.invalidate_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- semver / version
|
||||
|
||||
|
||||
def test_parse_tag():
|
||||
assert update_service.parse_tag("v0.2.1") == (0, 2, 1)
|
||||
assert update_service.parse_tag("0.10.3") == (0, 10, 3)
|
||||
assert update_service.parse_tag("v0.2") is None
|
||||
assert update_service.parse_tag("v0.2.1-beta") is None
|
||||
assert update_service.parse_tag("nimporte") is None
|
||||
|
||||
|
||||
def test_is_newer():
|
||||
assert update_service.is_newer("v0.2.0", "0.1.9")
|
||||
assert update_service.is_newer("v1.0.0", "v0.99.99")
|
||||
assert not update_service.is_newer("v0.1.0", "0.1.0")
|
||||
assert not update_service.is_newer("v0.1.0", "dev") # courant non semver → jamais forcé
|
||||
|
||||
|
||||
def test_get_version_env_override(monkeypatch):
|
||||
monkeypatch.setenv("OHM_VERSION", "9.9.9")
|
||||
assert get_version() == "9.9.9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- détection Gitea
|
||||
|
||||
|
||||
async def test_fetch_latest_without_repo():
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "", "tok")
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
async def test_fetch_latest_public_repo_no_token(monkeypatch):
|
||||
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "")
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.3.0"}]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) == "v0.3.0"
|
||||
assert calls[0][1] == {} # aucun en-tête d'auth
|
||||
|
||||
|
||||
async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
latest = await update_service.fetch_latest_version(force=True)
|
||||
|
||||
assert latest == "v0.2.3"
|
||||
assert calls == [
|
||||
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
|
||||
]
|
||||
# puis servi par le cache (plus d'appel réseau)
|
||||
assert await update_service.fetch_latest_version() == "v0.2.3"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_fetch_latest_network_error_degrades(monkeypatch):
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||
raise httpx.ConnectError("injoignable")
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- déclenchement
|
||||
|
||||
|
||||
async def test_trigger_update_requires_watchtower():
|
||||
with pytest.raises(update_service.UpdateError, match="Watchtower"):
|
||||
await update_service.trigger_update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
async def test_version_endpoint_public(client):
|
||||
r = await client.get("/api/version")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["version"] == get_version() and data["name"]
|
||||
|
||||
|
||||
async def test_update_requires_admin(client):
|
||||
r = await client.get("/api/admin/update", follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/login"
|
||||
|
||||
|
||||
async def test_update_status_defaults(client, admin_cookies, monkeypatch):
|
||||
# Aucun appel réseau : la détection renvoie None (repo sans tag)
|
||||
async def fake_fetch(*, force: bool = False):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(update_service, "fetch_latest_version", fake_fetch)
|
||||
|
||||
r = await client.get("/api/admin/update", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["configured"] is True and data["docker"] is False # repo par défaut présent
|
||||
assert data["latest"] is None and data["update_available"] is False
|
||||
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
|
||||
assert data["config"]["repo"] == "Roman/ohm_streaming"
|
||||
|
||||
|
||||
async def test_update_save_config_normalizes(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert await get_update_config() == {
|
||||
"gitea_url": "https://git.example",
|
||||
"repo": "roman/ohm",
|
||||
"token": "tok",
|
||||
}
|
||||
|
||||
|
||||
async def test_update_check_degrades_gracefully(client, admin_cookies):
|
||||
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
|
||||
await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.json()["latest"] is None
|
||||
|
||||
|
||||
async def test_update_apply_without_docker_is_502(client, admin_cookies):
|
||||
r = await client.post("/api/admin/update/apply", cookies=admin_cookies)
|
||||
assert r.status_code == 502
|
||||
Reference in New Issue
Block a user