feat: sync saved mobile search views (Closes #821)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 10:46:13 +00:00
1 parent 6314efb82b
commit 19772b5797
13 files changed
+593 -4

No files matched your search

+2 -2
View File
@@ -74,8 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
assert len(first.runtime_gzip_bytes) <= 99 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
+95
View File
@@ -0,0 +1,95 @@
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"
+105
View File
@@ -0,0 +1,105 @@
import json
import subprocess
from pathlib import Path
SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js"
def run_node(script):
return json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
def test_saved_search_controller_loads_saves_opens_renames_and_deletes():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const requests=[]; const opened=[]; const states=[];
let remote={{revision:0,views:[]}}; let sequence=0;
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
requests.push(options.method||'GET');
if(!options.method)return remote;
const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote;
}},
createId:()=> 'view-'+(++sequence),
onOpen:view=>opened.push(view),
onState:state=>states.push(state),
}});
(async()=>{{
await controller.load();
await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}});
controller.open('view-1');
await controller.rename('view-1','Morning release queue');
await controller.remove('view-1');
process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
assert run_node(script) == {
"requests": ["GET", "PUT", "PUT", "PUT"],
"opened": [{
"id": "view-1",
"name": "Release queue",
"query": "mobile",
"kind": "issue",
"state": "open",
"repository": "stackchain/dashboard",
}],
"snapshot": {"revision": 3, "views": []},
"statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"],
}
def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const states=[];let mode='conflict';
const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}};
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
if(!options.method)return {{revision:3,views:[]}};
if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}}
throw new Error('offline');
}},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']),
}});
(async()=>{{
await controller.load();
try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}}
mode='offline';
try{{await controller.remove('remote')}}catch(_error){{}}
process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}}));
}})();
"""
result = run_node(script)
assert result["snapshot"] == {
"revision": 4,
"views": [{
"id": "remote", "name": "Remote", "query": "review",
"kind": "pull", "state": "open", "repository": "",
}],
}
assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."]
assert ["conflict", "Saved searches changed on another device."] in result["states"]
def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring():
from tests.dashboard_bundle import dashboard_bundle_text
html = dashboard_bundle_text()
css = (SAVED_SEARCHES.parent / "dashboard.css").read_text()
dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text()
assert '<section class="saved-searches" aria-labelledby="saved-searches-heading">' in html
assert 'id="saved-search-name"' in html
assert 'id="save-current-search"' in html
assert 'id="saved-search-list"' in html
assert 'id="saved-search-status"' in html
assert "createSavedSearches.mount(" in dashboard
saved_searches = SAVED_SEARCHES.read_text()
assert "search.setQuery('')" in saved_searches
assert "applyScope(view)" in saved_searches
assert "search.setQuery(view.query)" in saved_searches
assert ").load();" in dashboard
assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text()
assert ".saved-search-action { min-height:44px;" in css
+1
View File
@@ -787,6 +787,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/security-center.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",