feat: sync saved mobile search views (Closes #821)
This commit is contained in:
1 parent
6314efb82b
commit
19772b5797
13 files changed
+593
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user