From 19772b5797686505c580ef2f57a7d5de9b7758eb Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 10:46:13 +0000 Subject: [PATCH] feat: sync saved mobile search views (Closes #821) --- README.md | 6 ++ frontend/dashboard.css | 12 +++ frontend/dashboard.js | 3 + frontend/index.html | 13 +++ frontend/saved-searches.js | 159 +++++++++++++++++++++++++++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 2 +- src/main.py | 66 ++++++++++++- src/saved_search_store.py | 130 +++++++++++++++++++++++++ tests/test_frontend_bundle.py | 4 +- tests/test_saved_search_store.py | 95 ++++++++++++++++++ tests/test_saved_searches.py | 105 ++++++++++++++++++++ tests/test_service_worker.py | 1 + 13 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 frontend/saved-searches.js create mode 100644 src/saved_search_store.py create mode 100644 tests/test_saved_search_store.py create mode 100644 tests/test_saved_searches.py diff --git a/README.md b/README.md index 6101e77..c435ecd 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success. +Named mobile Search views preserve the query, type, status, and optional repository scope. They are +bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another +device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently +overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable +sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default +`.stackchain-state/saved-searches.sqlite3` path. Search previews also let operators assign an eligible issue and add it to Today without starting or replacing active work. The queue action keeps the Search query, filters, results, and scroll position available for continued diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a1523bf..a0e48e5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -121,6 +121,15 @@ textarea { resize: vertical; min-height: 120px; } .cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } .cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } .cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; } +.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; } +.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; } +.saved-search-header { justify-content:space-between; } +.saved-search-create { margin-top:6px; } +.saved-search-create input { flex:1; min-width:0; } +.saved-search-list { display:grid; gap:4px; margin-top:6px; } +.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; } +.saved-search-open { flex:1; min-width:0; text-align:left; } +.saved-search-action { min-height:44px; } #cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; } .cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; } .cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } @@ -733,6 +742,9 @@ textarea { resize: vertical; min-height: 120px; } .cmd-search-scope { grid-template-columns:auto minmax(0,1fr); } #cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; } #cmd-search-repository { min-height:44px; width:100%; } + .saved-search-create { align-items:stretch; } + .saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; } + .saved-search-row { align-items:stretch; } #cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); } .create-issue-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 989ba6c..f7acc12 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4613,6 +4613,9 @@ }, }); taskOverlayHistory.start(); + createSavedSearches.mount( + document, fetch, commandSearch, applySearchScope, taskOverlayHistory + ).load(); qs('#open-palette').addEventListener('click', openCommandPalette); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore()); diff --git a/frontend/index.html b/frontend/index.html index 548fc12..ec70fc7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -451,6 +451,18 @@ +
+
+ Saved searches + +
+
+ + + +
+
+
@@ -1107,6 +1119,7 @@ + diff --git a/frontend/saved-searches.js b/frontend/saved-searches.js new file mode 100644 index 0000000..5206111 --- /dev/null +++ b/frontend/saved-searches.js @@ -0,0 +1,159 @@ +(function (root, factory) { + const createSavedSearches = factory(); + if (typeof module === 'object' && module.exports) module.exports = createSavedSearches; + if (root) root.createSavedSearches = createSavedSearches; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function createSavedSearches(options) { + const fetchJson = options.fetchJson; + const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, '')); + const onOpen = options.onOpen; + const onState = options.onState || (() => {}); + let current = { revision:0, views:[] }; + + function snapshot() { + return { revision:current.revision, views:current.views.map(view => ({ ...view })) }; + } + + function publish(status, message) { + onState({ ...snapshot(), status, ...(message ? { message } : {}) }); + } + + function normalizeSearch(name, search, id) { + const cleanName = String(name || '').trim(); + const query = String(search?.query || '').trim(); + if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.'); + if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.'); + return { + id, name:cleanName, query, + kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all', + state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all', + repository:String(search?.repository || '').trim(), + }; + } + + async function replace(views) { + publish('saving'); + try { + current = await fetchJson('api/v1/saved-searches', { + method:'PUT', headers:{ 'Content-Type':'application/json' }, + body:JSON.stringify({ revision:current.revision, views }), + }); + publish('ready'); + return snapshot(); + } catch (error) { + const conflict = error?.status === 409 && error?.payload?.detail?.snapshot; + if (conflict) { + current = conflict; + publish('conflict', 'Saved searches changed on another device.'); + } else { + publish('error', 'Saved searches could not sync. Search still works.'); + } + throw error; + } + } + + return { + async load() { + publish('loading'); + try { + current = await fetchJson('api/v1/saved-searches'); + publish('ready'); + } catch (error) { + publish('error', 'Saved searches could not load. Search still works.'); + } + return snapshot(); + }, + save(name, search) { + if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.')); + const created = normalizeSearch(name, search, createId()); + return replace(current.views.concat(created)); + }, + rename(id, name) { + const existing = current.views.find(view => view.id === id); + if (!existing) return Promise.reject(new Error('Saved search no longer exists.')); + const changed = normalizeSearch(name, existing, existing.id); + return replace(current.views.map(view => view.id === id ? changed : view)); + }, + remove(id) { + if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot()); + return replace(current.views.filter(view => view.id !== id)); + }, + open(id) { + const selected = current.views.find(view => view.id === id); + if (selected) onOpen({ ...selected }); + }, + snapshot, + }; + } + + createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) { + const query = selector => document.querySelector(selector); + const input = query('#cmd-input'); + const request = async (url, init) => { + const response = await fetch(url, init); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.'); + error.status = response.status; + error.payload = payload; + throw error; + } + return payload; + }; + let controller; + function render(state) { + const list = query('#saved-search-list'); + const status = query('#saved-search-status'); + list.replaceChildren(); + status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'', + conflict:state.message, error:state.message})[state.status] || ''; + state.views.forEach(view => { + const row = document.createElement('div'); + row.className = 'saved-search-row'; + const open = document.createElement('button'); + open.type = 'button'; open.className = 'saved-search-open saved-search-action'; + open.textContent = view.name; open.title = view.query; + open.addEventListener('click', () => controller.open(view.id)); + const actions = document.createElement('div'); + actions.className = 'saved-search-row-actions'; + for (const action of ['Rename', 'Delete']) { + const button = document.createElement('button'); + button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action; + button.setAttribute('aria-label', action + ' ' + view.name); + button.addEventListener('click', async () => { + if (action === 'Rename') { + const name = globalThis.prompt('Rename saved search', view.name); + if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {}); + } else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) { + await controller.remove(view.id).catch(() => {}); + } + }); + actions.append(button); + } + row.append(open, actions); list.append(row); + }); + } + controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => { + search.setQuery(''); + input.value = view.query; + applyScope(view); + search.setQuery(view.query); + history.update({query:view.query, scope:view}); + input.focus(); + }}); + query('#save-current-search').addEventListener('click', async () => { + const name = query('#saved-search-name').value.trim(); + const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value, + state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()}; + if (!name || current.query.length < 2) { + query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.'; + return; + } + await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; }) + .catch(error => { query('#saved-search-status').textContent = error.message; }); + }); + return controller; + }; + + return createSavedSearches; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0df4b65..59a6e45 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -17,6 +17,7 @@ const SHELL = [ BASE + 'static/security-center.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', + BASE + 'static/saved-searches.js', BASE + 'static/search-preview.js', BASE + 'static/search-defer.js', BASE + 'static/widgets.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index e6d4124..0d240e7 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -28,7 +28,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/assign-and-start.js", "static/queue-today.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/src/main.py b/src/main.py index 1a2781a..945f7dd 100644 --- a/src/main.py +++ b/src/main.py @@ -57,6 +57,7 @@ from src.push_notifications import ( from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint from src.push_subscription_store import PushSubscriptionStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit +from src.saved_search_store import SavedSearchConflict, SavedSearchStore from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute from src.later_store import LaterStore @@ -617,6 +618,20 @@ class LaterOperationBatch(BaseModel): operations: list[LaterOperation] = Field(min_length=1, max_length=50) +class SavedSearchView(BaseModel): + id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$") + name: str = Field(min_length=1, max_length=60) + query: str = Field(min_length=2, max_length=200) + kind: Literal["all", "issue", "pull"] = "all" + state: Literal["all", "open", "closed"] = "all" + repository: str = Field(default="", max_length=200) + + +class SavedSearchCollection(BaseModel): + revision: int = Field(ge=0) + views: list[SavedSearchView] = Field(max_length=20) + + class NotificationLaterRequest(BaseModel): wake_at: str = Field(min_length=1, max_length=100) @@ -1183,7 +1198,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -2114,6 +2129,12 @@ def _later_store() -> LaterStore: ) +def _saved_search_store() -> SavedSearchStore: + return SavedSearchStore( + os.getenv("STACKCHAIN_SAVED_SEARCH_DB", str(_state_dir / "saved-searches.sqlite3")) + ) + + async def _confirmed_login() -> str: try: user = await asyncio.wait_for( @@ -2129,6 +2150,49 @@ async def _confirmed_login() -> str: return login.strip().lower() +@app.get("/api/v1/saved-searches") +async def get_saved_searches(response: Response): + login = await _confirmed_login() + try: + snapshot = await asyncio.to_thread(_saved_search_store().get, login) + except (OSError, sqlite3.Error): + raise HTTPException( + status_code=503, + detail="Saved Search synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + response.headers["Cache-Control"] = "no-store" + return snapshot + + +@app.put("/api/v1/saved-searches") +async def replace_saved_searches(payload: SavedSearchCollection): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _saved_search_store().replace, + login, + payload.revision, + [view.model_dump() for view in payload.views], + ) + except SavedSearchConflict as exc: + raise HTTPException( + status_code=409, + detail={ + "message": "Saved searches changed on another device.", + "snapshot": exc.snapshot, + }, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + except (OSError, sqlite3.Error): + raise HTTPException( + status_code=503, + detail="Saved Search synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/today") async def get_today_plan(): login = await _confirmed_login() diff --git a/src/saved_search_store.py b/src/saved_search_store.py new file mode 100644 index 0000000..617bc9a --- /dev/null +++ b/src/saved_search_store.py @@ -0,0 +1,130 @@ +"""Durable, account-scoped saved Search views.""" + +import json +import re +import sqlite3 +from pathlib import Path + + +_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + +class SavedSearchConflict(ValueError): + """Raised when a client attempts to replace a stale collection.""" + + def __init__(self, snapshot: dict): + super().__init__("saved searches changed on another device") + self.snapshot = snapshot + + +class SavedSearchStore: + def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0): + self.path = Path(path) + self.limit = limit + self.timeout = timeout + self._initialize() + + def _initialize(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.path, timeout=self.timeout) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS saved_searches ( + login TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + views TEXT NOT NULL + ) + """ + ) + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path, timeout=self.timeout) + + @staticmethod + def _login(login: str) -> str: + normalized = login.strip().lower() + if not normalized: + raise ValueError("login is required") + return normalized + + @staticmethod + def _snapshot(row) -> dict: + return {"revision": 0, "views": []} if row is None else { + "revision": int(row[0]), "views": json.loads(row[1]) + } + + def get(self, login: str) -> dict: + with self._connect() as connection: + row = connection.execute( + "SELECT revision, views FROM saved_searches WHERE login = ?", + (self._login(login),), + ).fetchone() + return self._snapshot(row) + + def _normalize(self, views: list[dict]) -> list[dict]: + if not isinstance(views, list): + raise ValueError("views must be a list") + if len(views) > self.limit: + raise ValueError(f"saved searches are limited to {self.limit}") + normalized = [] + seen = set() + for raw in views: + if not isinstance(raw, dict): + raise ValueError("saved search must be an object") + view_id = raw.get("id") + if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id): + raise ValueError("id is invalid") + if view_id in seen: + raise ValueError("saved search ids must be unique") + name = raw.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("name is required") + name = name.strip() + if len(name) > 60: + raise ValueError("name must be at most 60 characters") + query = raw.get("query") + if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200: + raise ValueError("query must be between 2 and 200 characters") + kind = raw.get("kind", "all") + state = raw.get("state", "all") + if kind not in {"all", "issue", "pull"}: + raise ValueError("kind is invalid") + if state not in {"all", "open", "closed"}: + raise ValueError("state is invalid") + repository = raw.get("repository", "") + if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)): + raise ValueError("repository is invalid") + seen.add(view_id) + normalized.append({ + "id": view_id, + "name": name, + "query": query.strip(), + "kind": kind, + "state": state, + "repository": repository, + }) + return normalized + + def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict: + login = self._login(login) + if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0: + raise ValueError("revision is invalid") + normalized = self._normalize(views) + serialized = json.dumps(normalized, separators=(",", ":")) + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT revision, views FROM saved_searches WHERE login = ?", (login,) + ).fetchone() + current = self._snapshot(row) + if current["revision"] != expected_revision: + raise SavedSearchConflict(current) + revision = expected_revision + 1 + connection.execute( + "INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) " + "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views", + (login, revision, serialized), + ) + return {"revision": revision, "views": normalized} diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 3355666..c3988a6 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -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 diff --git a/tests/test_saved_search_store.py b/tests/test_saved_search_store.py new file mode 100644 index 0000000..e0c037a --- /dev/null +++ b/tests/test_saved_search_store.py @@ -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" diff --git a/tests/test_saved_searches.py b/tests/test_saved_searches.py new file mode 100644 index 0000000..d475250 --- /dev/null +++ b/tests/test_saved_searches.py @@ -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 '
' 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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 07208ea..bc4417b 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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", -- 2.43.0