hermes-kanban-miniapp/backend/tests/test_api.py

195 lines
7 KiB
Python
Raw Normal View History

2026-07-09 17:19:33 +03:00
import hashlib
import hmac
import json
import time
from urllib.parse import quote, urlencode
import pytest
from fastapi.testclient import TestClient
from app.auth import validate_telegram_init_data
from app.config import Settings
from app.main import create_app
def signed_init_data(bot_token: str, user_id: int = 123) -> str:
pairs = {
"query_id": "AAEAAAE",
"user": json.dumps({"id": user_id, "first_name": "Test"}, separators=(",", ":")),
"auth_date": str(int(time.time())),
}
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
pairs["hash"] = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
return urlencode(pairs, quote_via=quote)
class FakeAdapter:
def __init__(self):
self.calls = []
self.tasks = [
{"id": "t_1", "title": "Ready task", "status": "ready", "assignee": "dev", "priority": 7},
{"id": "t_2", "title": "Blocked task", "status": "blocked", "assignee": "worker", "priority": 1},
]
def boards(self):
return [{"slug": "default", "current": True}]
def list_tasks(self, board=None):
self.calls.append(("list", board))
return self.tasks
def show_task(self, task_id, board=None):
return {"id": task_id, "title": "Ready task", "body": "Full body", "status": "ready", "comments": [], "events": [], "runs": []}
def task_events(self, task_id, board=None):
return [{"kind": "created"}]
def task_runs(self, task_id, board=None):
return [{"id": 1, "status": "completed"}]
def task_log(self, task_id, board=None, lines=200):
return {"task_id": task_id, "log": "", "missing": True}
def create_task(self, payload, board=None):
self.calls.append(("create", payload.title))
return {"id": "t_new", "title": payload.title, "status": "todo"}
def comment(self, task_id, body, board=None):
self.calls.append(("comment", task_id, body))
return {"ok": True}
def assign(self, task_id, assignee, board=None):
self.calls.append(("assign", task_id, assignee))
return {"ok": True}
def promote(self, task_id, board=None):
self.calls.append(("promote", task_id))
return {"ok": True}
def block(self, task_id, reason, board=None):
self.calls.append(("block", task_id, reason))
return {"ok": True}
def unblock(self, task_id, board=None):
self.calls.append(("unblock", task_id))
return {"ok": True}
def archive(self, task_id, board=None):
self.calls.append(("archive", task_id))
return {"ok": True}
def dispatch(self, board=None):
self.calls.append(("dispatch", board))
return {"ok": True}
@pytest.fixture
def fake_adapter():
return FakeAdapter()
@pytest.fixture
def client(fake_adapter):
app = create_app(Settings(auth_mode="none", allowed_origins=["http://localhost:5173"]), fake_adapter)
return TestClient(app)
def test_health_returns_ok(client):
assert client.get("/api/health").json()["status"] == "ok"
def test_telegram_init_data_accepts_correct_signature():
data = signed_init_data("token123", 123)
user = validate_telegram_init_data(data, "token123", max_age_seconds=3600)
assert user["id"] == 123
def test_telegram_init_data_rejects_wrong_hash():
data = signed_init_data("token123", 123).replace("hash=", "hash=bad")
with pytest.raises(ValueError):
validate_telegram_init_data(data, "token123", max_age_seconds=3600)
def test_allowed_user_filter_rejects_unauthorized(fake_adapter):
settings = Settings(auth_mode="telegram", telegram_bot_token="token123", telegram_allowed_user_ids=[999])
app = create_app(settings, fake_adapter)
response = TestClient(app).get("/api/tasks", headers={"X-Telegram-Init-Data": signed_init_data("token123", 123)})
assert response.status_code == 403
def test_env_list_settings_accept_json_strings():
settings = Settings(
telegram_allowed_user_ids='[123, "456"]',
allowed_origins='["https://miniapp.example", "http://localhost:5173"]',
)
assert settings.telegram_allowed_user_ids == [123, 456]
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
def test_env_list_settings_accept_comma_separated_strings():
settings = Settings(
telegram_allowed_user_ids="123,456",
allowed_origins="https://miniapp.example,http://localhost:5173",
)
assert settings.telegram_allowed_user_ids == [123, 456]
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
def test_static_frontend_can_be_served_from_backend(tmp_path, fake_adapter):
(tmp_path / "index.html").write_text("<h1>Kanban Mini App</h1>", encoding="utf-8")
settings = Settings(auth_mode="none", frontend_dist_dir=str(tmp_path))
app = create_app(settings, fake_adapter)
response = TestClient(app).get("/")
assert response.status_code == 200
assert "Kanban Mini App" in response.text
2026-07-09 17:19:33 +03:00
def test_task_list_returns_normalized_tasks_grouped_by_status(client):
data = client.get("/api/tasks").json()
assert data["groups"]["ready"][0]["id"] == "t_1"
assert data["groups"]["blocked"][0]["status"] == "blocked"
def test_create_task_validates_required_title(client):
response = client.post("/api/tasks", json={"title": " "})
assert response.status_code == 422
def test_create_task_accepts_safe_payload(client):
response = client.post("/api/tasks", json={"title": "New task", "body": "Body", "assignee": "developer", "priority": 3})
assert response.status_code == 200
assert response.json()["task"]["id"] == "t_new"
def test_action_endpoints_call_fixed_adapter_methods(client, fake_adapter):
assert client.post("/api/tasks/t_1/comment", json={"body": "hello"}).status_code == 200
assert client.post("/api/tasks/t_1/assign", json={"assignee": "worker"}).status_code == 200
assert client.post("/api/tasks/t_1/promote").status_code == 200
assert client.post("/api/tasks/t_1/block", json={"reason": "need input"}).status_code == 200
assert client.post("/api/tasks/t_1/unblock").status_code == 200
assert client.post("/api/tasks/t_1/archive").status_code == 200
assert ("comment", "t_1", "hello") in fake_adapter.calls
assert ("assign", "t_1", "worker") in fake_adapter.calls
assert ("promote", "t_1") in fake_adapter.calls
def test_log_endpoint_handles_missing_log_gracefully(client):
response = client.get("/api/tasks/t_1/log")
assert response.status_code == 200
assert response.json()["missing"] is True
def test_adapter_failure_returns_structured_error_without_secret(client, fake_adapter):
def broken(*args, **kwargs):
raise RuntimeError("failed with secret token value")
fake_adapter.list_tasks = broken
response = client.get("/api/tasks")
assert response.status_code == 502
body = response.json()["detail"]
assert body["error"] == "adapter_error"
assert "secret" not in body["message"].lower()