"""Open Graph repo card tests — musehub#129 OG_01 through OG_06.""" from __future__ import annotations from datetime import datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubRepo from musehub.services.opengraph_cards import compute_content_hash from musehub.services.opengraph_renderer import StubOpenGraphRenderer, reset_renderer_for_tests _PNG_MAGIC = b"\x89PNG\r\n\x1a\n" async def _make_repo( db: AsyncSession, owner: str = "alice", slug: str = "og-test", *, description: str = "A test repository for OG cards", visibility: str = "public", ) -> MusehubRepo: created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(owner.encode()) repo = MusehubRepo( repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), name=slug, owner=owner, slug=slug, visibility=visibility, description=description, owner_user_id=owner_id, created_at=created_at, updated_at=created_at, ) db.add(repo) await db.commit() await db.refresh(repo) return repo @pytest.fixture(autouse=True) def _reset_og_renderer() -> None: reset_renderer_for_tests() # --------------------------------------------------------------------------- # OG_01 — endpoint returns PNG # --------------------------------------------------------------------------- async def test_og01_opengraph_image_returns_png( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="alice", slug="og-test") resp = await client.get("/alice/og-test/opengraph-image") assert resp.status_code == 200 assert resp.headers.get("content-type", "").startswith("image/png") assert resp.content[:8] == _PNG_MAGIC async def test_og01_private_repo_returns_404( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="alice", slug="secret", visibility="private") resp = await client.get("/alice/secret/opengraph-image") assert resp.status_code == 404 # --------------------------------------------------------------------------- # OG_02 — description in template context (stub renderer) # --------------------------------------------------------------------------- async def test_og02_description_in_renderer_context( client: AsyncClient, db_session: AsyncSession, ) -> None: desc = "Unique OG description string for OG_02" await _make_repo(db_session, owner="bob", slug="desc-repo", description=desc) await client.get("/bob/desc-repo/opengraph-image") ctx = StubOpenGraphRenderer.last_context assert ctx is not None assert ctx.description == desc async def test_og02_empty_description_uses_fallback( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="carol", slug="no-desc", description="") await client.get("/carol/no-desc/opengraph-image") ctx = StubOpenGraphRenderer.last_context assert ctx is not None assert "carol" in ctx.description # --------------------------------------------------------------------------- # OG_03 — avatar context for registered and unregistered owners # --------------------------------------------------------------------------- async def test_og03_avatar_b64_present_for_unregistered_owner( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="dave", slug="avatar-repo") await client.get("/dave/avatar-repo/opengraph-image") ctx = StubOpenGraphRenderer.last_context assert ctx is not None assert len(ctx.avatar_png_b64) > 20 # --------------------------------------------------------------------------- # OG_04 — stats reflect DB (commits count via stub context) # --------------------------------------------------------------------------- async def test_og04_stats_in_renderer_context( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="erin", slug="stats-repo") await client.get("/erin/stats-repo/opengraph-image") ctx = StubOpenGraphRenderer.last_context assert ctx is not None assert ctx.total_commits >= 0 assert ctx.fork_count == 0 assert ctx.open_issue_count == 0 # --------------------------------------------------------------------------- # OG_05 — cache: second request does not re-render; metadata change does # --------------------------------------------------------------------------- async def test_og05_cache_hit_avoids_second_render( client: AsyncClient, db_session: AsyncSession, ) -> None: repo = await _make_repo(db_session, owner="frank", slug="cache-repo") await client.get("/frank/cache-repo/opengraph-image") assert StubOpenGraphRenderer.render_count == 1 await client.get("/frank/cache-repo/opengraph-image") assert StubOpenGraphRenderer.render_count == 1 repo.description = "Updated description busts the cache" await db_session.commit() await client.get("/frank/cache-repo/opengraph-image") assert StubOpenGraphRenderer.render_count == 2 # --------------------------------------------------------------------------- # OG_06 — repo page HTML contains absolute og:image with ?cb= # --------------------------------------------------------------------------- async def test_og06_repo_page_emits_og_image_meta( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="grace", slug="meta-repo", description="OG meta test") resp = await client.get("/grace/meta-repo") assert resp.status_code == 200 assert 'property="og:image"' in resp.text assert "opengraph-image?cb=" in resp.text assert resp.text.index("http") < resp.text.index("opengraph-image") async def test_og06_twitter_large_image_card( client: AsyncClient, db_session: AsyncSession, ) -> None: await _make_repo(db_session, owner="hank", slug="twitter-repo") resp = await client.get("/hank/twitter-repo") assert 'name="twitter:card"' in resp.text assert "summary_large_image" in resp.text # --------------------------------------------------------------------------- # Unit — content hash stability # --------------------------------------------------------------------------- def test_content_hash_changes_when_description_changes() -> None: from musehub.services.opengraph_cards import OpenGraphCardContext, description_fallback base = OpenGraphCardContext( owner="a", repo_slug="b", description=description_fallback("a"), domain_id="code", total_commits=1, fork_count=0, open_issue_count=0, owner_identity_hex="abc", identity_type="agent", avatar_png_b64="e30=", ) h1 = compute_content_hash(base) changed = OpenGraphCardContext( owner=base.owner, repo_slug=base.repo_slug, description="different", domain_id=base.domain_id, total_commits=base.total_commits, fork_count=base.fork_count, open_issue_count=base.open_issue_count, owner_identity_hex=base.owner_identity_hex, identity_type=base.identity_type, avatar_png_b64=base.avatar_png_b64, ) h2 = compute_content_hash(changed) assert h1 != h2 # --------------------------------------------------------------------------- # Security — slug injection blocked at validation layer # --------------------------------------------------------------------------- async def test_og_security_invalid_slug_rejected(client: AsyncClient) -> None: resp = await client.get("/alice/not%20valid/opengraph-image") assert resp.status_code == 422