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_synced": True, "following_revision": 1, "following_count": 1, } assert following.status_code == 200 assert following.headers["cache-control"] == "no-store" assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{ "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", "has_unseen_change": False, }]} @pytest.mark.anyio async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32) detail = { "repository": "stackchain/api", "kind": "pull", "number": 84, "title": "Ship typed Following", "state": "open", "updated_at": "2026-08-23T05:00:00Z", "url": "https://forge.example/stackchain/api/pulls/84", } previews = [] async def preview(repository, kind, number): previews.append((repository, kind, number)) 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) 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/84/preview/subscription?kind=pull" ) following = await client.get("/api/v1/following") assert watched.status_code == 200 assert following.status_code == 200 assert following.json()["items"][0]["kind"] == "pull" assert previews == [ ("stackchain/api", "pull", 84), ("stackchain/api", "pull", 84), ] @pytest.mark.anyio async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32) store.set_watching("timmy", { "repository": "stackchain/api", "kind": "pull", "number": 84, "title": "Review watched changes", "state": "open", "updated_at": "2026-08-24T05:00:00Z", "url": "https://forge.example/stackchain/api/pulls/84", }, True) review = { "head_sha": "abc123", "ci_state": "success", "files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}], } async def user(): return {"login": "timmy"} async def load_review(repository, number): assert (repository, number) == ("stackchain/api", 84) return review monkeypatch.setattr(main, "_following_store", lambda: store) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review) 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/stackchain/api/pulls/84/review-data" ) assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" assert response.json() == review @pytest.mark.anyio async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32) async def user(): return {"login": "timmy"} async def unexpected_review(*_args): raise AssertionError("unwatched pull must be rejected before upstream access") monkeypatch.setattr(main, "_following_store", lambda: store) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review) 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/stackchain/api/pulls/84/review-data" ) assert response.status_code == 404 @pytest.mark.anyio async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1) store.set_watching("timmy", { "repository": "stackchain/api", "number": 7, "title": "Already followed", "state": "open", "updated_at": "2026-08-23T02:00:00Z", "url": "https://forge.example/stackchain/api/issues/7", }, True) calls = [] async def preview(repository, kind, number): return { "repository": repository, "kind": kind, "number": number, "title": "Another issue", "state": "open", "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, } async def set_subscription(*args): calls.append(args) return {"watching": True} async def user(): return {"login": "timmy"} monkeypatch.setattr(main, "_following_store", lambda: store) 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: response = await client.put( "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" ) assert response.status_code == 503 assert calls == [] assert store.get("timmy")["items"][0]["number"] == 7 @pytest.mark.anyio async def test_following_write_failure_compensates_confirmed_gitea_watch(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"c" * 32) calls = [] async def preview(repository, kind, number): return { "repository": repository, "kind": kind, "number": number, "title": "Recoverable watch", "state": "open", "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, } async def set_subscription(repository, number, watching): calls.append(watching) return {"watching": watching} async def user(): return {"login": "timmy"} def fail_write(*args): raise OSError("disk unavailable") monkeypatch.setattr(main, "_following_store", lambda: store) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(store, "set_watching", fail_write) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.put( "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" ) assert response.status_code == 503 assert response.json() == {"error": "Watch status was not changed. Please retry."} assert calls == [True, False] @pytest.mark.anyio async def test_uncompensated_following_write_reports_authoritative_partial_outcome(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"d" * 32) calls = [] async def preview(repository, kind, number): return { "repository": repository, "kind": kind, "number": number, "title": "Split watch", "state": "open", "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", "claimable": True, } async def set_subscription(repository, number, watching): calls.append(watching) return {"watching": True} async def user(): return {"login": "timmy"} monkeypatch.setattr(main, "_following_store", lambda: store) monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(store, "set_watching", lambda *args: (_ for _ in ()).throw(OSError())) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.put( "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" ) assert response.status_code == 200 assert response.json() == { "watching": True, "following_synced": False, "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", "kind": "pull", "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?kind=pull", json={"updated_at": "2026-08-23T04:00:00Z"}, ) assert response.status_code == 200 assert response.json()["items"][0]["has_unseen_change"] is False @pytest.mark.anyio async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32) item = { "repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed", "state": "open", "updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/stackchain/api/pulls/42", } store.set_watching("timmy", item, True) changed = {**item, "updated_at": "2026-08-23T04:00:00Z"} store.refresh("timmy", [changed]) store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull") 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/keep?kind=pull", json={"updated_at": changed["updated_at"]}, ) assert response.status_code == 200 assert response.json()["items"][0]["has_unseen_change"] is True @pytest.mark.anyio async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32) closed = { "repository": "stackchain/api", "number": 42, "title": "Finished work", "state": "closed", "updated_at": "2026-08-23T04:00:00Z", "url": "https://forge.example/stackchain/api/issues/42", } store.set_watching("timmy", closed, True) mutations = [] async def preview(repository, kind, number): return {**closed, "kind": "issue"} async def set_subscription(repository, number, watching): mutations.append((repository, number, watching)) return {"watching": watching} async def user(): return {"login": "timmy"} monkeypatch.setattr(main, "_following_store", lambda: store) 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: watch = await client.put( "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" ) unwatch = await client.delete( "/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" ) assert watch.status_code == 404 assert unwatch.status_code == 200 assert unwatch.json() == { "watching": False, "following_synced": True, "following_revision": 2, "following_count": 0, } assert mutations == [("stackchain/api", 42, False)] assert store.get("timmy")["items"] == []