feat: add mobile Following queue for watched issues (Closes #1293)
This commit is contained in:
1 parent
4b4e55dbe2
commit
041f2dbe9b
17 files changed
+553
-12
No files matched your search
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_following_queue_is_phone_usable_at_narrow_viewport():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
|
||||
row = page.locator('[data-mobile-queue="following"]')
|
||||
expect(row).to_have_count(1)
|
||||
page.locator("#following-list").evaluate("""node => {
|
||||
node.innerHTML = '<button class="following-card" type="button"><span><strong>Quiet issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true">›</span></button>';
|
||||
}""")
|
||||
page.locator("#following-sheet").evaluate("node => node.showModal()")
|
||||
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_be_visible()
|
||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||
assert overflow is False
|
||||
browser.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
|
||||
detail = {
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
}
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
|
||||
return detail
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watched = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
following = await client.get("/api/v1/following")
|
||||
|
||||
assert watched.status_code == 200
|
||||
assert watched.json() == {"watching": True, "following_revision": 1, "following_count": 1}
|
||||
assert following.status_code == 200
|
||||
assert following.headers["cache-control"] == "no-store"
|
||||
assert following.json() == {"revision": 1, "items": [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}]}
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "following.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
|
||||
const feature = createFollowing({{
|
||||
fetchJson: async path => {{
|
||||
state.requests.push(path);
|
||||
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42'}}]}};
|
||||
}},
|
||||
render: snapshot => state.renders.push(snapshot),
|
||||
onCount: count => state.counts.push(count),
|
||||
onOpen: item => state.opened.push(item),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_following_loads_account_collection_and_opens_existing_preview():
|
||||
result = run("""
|
||||
await feature.load();
|
||||
feature.open(0);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["api/v1/following"]
|
||||
assert result["counts"] == [1]
|
||||
assert result["renders"][-1]["status"] == "ready"
|
||||
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
|
||||
assert result["opened"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Quiet issue",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/issue/42",
|
||||
"kind": "issue",
|
||||
}]
|
||||
|
||||
|
||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(launcher))});
|
||||
const calls = [];
|
||||
const feature = createLauncher({{
|
||||
getCounts:() => ({{following:7}}),
|
||||
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
|
||||
selectFilter:name => calls.push(name), firstAction:() => null,
|
||||
announce:() => {{}}, openFindWork:() => calls.push('find'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result == {
|
||||
"opened": "opened-following",
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import sqlite3
|
||||
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
KEY = b"f" * 32
|
||||
ITEM = {
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||
path = tmp_path / "following.sqlite3"
|
||||
store = FollowingStore(path, encryption_key=KEY, limit=50)
|
||||
|
||||
first = store.set_watching("Timmy", ITEM, True)
|
||||
repeated = store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert first == repeated == {"revision": 1, "items": [ITEM]}
|
||||
assert store.get("other") == {"revision": 0, "items": []}
|
||||
stored = sqlite3.connect(path).execute(
|
||||
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
||||
).fetchone()[0]
|
||||
assert ITEM["title"] not in stored
|
||||
assert ITEM["repository"] not in stored
|
||||
@@ -251,19 +251,26 @@ async def test_search_preview_subscription_reads_server_truth_without_caching(mo
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
||||
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
||||
monkeypatch, method, watching
|
||||
monkeypatch, tmp_path, method, watching
|
||||
):
|
||||
calls = []
|
||||
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
calls.append(("preview", repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number,
|
||||
"state": "open", "claimable": True}
|
||||
"title": "Quiet issue", "state": "open", "claimable": True,
|
||||
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
|
||||
|
||||
async def set_subscription(repository, number, desired):
|
||||
calls.append(("set", repository, number, desired))
|
||||
return {"watching": desired}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -275,7 +282,12 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"watching": watching}
|
||||
expected_count = 1 if watching else 0
|
||||
assert response.json() == {
|
||||
"watching": watching,
|
||||
"following_revision": expected_count,
|
||||
"following_count": expected_count,
|
||||
}
|
||||
assert calls == [
|
||||
("preview", "stackchain/api", "issue", 42),
|
||||
("set", "stackchain/api", 42, watching),
|
||||
|
||||
@@ -1333,6 +1333,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/saved-searches.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/following.js",
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
|
||||
Reference in new issue
Block a user