stackchain-dashboard/tests/test_comment_reactions.py
timmy b4f3c6d419
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
feat: react to mobile conversation comments (Closes #1380)
2026-08-25 05:09:32 +00:00

309 lines
12 KiB
Python

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."}