import json from pathlib import Path import httpx import pytest from src import gitea_proxy, main ROOT = Path(__file__).resolve().parents[1] @pytest.mark.anyio async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch): calls = [] async def fake_fetch(path): calls.append(("fetch", path)) if path == "user": return {"login": "timmy"} if path == "repos/stackchain/api/issues/comments/91": return { "issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7", } raise AssertionError(path) class Response: def raise_for_status(self): return None def json(self): return [ {"content": "heart", "user": {"login": "alex"}}, {"content": "heart", "user": {"login": "timmy"}}, {"content": "+1", "user": {"login": "alex"}}, {"content": "unsupported", "user": {"login": "alex"}}, {"content": "heart", "user": {}}, ] class Client: async def get(self, path, headers): calls.append(("get", path)) return Response() monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"}) result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91) assert result == { "comment_id": 91, "reactions": [ {"content": "+1", "count": 1, "selected": False}, {"content": "heart", "count": 2, "selected": True}, ], } assert calls == [ ("fetch", "user"), ("fetch", "repos/stackchain/api/issues/comments/91"), ("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"), ] @pytest.mark.anyio async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch): async def fake_fetch(path): if path == "user": return {"login": "timmy"} return { "issue_url": "https://forge.example/git/stackchain/api/issues/7", } class Response: def raise_for_status(self): return None def json(self): return [] class Client: async def get(self, path, headers): return Response() monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91) assert result == {"comment_id": 91, "reactions": []} @pytest.mark.anyio async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch): payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]] mutations = [] async def fake_fetch(path): if path == "user": return {"login": "timmy"} return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"} class Response: def __init__(self, payload=None): self.payload = payload def raise_for_status(self): return None def json(self): return self.payload class Client: async def get(self, path, headers): return Response(payloads.pop(0)) async def post(self, path, headers, json): mutations.append(("post", path, json)) return Response() async def delete(self, path, headers, json): mutations.append(("delete", path, json)) return Response() monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"}) result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True) path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions" assert mutations == [("post", path, {"content": "heart"})] assert result == { "comment_id": 91, "reactions": [{"content": "heart", "count": 1, "selected": True}], } @pytest.mark.anyio async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch): reads = [] writes = [] async def assigned(repository, number): return (repository, number) == ("stackchain/api", 7) async def pull_capabilities(repository, number): return {"can_open": (repository, number) == ("stackchain/api", 8)} async def notification_target(thread_id): assert thread_id == 42 return "stackchain/api", 9 async def reactions(repository, number, comment_id): reads.append((repository, number, comment_id)) return {"comment_id": comment_id, "reactions": []} async def set_reaction(repository, number, comment_id, content, active): writes.append((repository, number, comment_id, content, active)) return { "comment_id": comment_id, "reactions": [{"content": content, "count": 1, "selected": active}], } monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities) monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True) monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target) monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False) monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions") pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions") update = await client.get("/api/v1/notifications/42/comments/93/reactions") changed = await client.put( "/api/v1/notifications/42/comments/93/reactions/heart", json={"active": True}, ) assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200] assert reads == [ ("stackchain/api", 7, 91), ("stackchain/api", 8, 92), ("stackchain/api", 9, 93), ] assert writes == [("stackchain/api", 9, 93, "heart", True)] assert changed.json()["reactions"][0]["selected"] is True def run_node(script: str) -> dict: import subprocess result = subprocess.run( ["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True ) return json.loads(result.stdout) def test_comment_action_controller_loads_and_toggles_authoritative_reactions(): module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) script = f""" const createActions=require({module}); const calls=[]; const actions=createActions({{ fetchJson:async (path, options={{}})=>{{ calls.push([path, options.method || 'GET', options.body || null]); return options.method === 'PUT' ? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}} : {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}}; }}, getLogin:()=> 'timmy', }}); (async()=>{{ const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}}; const loaded=await actions.loadReactions(context,91); const changed=await actions.setReaction(context,91,'heart',true); process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ output = run_node(script) base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions" assert output["calls"] == [ [base, "GET", None], [base + "/heart", "PUT", '{"active":true}'], ] assert output["loaded"]["reactions"][0]["count"] == 1 assert output["changed"]["reactions"][0]["selected"] is True assert 'data-comment-reactions-open' in output["html"] assert 'aria-label="React to this comment"' in output["html"] def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection(): module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) script = f""" const createActions=require({module}); const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}}); process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{ comment_id:91, reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}], }})}})); """ html = run_node(script)["html"] assert 'data-comment-reaction="heart"' in html assert 'aria-pressed="true"' in html assert "Heart 2" in html assert 'data-comment-reaction="rocket"' in html assert "Rocket 0" in html assert 'data-comment-reactions-close' in html css = (ROOT / "frontend" / "dashboard.css").read_text() assert ".comment-reactions" in css assert "flex-wrap:wrap" in css assert ".comment-reaction-menu button" in css assert "min-height:44px" in css def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle(): module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) script = f""" const createActions=require({module}); const calls=[]; const menu={{hidden:true,innerHTML:''}}; const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}}; const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}}; const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}}; const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}}; const status={{textContent:''}}; const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}}; const actions=createActions({{ fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}}, getLogin:()=> 'timmy', }}); actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}}); (async()=>{{ await root.listeners.click({{target:trigger}}); await root.listeners.click({{target:reaction}}); process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ output = run_node(script) base = "api/v1/notifications/42/comments/91/reactions" assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]] assert output["hidden"] is True assert output["expanded"] == "false" assert output["focused"] == 1 assert output["status"] == "Reaction updated." def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance(): module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) script = f""" const createActions=require({module}); let calls=0; const menu={{hidden:true,innerHTML:''}}; const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}}; const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}}; const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}}; const status={{textContent:''}}; const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}}); actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}}); (async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})(); """ assert run_node(script) == {"calls": 0, "status": "Reconnect to react."}