test_opengraph_repo_cards.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Open Graph repo card tests — musehub#129 OG_01 through OG_06.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from datetime import datetime, timezone |
| 6 | |
| 7 | import pytest |
| 8 | from httpx import AsyncClient |
| 9 | from sqlalchemy.ext.asyncio import AsyncSession |
| 10 | |
| 11 | from musehub.core.genesis import compute_identity_id, compute_repo_id |
| 12 | from musehub.db.musehub_repo_models import MusehubRepo |
| 13 | from musehub.services.opengraph_cards import compute_content_hash |
| 14 | from musehub.services.opengraph_renderer import StubOpenGraphRenderer, reset_renderer_for_tests |
| 15 | |
| 16 | _PNG_MAGIC = b"\x89PNG\r\n\x1a\n" |
| 17 | |
| 18 | |
| 19 | async def _make_repo( |
| 20 | db: AsyncSession, |
| 21 | owner: str = "alice", |
| 22 | slug: str = "og-test", |
| 23 | *, |
| 24 | description: str = "A test repository for OG cards", |
| 25 | visibility: str = "public", |
| 26 | ) -> MusehubRepo: |
| 27 | created_at = datetime.now(tz=timezone.utc) |
| 28 | owner_id = compute_identity_id(owner.encode()) |
| 29 | repo = MusehubRepo( |
| 30 | repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), |
| 31 | name=slug, |
| 32 | owner=owner, |
| 33 | slug=slug, |
| 34 | visibility=visibility, |
| 35 | description=description, |
| 36 | owner_user_id=owner_id, |
| 37 | created_at=created_at, |
| 38 | updated_at=created_at, |
| 39 | ) |
| 40 | db.add(repo) |
| 41 | await db.commit() |
| 42 | await db.refresh(repo) |
| 43 | return repo |
| 44 | |
| 45 | |
| 46 | @pytest.fixture(autouse=True) |
| 47 | def _reset_og_renderer() -> None: |
| 48 | reset_renderer_for_tests() |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # OG_01 — endpoint returns PNG |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | |
| 56 | async def test_og01_opengraph_image_returns_png( |
| 57 | client: AsyncClient, |
| 58 | db_session: AsyncSession, |
| 59 | ) -> None: |
| 60 | await _make_repo(db_session, owner="alice", slug="og-test") |
| 61 | resp = await client.get("/alice/og-test/opengraph-image") |
| 62 | assert resp.status_code == 200 |
| 63 | assert resp.headers.get("content-type", "").startswith("image/png") |
| 64 | assert resp.content[:8] == _PNG_MAGIC |
| 65 | |
| 66 | |
| 67 | async def test_og01_private_repo_returns_404( |
| 68 | client: AsyncClient, |
| 69 | db_session: AsyncSession, |
| 70 | ) -> None: |
| 71 | await _make_repo(db_session, owner="alice", slug="secret", visibility="private") |
| 72 | resp = await client.get("/alice/secret/opengraph-image") |
| 73 | assert resp.status_code == 404 |
| 74 | |
| 75 | |
| 76 | # --------------------------------------------------------------------------- |
| 77 | # OG_02 — description in template context (stub renderer) |
| 78 | # --------------------------------------------------------------------------- |
| 79 | |
| 80 | |
| 81 | async def test_og02_description_in_renderer_context( |
| 82 | client: AsyncClient, |
| 83 | db_session: AsyncSession, |
| 84 | ) -> None: |
| 85 | desc = "Unique OG description string for OG_02" |
| 86 | await _make_repo(db_session, owner="bob", slug="desc-repo", description=desc) |
| 87 | await client.get("/bob/desc-repo/opengraph-image") |
| 88 | ctx = StubOpenGraphRenderer.last_context |
| 89 | assert ctx is not None |
| 90 | assert ctx.description == desc |
| 91 | |
| 92 | |
| 93 | async def test_og02_empty_description_uses_fallback( |
| 94 | client: AsyncClient, |
| 95 | db_session: AsyncSession, |
| 96 | ) -> None: |
| 97 | await _make_repo(db_session, owner="carol", slug="no-desc", description="") |
| 98 | await client.get("/carol/no-desc/opengraph-image") |
| 99 | ctx = StubOpenGraphRenderer.last_context |
| 100 | assert ctx is not None |
| 101 | assert "carol" in ctx.description |
| 102 | |
| 103 | |
| 104 | # --------------------------------------------------------------------------- |
| 105 | # OG_03 — avatar context for registered and unregistered owners |
| 106 | # --------------------------------------------------------------------------- |
| 107 | |
| 108 | |
| 109 | async def test_og03_avatar_b64_present_for_unregistered_owner( |
| 110 | client: AsyncClient, |
| 111 | db_session: AsyncSession, |
| 112 | ) -> None: |
| 113 | await _make_repo(db_session, owner="dave", slug="avatar-repo") |
| 114 | await client.get("/dave/avatar-repo/opengraph-image") |
| 115 | ctx = StubOpenGraphRenderer.last_context |
| 116 | assert ctx is not None |
| 117 | assert len(ctx.avatar_png_b64) > 20 |
| 118 | |
| 119 | |
| 120 | # --------------------------------------------------------------------------- |
| 121 | # OG_04 — stats reflect DB (commits count via stub context) |
| 122 | # --------------------------------------------------------------------------- |
| 123 | |
| 124 | |
| 125 | async def test_og04_stats_in_renderer_context( |
| 126 | client: AsyncClient, |
| 127 | db_session: AsyncSession, |
| 128 | ) -> None: |
| 129 | await _make_repo(db_session, owner="erin", slug="stats-repo") |
| 130 | await client.get("/erin/stats-repo/opengraph-image") |
| 131 | ctx = StubOpenGraphRenderer.last_context |
| 132 | assert ctx is not None |
| 133 | assert ctx.total_commits >= 0 |
| 134 | assert ctx.fork_count == 0 |
| 135 | assert ctx.open_issue_count == 0 |
| 136 | |
| 137 | |
| 138 | # --------------------------------------------------------------------------- |
| 139 | # OG_05 — cache: second request does not re-render; metadata change does |
| 140 | # --------------------------------------------------------------------------- |
| 141 | |
| 142 | |
| 143 | async def test_og05_cache_hit_avoids_second_render( |
| 144 | client: AsyncClient, |
| 145 | db_session: AsyncSession, |
| 146 | ) -> None: |
| 147 | repo = await _make_repo(db_session, owner="frank", slug="cache-repo") |
| 148 | await client.get("/frank/cache-repo/opengraph-image") |
| 149 | assert StubOpenGraphRenderer.render_count == 1 |
| 150 | await client.get("/frank/cache-repo/opengraph-image") |
| 151 | assert StubOpenGraphRenderer.render_count == 1 |
| 152 | |
| 153 | repo.description = "Updated description busts the cache" |
| 154 | await db_session.commit() |
| 155 | await client.get("/frank/cache-repo/opengraph-image") |
| 156 | assert StubOpenGraphRenderer.render_count == 2 |
| 157 | |
| 158 | |
| 159 | # --------------------------------------------------------------------------- |
| 160 | # OG_06 — repo page HTML contains absolute og:image with ?cb= |
| 161 | # --------------------------------------------------------------------------- |
| 162 | |
| 163 | |
| 164 | async def test_og06_repo_page_emits_og_image_meta( |
| 165 | client: AsyncClient, |
| 166 | db_session: AsyncSession, |
| 167 | ) -> None: |
| 168 | await _make_repo(db_session, owner="grace", slug="meta-repo", description="OG meta test") |
| 169 | resp = await client.get("/grace/meta-repo") |
| 170 | assert resp.status_code == 200 |
| 171 | assert 'property="og:image"' in resp.text |
| 172 | assert "opengraph-image?cb=" in resp.text |
| 173 | assert resp.text.index("http") < resp.text.index("opengraph-image") |
| 174 | |
| 175 | |
| 176 | async def test_og06_twitter_large_image_card( |
| 177 | client: AsyncClient, |
| 178 | db_session: AsyncSession, |
| 179 | ) -> None: |
| 180 | await _make_repo(db_session, owner="hank", slug="twitter-repo") |
| 181 | resp = await client.get("/hank/twitter-repo") |
| 182 | assert 'name="twitter:card"' in resp.text |
| 183 | assert "summary_large_image" in resp.text |
| 184 | |
| 185 | |
| 186 | # --------------------------------------------------------------------------- |
| 187 | # Unit — content hash stability |
| 188 | # --------------------------------------------------------------------------- |
| 189 | |
| 190 | |
| 191 | def test_content_hash_changes_when_description_changes() -> None: |
| 192 | from musehub.services.opengraph_cards import OpenGraphCardContext, description_fallback |
| 193 | |
| 194 | base = OpenGraphCardContext( |
| 195 | owner="a", |
| 196 | repo_slug="b", |
| 197 | description=description_fallback("a"), |
| 198 | domain_id="code", |
| 199 | total_commits=1, |
| 200 | fork_count=0, |
| 201 | open_issue_count=0, |
| 202 | owner_identity_hex="abc", |
| 203 | identity_type="agent", |
| 204 | avatar_png_b64="e30=", |
| 205 | ) |
| 206 | h1 = compute_content_hash(base) |
| 207 | changed = OpenGraphCardContext( |
| 208 | owner=base.owner, |
| 209 | repo_slug=base.repo_slug, |
| 210 | description="different", |
| 211 | domain_id=base.domain_id, |
| 212 | total_commits=base.total_commits, |
| 213 | fork_count=base.fork_count, |
| 214 | open_issue_count=base.open_issue_count, |
| 215 | owner_identity_hex=base.owner_identity_hex, |
| 216 | identity_type=base.identity_type, |
| 217 | avatar_png_b64=base.avatar_png_b64, |
| 218 | ) |
| 219 | h2 = compute_content_hash(changed) |
| 220 | assert h1 != h2 |
| 221 | |
| 222 | |
| 223 | # --------------------------------------------------------------------------- |
| 224 | # Security — slug injection blocked at validation layer |
| 225 | # --------------------------------------------------------------------------- |
| 226 | |
| 227 | |
| 228 | async def test_og_security_invalid_slug_rejected(client: AsyncClient) -> None: |
| 229 | resp = await client.get("/alice/not%20valid/opengraph-image") |
| 230 | assert resp.status_code == 422 |