Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import hmac
import os
import unittest
from hashlib import sha256
from urllib.parse import parse_qs, urlparse
from unittest.mock import AsyncMock, patch
@ -55,7 +57,7 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
response_payload = response.json()
@ -70,14 +72,14 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
def test_prototype_accepts_authorization_token(self) -> None:
def test_prototype_reuses_cached_public_payload(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": True,
"login": "kacper",
"source": "authorization",
"can_reply": True,
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
@ -87,15 +89,98 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
with patch("prototype_cache.build_live_prototype_payload", new=builder):
first_response = self.client.get("/api/prototype")
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(builder.await_count, 1)
def test_forgejo_webhook_invalidates_prototype_cache(self) -> None:
initial_payload = {
"hero": {"title": "Before"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
refreshed_payload = {
**initial_payload,
"hero": {"title": "After"},
}
builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
with patch("prototype_cache.build_live_prototype_payload", new=builder):
first_response = self.client.get("/api/prototype")
webhook_response = self.client.post("/api/forgejo/webhook", json={"ref": "main"})
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.json()["hero"]["title"], "Before")
self.assertEqual(webhook_response.status_code, 200)
self.assertEqual(second_response.json()["hero"]["title"], "After")
self.assertEqual(builder.await_count, 2)
def test_forgejo_webhook_validates_signature_when_secret_is_configured(self) -> None:
get_settings.cache_clear()
body = b'{"ref":"main"}'
signature = hmac.new(b"webhook-secret", body, sha256).hexdigest()
with patch.dict(os.environ, {"FORGEJO_WEBHOOK_SECRET": "webhook-secret"}):
bad_response = self.client.post(
"/api/forgejo/webhook",
content=body,
headers={"X-Forgejo-Signature": "bad-signature"},
)
good_response = self.client.post(
"/api/forgejo/webhook",
content=body,
headers={"X-Forgejo-Signature": signature},
)
self.assertEqual(bad_response.status_code, 401)
self.assertEqual(good_response.status_code, 200)
def test_prototype_accepts_authorization_token(self) -> None:
payload = {
"hero": {"title": "Robot U"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
fake_client = _FakeForgejoClient(user={"login": "kacper"})
with (
patch("prototype_cache.build_live_prototype_payload", new=builder),
patch("app.ForgejoClient", return_value=fake_client),
):
response = self.client.get(
"/api/prototype",
headers={"Authorization": "token test-token"},
)
response_payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
self.assertEqual(response_payload["auth"]["authenticated"], True)
self.assertEqual(response_payload["auth"]["login"], "kacper")
self.assertEqual(response_payload["auth"]["source"], "authorization")
def test_prototype_can_use_server_token_without_user_session(self) -> None:
payload = {
@ -117,7 +202,7 @@ class AppTestCase(unittest.TestCase):
get_settings.cache_clear()
with (
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
patch("app.build_live_prototype_payload", new=builder),
patch("prototype_cache.build_live_prototype_payload", new=builder),
):
response = self.client.get("/api/prototype")
@ -213,13 +298,17 @@ class AppTestCase(unittest.TestCase):
"source_of_truth": [],
}
builder = AsyncMock(return_value=payload)
with patch("app.build_live_prototype_payload", new=builder):
with patch("prototype_cache.build_live_prototype_payload", new=builder):
response = self.client.get("/api/prototype")
response_payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
self.assertIsNone(builder.call_args.kwargs["session_user"])
self.assertEqual(response_payload["auth"]["authenticated"], True)
self.assertEqual(response_payload["auth"]["login"], "kacper")
self.assertEqual(response_payload["auth"]["source"], "session")
def test_encrypted_session_cookie_survives_new_app_instance(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
@ -278,6 +367,187 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(payload["author"], "Kacper")
self.assertEqual(payload["body"], "Thanks, this helped.")
def test_discussion_detail_fetches_public_issue(self) -> None:
fake_client = _FakeForgejoClient(
issue={
"id": 456,
"number": 9,
"title": "Encoder math question",
"body": "Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"comments": 1,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
comments=[
{
"id": 777,
"body": "Reply body",
"created_at": "2026-04-11T12:30:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9#issuecomment-777",
"user": {"login": "Ada", "avatar_url": ""},
}
],
)
with patch("app.ForgejoClient", return_value=fake_client):
response = self.client.get("/api/discussions/Robot-U/robot-u-site/9")
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(payload["title"], "Encoder math question")
self.assertEqual(payload["comments"][0]["body"], "Reply body")
self.assertEqual(payload["links"][0]["kind"], "post")
def test_create_discussion_reply_invalidates_prototype_cache(self) -> None:
initial_payload = {
"hero": {"title": "Before"},
"auth": {
"authenticated": False,
"login": None,
"source": "none",
"can_reply": False,
"oauth_configured": True,
},
"featured_courses": [],
"recent_posts": [],
"recent_discussions": [],
"upcoming_events": [],
"source_of_truth": [],
}
refreshed_payload = {
**initial_payload,
"hero": {"title": "After"},
}
builder = AsyncMock(side_effect=[initial_payload, refreshed_payload])
fake_client = _FakeForgejoClient(
comment={
"id": 123,
"body": "Thanks, this helped.",
"created_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
"user": {"login": "Kacper", "avatar_url": ""},
},
)
with (
patch("prototype_cache.build_live_prototype_payload", new=builder),
patch("app.ForgejoClient", return_value=fake_client),
):
first_response = self.client.get("/api/prototype")
reply_response = self.client.post(
"/api/discussions/replies",
json={
"owner": "Robot-U",
"repo": "RobotClass",
"number": 2,
"body": "Thanks, this helped.",
},
headers={"Authorization": "token test-token"},
)
second_response = self.client.get("/api/prototype")
self.assertEqual(first_response.json()["hero"]["title"], "Before")
self.assertEqual(reply_response.status_code, 200)
self.assertEqual(second_response.json()["hero"]["title"], "After")
self.assertEqual(builder.await_count, 2)
def test_create_linked_discussion(self) -> None:
fake_client = _FakeForgejoClient(
issue={
"id": 456,
"number": 9,
"title": "Encoder math question",
"body": "How should I debounce this?\n\n---\nCanonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"comments": 0,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/robot-u-site/issues/9",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
)
with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
response = self.client.post(
"/api/discussions",
json={
"owner": "Robot-U",
"repo": "robot-u-site",
"title": "Encoder math question",
"body": "How should I debounce this?",
"context_url": "http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
"context_path": "blogs/building-robot-u-site/index.md",
"context_title": "Building Robot U",
},
headers={"Authorization": "token test-token"},
)
payload = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token")
self.assertIsNotNone(fake_client.created_issue)
assert fake_client.created_issue is not None
self.assertEqual(
fake_client.created_issue[:3], ("Robot-U", "robot-u-site", "Encoder math question")
)
self.assertIn(
"Canonical URL: http://testserver/posts/Robot-U/robot-u-site/building-robot-u-site",
fake_client.created_issue[3],
)
self.assertEqual(payload["id"], 456)
self.assertEqual(payload["repo"], "Robot-U/robot-u-site")
self.assertEqual(payload["links"][0]["kind"], "post")
def test_create_general_discussion_uses_configured_repo(self) -> None:
get_settings.cache_clear()
fake_client = _FakeForgejoClient(
issue={
"id": 457,
"number": 10,
"title": "General project help",
"body": "I need help choosing motors.",
"comments": 0,
"updated_at": "2026-04-11T12:00:00Z",
"html_url": "https://aksal.cloud/Robot-U/community/issues/10",
"user": {"login": "Kacper", "avatar_url": ""},
"labels": [],
"state": "open",
},
)
with (
patch.dict(os.environ, {"FORGEJO_GENERAL_DISCUSSION_REPO": "Robot-U/community"}),
patch("app.ForgejoClient", return_value=fake_client),
):
response = self.client.post(
"/api/discussions",
json={
"title": "General project help",
"body": "I need help choosing motors.",
},
headers={"Authorization": "token test-token"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
fake_client.created_issue,
("Robot-U", "community", "General project help", "I need help choosing motors."),
)
def test_create_discussion_rejects_server_token_fallback(self) -> None:
get_settings.cache_clear()
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
response = self.client.post(
"/api/discussions",
json={
"owner": "Robot-U",
"repo": "robot-u-site",
"title": "General project help",
"body": "I need help choosing motors.",
},
)
self.assertEqual(response.status_code, 401)
def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=sign_in_client):
@ -354,15 +624,20 @@ class _FakeForgejoClient:
def __init__(
self,
comment: dict[str, object] | None = None,
comments: list[dict[str, object]] | None = None,
issue: dict[str, object] | None = None,
user: dict[str, object] | None = None,
access_token: str = "test-oauth-token",
repo_private: bool = False,
) -> None:
self._comment = comment
self._comments = comments or []
self._issue = issue
self._user = user or {"login": "test-user"}
self._access_token = access_token
self._repo_private = repo_private
self.created_comment: tuple[str, str, int, str] | None = None
self.created_issue: tuple[str, str, str, str] | None = None
self.exchanged_code: str | None = None
async def __aenter__(self) -> _FakeForgejoClient:
@ -383,6 +658,23 @@ class _FakeForgejoClient:
raise AssertionError("Fake comment was not configured.")
return self._comment
async def create_issue(
self,
owner: str,
repo: str,
title: str,
body: str,
) -> dict[str, object]:
self.created_issue = (owner, repo, title, body)
if self._issue is None:
raise AssertionError("Fake issue was not configured.")
return self._issue
async def fetch_issue(self, _owner: str, _repo: str, _issue_number: int) -> dict[str, object]:
if self._issue is None:
raise AssertionError("Fake issue was not configured.")
return self._issue
async def fetch_current_user(self) -> dict[str, object]:
return self._user
@ -394,6 +686,14 @@ class _FakeForgejoClient:
"private": self._repo_private,
}
async def list_issue_comments(
self,
_owner: str,
_repo: str,
_issue_number: int,
) -> list[dict[str, object]]:
return self._comments
async def fetch_openid_configuration(self) -> dict[str, object]:
return {
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",