implement ota updates
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
This commit is contained in:
25 files changed
+2353
-4
No files matched your search
@@ -0,0 +1,202 @@
|
||||
"""The OTA feed: which artifact wins, and what the phone receives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from app.config import env_settings
|
||||
from app.services import android_updates
|
||||
|
||||
|
||||
def _configure(monkeypatch, *, token: str = "read-token") -> None:
|
||||
monkeypatch.setenv("GITEA_URL", "https://git.example.test")
|
||||
monkeypatch.setenv("GITEA_REPO", "timka123/kadence")
|
||||
monkeypatch.setenv("GITEA_UPDATE_BRANCH", "master")
|
||||
monkeypatch.setenv("GITEA_TOKEN", token)
|
||||
env_settings.cache_clear()
|
||||
android_updates.reset_cache()
|
||||
|
||||
|
||||
def _artifact(
|
||||
artifact_id: int,
|
||||
*,
|
||||
run_id: int,
|
||||
run_number: int,
|
||||
name: str = android_updates.METADATA_ARTIFACT,
|
||||
branch: str = "master",
|
||||
conclusion: str = "success",
|
||||
expired: bool = False,
|
||||
created_at: str = "2026-10-20T10:00:00Z",
|
||||
size: int = 100,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": artifact_id,
|
||||
"name": name,
|
||||
"created_at": created_at,
|
||||
"size_in_bytes": size,
|
||||
"expired": expired,
|
||||
"workflow_run": {
|
||||
"id": run_id,
|
||||
"run_number": run_number,
|
||||
"head_branch": branch,
|
||||
"head_sha": f"sha-{run_number}",
|
||||
"conclusion": conclusion,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _zip(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
for name, body in files.items():
|
||||
archive.writestr(name, body)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _install_transport(monkeypatch, handler) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def client(_settings, *, timeout: float = 30.0) -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url="https://git.example.test",
|
||||
transport=transport,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(android_updates, "_client", client)
|
||||
|
||||
|
||||
def _metadata_body(version_code: int, version_name: str) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"version_code": version_code,
|
||||
"version_name": version_name,
|
||||
"commit": f"sha-{version_code}",
|
||||
"built_at": "2026-10-20T10:00:00Z",
|
||||
"size_bytes": 1234,
|
||||
"sha256": "abc",
|
||||
"signer_sha256": "def",
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_newest_successful_master_build_wins(monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
metadata_artifacts = [
|
||||
_artifact(11, run_id=101, run_number=10),
|
||||
_artifact(12, run_id=102, run_number=20),
|
||||
# A newer tag build must not become the master channel.
|
||||
_artifact(13, run_id=103, run_number=30, branch="v0.2.0"),
|
||||
# A failed build uploaded an artifact but must never be offered.
|
||||
_artifact(14, run_id=104, run_number=40, conclusion="failure"),
|
||||
# An expired artifact is not downloadable any more.
|
||||
_artifact(15, run_id=105, run_number=50, expired=True),
|
||||
]
|
||||
apk_artifacts = [
|
||||
_artifact(
|
||||
artifact_id + 100,
|
||||
run_id=run_id,
|
||||
run_number=run_number,
|
||||
name=android_updates.APK_ARTIFACT,
|
||||
)
|
||||
for artifact_id, run_id, run_number in (
|
||||
(11, 101, 10),
|
||||
(12, 102, 20),
|
||||
(13, 103, 30),
|
||||
(14, 104, 40),
|
||||
(15, 105, 50),
|
||||
)
|
||||
]
|
||||
zips = {
|
||||
12: _zip({"update.json": _metadata_body(20, "0.1.0-master.20")}),
|
||||
112: _zip({"app-release.apk": b"new apk"}),
|
||||
}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/actions/artifacts"):
|
||||
name = request.url.params.get("name")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"total_count": 5,
|
||||
"artifacts": (
|
||||
metadata_artifacts
|
||||
if name == android_updates.METADATA_ARTIFACT
|
||||
else apk_artifacts
|
||||
),
|
||||
},
|
||||
)
|
||||
artifact_id = int(request.url.path.rstrip("/").split("/")[-2])
|
||||
return httpx.Response(200, content=zips[artifact_id])
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
|
||||
metadata = android_updates.latest_android()
|
||||
assert metadata["version_code"] == 20
|
||||
assert metadata["version_name"] == "0.1.0-master.20"
|
||||
assert metadata["commit"] == "sha-20"
|
||||
assert metadata["size_bytes"] == 1234
|
||||
assert metadata["signer_sha256"] == "def"
|
||||
|
||||
path, same = android_updates.android_apk()
|
||||
assert path.read_bytes() == b"new apk"
|
||||
assert same == metadata
|
||||
|
||||
|
||||
def test_falls_back_to_an_apk_artifact_without_metadata(monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
apk = _artifact(
|
||||
201,
|
||||
run_id=301,
|
||||
run_number=77,
|
||||
name=android_updates.APK_ARTIFACT,
|
||||
created_at="2026-10-21T09:30:00Z",
|
||||
size=900,
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/actions/artifacts"):
|
||||
name = request.url.params.get("name")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"total_count": 0 if name == android_updates.METADATA_ARTIFACT else 1,
|
||||
"artifacts": [] if name == android_updates.METADATA_ARTIFACT else [apk]},
|
||||
)
|
||||
raise AssertionError("the metadata endpoint should not download anything")
|
||||
|
||||
_install_transport(monkeypatch, handler)
|
||||
|
||||
metadata = android_updates.latest_android()
|
||||
assert metadata["version_code"] == 77
|
||||
assert metadata["version_name"] == "0.1.0-master.77"
|
||||
assert metadata["commit"] == "sha-77"
|
||||
assert metadata["built_at"] == "2026-10-21T09:30:00Z"
|
||||
assert metadata["size_bytes"] == 900
|
||||
|
||||
|
||||
def test_missing_token_is_not_configured(monkeypatch):
|
||||
_configure(monkeypatch, token="")
|
||||
with pytest.raises(android_updates.UpdateNotConfigured):
|
||||
android_updates.latest_android()
|
||||
|
||||
|
||||
def test_gitea_failure_is_reported_as_unavailable(monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
_install_transport(monkeypatch, lambda _request: httpx.Response(500, text="boom"))
|
||||
|
||||
with pytest.raises(android_updates.UpdateUnavailable):
|
||||
android_updates.latest_android()
|
||||
|
||||
|
||||
def test_the_route_reports_503_when_updates_are_not_configured(client, monkeypatch):
|
||||
_configure(monkeypatch, token="")
|
||||
|
||||
response = client.get("/api/updates/android")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "GITEA_TOKEN" in response.json()["detail"]
|
||||
Reference in new issue
Block a user