stackchain-dashboard/tests/test_saved_search_store.py
timmy 19772b5797
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: sync saved mobile search views (Closes #821)
2026-08-14 10:46:13 +00:00

96 lines
4.0 KiB
Python

import httpx
import pytest
from src import main
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
def view(view_id="release", name="Release queue", query="mobile", **scope):
return {
"id": view_id,
"name": name,
"query": query,
"kind": scope.get("kind", "issue"),
"state": scope.get("state", "open"),
"repository": scope.get("repository", "stackchain/stackchain-dashboard"),
}
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
created = store.replace(" Timmy ", 0, [view(), view("reviews", "My reviews", "review", kind="pull")])
assert created == {"revision": 1, "views": [view(), view("reviews", "My reviews", "review", kind="pull")]}
assert store.get("timmy") == created
assert store.get("alexander") == {"revision": 0, "views": []}
with pytest.raises(SavedSearchConflict) as conflict:
store.replace("timmy", 0, [view(name="Stale overwrite")])
assert conflict.value.snapshot == created
def test_saved_searches_validate_and_bound_the_synced_collection(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3", limit=2)
with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [view("one"), view("two"), view("three")])
with pytest.raises(ValueError, match="unique"):
store.replace("timmy", 0, [view("same"), view("same")])
invalid = [
({**view(), "name": " "}, "name is required"),
({**view(), "query": "x"}, "query must be between"),
({**view(), "kind": "commit"}, "kind is invalid"),
({**view(), "state": "merged"}, "state is invalid"),
({**view(), "repository": "not-a-repository"}, "repository is invalid"),
]
for candidate, message in invalid:
with pytest.raises(ValueError, match=message):
store.replace("timmy", 0, [candidate])
@pytest.mark.anyio
async def test_saved_search_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SAVED_SEARCH_DB", str(tmp_path / "searches.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
forbidden = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}
)
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}, headers=headers
)
stale = await client.put(
"/api/v1/saved-searches",
json={"revision": 0, "views": [view(name="Overwrite")]},
headers=headers,
)
fetched = await client.get("/api/v1/saved-searches")
assert forbidden.status_code == 403
assert saved.status_code == 200
assert saved.json() == {"revision": 1, "views": [view()]}
assert stale.status_code == 409
assert stale.json()["detail"] == {
"message": "Saved searches changed on another device.",
"snapshot": saved.json(),
}
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"