feat: make Following a changed-first inbox (Closes #1297)
This commit is contained in:
1 parent
bbf397454d
commit
32e2a228b9
8 files changed
+325
-21
No files matched your search
@@ -23,12 +23,13 @@ def test_following_queue_is_phone_usable_at_narrow_viewport():
|
||||
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>';
|
||||
node.innerHTML = '<button class="following-card has-unseen-change" type="button"><span><em>New activity</em><strong>Changed 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()
|
||||
expect(page.locator(".following-card")).to_contain_text("New activity")
|
||||
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")
|
||||
|
||||
@@ -50,13 +50,14 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
|
||||
}
|
||||
assert following.status_code == 200
|
||||
assert following.headers["cache-control"] == "no-store"
|
||||
assert following.json() == {"revision": 1, "items": [{
|
||||
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "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",
|
||||
"has_unseen_change": False,
|
||||
}]}
|
||||
|
||||
|
||||
@@ -180,3 +181,71 @@ async def test_uncompensated_following_write_reports_authoritative_partial_outco
|
||||
"error": "Watching in Gitea, but Following could not sync. Retry this action.",
|
||||
}
|
||||
assert calls == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_refreshes_changed_items_and_preserves_failed_items(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"e" * 32)
|
||||
first = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Old title",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
failed = {**first, "number": 43, "title": "Last known",
|
||||
"url": "https://forge.example/stackchain/api/issues/43"}
|
||||
store.set_watching("timmy", failed, True)
|
||||
store.set_watching("timmy", first, True)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind) == ("stackchain/api", "issue")
|
||||
if number == 43:
|
||||
return {**failed, "kind": "issue", "title": ""}
|
||||
return {**first, "kind": "issue", "title": "Fresh title", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z"}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/following")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["degraded"] is True
|
||||
assert payload["refresh_failures"] == 1
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in payload["items"]] == [
|
||||
(42, True), (43, False)
|
||||
]
|
||||
assert payload["items"][0]["title"] == "Fresh title"
|
||||
assert payload["items"][1]["title"] == "Last known"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
item = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Changed",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
store.set_watching("timmy", item, True)
|
||||
store.refresh("timmy", [{**item, "updated_at": "2026-08-23T04:00:00Z"}])
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/following/stackchain/api/issues/42/seen",
|
||||
json={"updated_at": "2026-08-23T04:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["has_unseen_change"] is False
|
||||
@@ -14,7 +14,7 @@ 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'}}]}};
|
||||
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',has_unseen_change:false}}]}};
|
||||
}},
|
||||
render: snapshot => state.renders.push(snapshot),
|
||||
onCount: count => state.counts.push(count),
|
||||
@@ -34,7 +34,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["api/v1/following"]
|
||||
assert result["counts"] == [1]
|
||||
assert result["counts"] == [0]
|
||||
assert result["renders"][-1]["status"] == "ready"
|
||||
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
|
||||
assert result["opened"] == [{
|
||||
@@ -44,6 +44,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/issue/42",
|
||||
"has_unseen_change": False,
|
||||
"kind": "issue",
|
||||
}]
|
||||
|
||||
@@ -69,3 +70,38 @@ process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommend
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
|
||||
|
||||
def test_following_counts_unseen_changes_and_acknowledges_after_preview_loads():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{counts:[], opened:[], acknowledged:[], renders:[]}};
|
||||
let finishPreview;
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:4,items:[
|
||||
{{repository:'stackchain/api',number:42,title:'Changed',state:'open',updated_at:'2026-08-23T04:00:00Z',url:'https://forge/42',has_unseen_change:true}},
|
||||
{{repository:'stackchain/api',number:43,title:'Quiet',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge/43',has_unseen_change:false}}
|
||||
]}}),
|
||||
render:value => state.renders.push(value),
|
||||
onCount:value => state.counts.push(value),
|
||||
onOpen:item => new Promise(resolve => {{ finishPreview=() => {{ state.opened.push(item.number); resolve(); }}; }}),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
const opening=feature.open(0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.before={{acknowledged:[...state.acknowledged],counts:[...state.counts]}};
|
||||
finishPreview();
|
||||
await opening;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result["before"] == {"acknowledged": [], "counts": [1]}
|
||||
assert result["opened"] == [42]
|
||||
assert result["acknowledged"] == ["2026-08-23T04:00:00Z"]
|
||||
assert result["counts"] == [1, 0]
|
||||
assert result["renders"][-1]["items"][0]["has_unseen_change"] is False
|
||||
@@ -21,10 +21,54 @@ def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||
first = store.set_watching("Timmy", ITEM, True)
|
||||
repeated = store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert first == repeated == {"revision": 1, "items": [ITEM]}
|
||||
assert first == repeated == {"revision": 1, "items": [{**ITEM, "has_unseen_change": False}]}
|
||||
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
|
||||
|
||||
|
||||
def test_refresh_marks_changed_items_unseen_and_sorts_them_first(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
quiet = {**ITEM, "number": 7, "title": "Quiet", "updated_at": "2026-08-23T02:00:00Z"}
|
||||
store.set_watching("timmy", quiet, True)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
snapshot = store.refresh("timmy", [{
|
||||
**quiet, "title": "Changed upstream", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z",
|
||||
}])
|
||||
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in snapshot["items"]] == [
|
||||
(7, True), (42, False)
|
||||
]
|
||||
assert snapshot["items"][0]["title"] == "Changed upstream"
|
||||
assert snapshot["items"][0]["state"] == "closed"
|
||||
|
||||
|
||||
def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
stale = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], ITEM["updated_at"])
|
||||
assert stale["items"][0]["has_unseen_change"] is True
|
||||
seen = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
assert seen["items"][0]["has_unseen_change"] is False
|
||||
|
||||
store.refresh("timmy", [{**ITEM, "updated_at": "2026-08-23T05:00:00Z"}])
|
||||
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
snapshot = store.set_watching("timmy", changed, True)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||
Reference in new issue
Block a user