queryview 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. queryview/__init__.py +0 -0
  2. queryview/conftest.py +86 -0
  3. queryview/connect.py +428 -0
  4. queryview/dashboard_queries.py +75 -0
  5. queryview/dashboards.py +156 -0
  6. queryview/drivers/__init__.py +10 -0
  7. queryview/drivers/base.py +165 -0
  8. queryview/drivers/clickhouse.py +138 -0
  9. queryview/drivers/duckdb.py +153 -0
  10. queryview/drivers/postgres.py +166 -0
  11. queryview/drivers/test_base.py +49 -0
  12. queryview/drivers/test_clickhouse.py +53 -0
  13. queryview/drivers/test_contract.py +77 -0
  14. queryview/drivers/test_duckdb.py +80 -0
  15. queryview/drivers/test_postgres.py +80 -0
  16. queryview/gitsync.py +374 -0
  17. queryview/main.py +740 -0
  18. queryview/mcp_server.py +294 -0
  19. queryview/migrations/env.py +39 -0
  20. queryview/migrations/script.py.mako +29 -0
  21. queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
  22. queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
  23. queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
  24. queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
  25. queryview/queries.py +159 -0
  26. queryview/remote.py +141 -0
  27. queryview/static/assets/index-CvnC_D68.js +47 -0
  28. queryview/static/assets/index-Qe7bhycG.css +2 -0
  29. queryview/static/favicon.svg +1 -0
  30. queryview/static/index.html +14 -0
  31. queryview/test_api_db.py +51 -0
  32. queryview/test_api_export_import.py +85 -0
  33. queryview/test_api_gitsync.py +83 -0
  34. queryview/test_api_workspaces.py +44 -0
  35. queryview/test_connect_flow.py +123 -0
  36. queryview/test_connect_store.py +34 -0
  37. queryview/test_dashboards.py +216 -0
  38. queryview/test_gitsync.py +346 -0
  39. queryview/test_main.py +18 -0
  40. queryview/test_mcp_gitsync.py +72 -0
  41. queryview/test_migrations.py +99 -0
  42. queryview/test_queries.py +170 -0
  43. queryview/test_remote.py +260 -0
  44. queryview/test_validation.py +87 -0
  45. queryview/test_workspaces.py +109 -0
  46. queryview/test_yamlio.py +198 -0
  47. queryview/validation.py +111 -0
  48. queryview/workspaces.py +167 -0
  49. queryview/yamlio.py +245 -0
  50. queryview-0.0.2.dist-info/METADATA +183 -0
  51. queryview-0.0.2.dist-info/RECORD +54 -0
  52. queryview-0.0.2.dist-info/WHEEL +4 -0
  53. queryview-0.0.2.dist-info/entry_points.txt +3 -0
  54. queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,216 @@
1
+ """Tests for the dashboard store and its REST surface. These don't need a real
2
+ ClickHouse: the store is pure SQLite, and route tests exercise validation plus
3
+ the persist/push path (the runner's happy/bad ClickHouse paths live in e2e)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+
9
+ from fastapi.testclient import TestClient
10
+
11
+ from queryview import remote
12
+ from queryview.dashboards import (
13
+ _upsert_and_push,
14
+ get_dashboard,
15
+ list_dashboards,
16
+ upsert_dashboard,
17
+ )
18
+ from queryview.main import app
19
+
20
+
21
+ def _run(coro):
22
+ return asyncio.run(coro)
23
+
24
+
25
+ def test_upsert_creates_then_updates_by_name(default_ws_id):
26
+ _run(upsert_dashboard("d1", "conn-a", "<h1>v1</h1>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
27
+ got = _run(get_dashboard("d1", default_ws_id))
28
+ assert got is not None
29
+ assert got["connection"] == "conn-a"
30
+ assert got["html"] == "<h1>v1</h1>"
31
+ assert got["queries"] == {"q": "SELECT 1"}
32
+
33
+ _run(upsert_dashboard("d1", "conn-b", "<h1>v2</h1>", {"q": "SELECT 2"}, workspace_id=default_ws_id))
34
+ got = _run(get_dashboard("d1", default_ws_id))
35
+ assert got is not None
36
+ assert got["connection"] == "conn-b"
37
+ assert got["html"] == "<h1>v2</h1>"
38
+ assert got["queries"] == {"q": "SELECT 2"}
39
+
40
+
41
+ def test_get_dashboard_round_trips_queries_dict(default_ws_id):
42
+ queries = {"sales": "SELECT * FROM sales", "users": "SELECT count() FROM users"}
43
+ _run(upsert_dashboard("multi", "c", "<div></div>", queries, workspace_id=default_ws_id))
44
+ got = _run(get_dashboard("multi", default_ws_id))
45
+ assert got is not None
46
+ assert got["queries"] == queries
47
+
48
+
49
+ def test_get_missing_dashboard_is_none(default_ws_id):
50
+ assert _run(get_dashboard("does-not-exist", default_ws_id)) is None
51
+
52
+
53
+ def test_list_dashboards_orders_by_name_and_omits_payload(default_ws_id):
54
+ _run(upsert_dashboard("zeta", "c", "<i></i>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
55
+ _run(upsert_dashboard("alpha", "c", "<i></i>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
56
+ names = [d["name"] for d in _run(list_dashboards(default_ws_id))]
57
+ assert names.index("alpha") < names.index("zeta")
58
+ row = next(d for d in _run(list_dashboards(default_ws_id)) if d["name"] == "alpha")
59
+ assert set(row) == {"name", "connection", "updated_at"}
60
+
61
+
62
+ def test_upsert_and_push_persists_without_session(default_ws_id):
63
+ persisted, pushed, _ = _run(
64
+ _upsert_and_push("np", "c", "<p></p>", {"q": "SELECT 1"}, None, workspace_id=default_ws_id)
65
+ )
66
+ assert persisted is True and pushed is False
67
+ assert _run(get_dashboard("np", default_ws_id)) is not None
68
+
69
+
70
+ def test_upsert_and_push_delivers_to_registered_session(default_ws_id):
71
+ rid = remote.register()
72
+ try:
73
+ persisted, pushed, _ = _run(
74
+ _upsert_and_push("pushed", "c", "<p>x</p>", {"q": "SELECT 1"}, rid, workspace_id=default_ws_id)
75
+ )
76
+ assert persisted is True and pushed is True
77
+ msg = _run(remote.next_message(rid, 1.0))
78
+ assert msg is not None
79
+ assert msg["type"] == "dashboard"
80
+ assert msg["name"] == "pushed"
81
+ assert msg["connection"] == "c"
82
+ assert msg["html"] == "<p>x</p>"
83
+ assert msg["queries"] == {"q": "SELECT 1"}
84
+ finally:
85
+ remote.unregister(rid)
86
+
87
+
88
+ # --- REST surface ---------------------------------------------------------
89
+
90
+
91
+ def test_runqueries_requires_connection_and_queries():
92
+ client = TestClient(app)
93
+ assert client.post("/api/runqueries", json={"queries": {"q": "SELECT 1"}}).status_code == 400
94
+ assert client.post("/api/runqueries", json={"connection": "c"}).status_code == 400
95
+ assert client.post("/api/runqueries", json={"connection": "c", "queries": {}}).status_code == 400
96
+
97
+
98
+ def test_runqueries_unknown_connection_returns_404():
99
+ client = TestClient(app)
100
+ r = client.post(
101
+ "/api/runqueries",
102
+ json={"connection": "no-such-connection", "queries": {"q": "SELECT 1"}},
103
+ )
104
+ assert r.status_code == 404
105
+ assert r.json()["ok"] is False
106
+ assert "no connection" in r.json()["message"].lower()
107
+
108
+
109
+ def test_dashboards_upsert_requires_fields():
110
+ client = TestClient(app)
111
+ r = client.post("/api/dashboards", json={"name": "x", "connection": "c"})
112
+ assert r.status_code == 400
113
+
114
+
115
+ def test_dashboards_upsert_persists_and_lists_and_gets():
116
+ client = TestClient(app)
117
+ r = client.post(
118
+ "/api/dashboards",
119
+ json={
120
+ "name": "rest-dash",
121
+ "connection": "c",
122
+ "html": "<h1>hi</h1>",
123
+ "queries": {"q": "SELECT 1"},
124
+ },
125
+ )
126
+ assert r.status_code == 200
127
+ body = r.json()
128
+ assert body["persisted"] is True and body["pushed"] is False
129
+
130
+ listed = client.get("/api/dashboards").json()["dashboards"]
131
+ assert any(d["name"] == "rest-dash" for d in listed)
132
+
133
+ got = client.get("/api/dashboards/rest-dash").json()
134
+ assert got["html"] == "<h1>hi</h1>"
135
+ assert got["queries"] == {"q": "SELECT 1"}
136
+
137
+
138
+ def test_dashboards_get_missing_returns_404():
139
+ client = TestClient(app)
140
+ assert client.get("/api/dashboards/missing-one").status_code == 404
141
+
142
+
143
+ def test_dashboards_upsert_pushes_to_registered_session():
144
+ rid = remote.register()
145
+ try:
146
+ client = TestClient(app)
147
+ r = client.post(
148
+ "/api/dashboards",
149
+ json={
150
+ "session_id": rid,
151
+ "name": "live-dash",
152
+ "connection": "c",
153
+ "html": "<p></p>",
154
+ "queries": {"q": "SELECT 1"},
155
+ },
156
+ )
157
+ assert r.json()["pushed"] is True
158
+ msg = _run(remote.next_message(rid, 1.0))
159
+ assert msg is not None
160
+ assert msg["type"] == "dashboard" and msg["name"] == "live-dash"
161
+ finally:
162
+ remote.unregister(rid)
163
+
164
+
165
+ def test_mcp_upsert_dashboard_pushes_draft_without_persisting(default_ws_id):
166
+ from queryview.mcp_server import push_dashboard as mcp_push
167
+
168
+ rid = remote.register()
169
+ try:
170
+ out = _run(mcp_push(rid, "draftdash", "c", "<p>d</p>", {"q": "SELECT 1"}))
171
+ assert out["pushed"] is True
172
+ # Draft: the agent push must NOT persist — only the user's Save does.
173
+ assert _run(get_dashboard("draftdash", default_ws_id)) is None
174
+ msg = _run(remote.next_message(rid, 1.0))
175
+ assert msg is not None
176
+ assert msg["type"] == "dashboard" and msg["name"] == "draftdash"
177
+ finally:
178
+ remote.unregister(rid)
179
+
180
+
181
+ def test_dashboard_names_distinct_per_workspace(default_ws_id):
182
+ from queryview.workspaces import create_workspace, resolve
183
+
184
+ _run(create_workspace("t4-iso"))
185
+ other = _run(resolve("t4-iso")).id
186
+ _run(upsert_dashboard("iso d", "prod", "<html>1</html>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
187
+ _run(upsert_dashboard("iso d", "prod", "<html>2</html>", {"q": "SELECT 2"}, workspace_id=other))
188
+
189
+ d_default = _run(get_dashboard("iso d", default_ws_id))
190
+ d_other = _run(get_dashboard("iso d", other))
191
+ assert d_default is not None and d_default["html"] == "<html>1</html>"
192
+ assert d_other is not None and d_other["html"] == "<html>2</html>"
193
+ assert "iso d" in [d["name"] for d in _run(list_dashboards(default_ws_id))]
194
+ assert "iso d" in [d["name"] for d in _run(list_dashboards(other))]
195
+
196
+
197
+ def test_mcp_list_dashboards_scopes_by_session_workspace(default_ws_id):
198
+ from queryview.mcp_server import list_dashboards as mcp_list
199
+ from queryview.workspaces import create_workspace, resolve
200
+
201
+ _run(create_workspace("t-mcp-ld"))
202
+ other = _run(resolve("t-mcp-ld")).id
203
+ _run(upsert_dashboard("mcp ld", "c", "<i></i>", {}, workspace_id=other))
204
+
205
+ # No session_id -> default workspace: the other workspace's dashboard is absent.
206
+ names = [d["name"] for d in _run(mcp_list())["dashboards"]]
207
+ assert "mcp ld" not in names
208
+
209
+ # A session reporting the other workspace sees it.
210
+ rid = remote.register()
211
+ try:
212
+ remote.set_session_workspace(rid, "t-mcp-ld")
213
+ names = [d["name"] for d in _run(mcp_list(session_id=rid))["dashboards"]]
214
+ assert names == ["mcp ld"]
215
+ finally:
216
+ remote.unregister(rid)
@@ -0,0 +1,346 @@
1
+ """Git sync: serializer round-trips (queries/dashboards <-> repo files) and,
2
+ in later tests, store/history/restore against a local bare repo (no network)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import subprocess
8
+
9
+ import pytest
10
+
11
+ from queryview import gitsync
12
+ from queryview.gitsync import (
13
+ GitSyncError,
14
+ dashboard_from_files,
15
+ dashboard_reldir,
16
+ dashboard_to_files,
17
+ query_from_yaml,
18
+ query_relpath,
19
+ query_to_yaml,
20
+ )
21
+
22
+
23
+ def _run(coro):
24
+ return asyncio.run(coro)
25
+
26
+
27
+ def test_relpaths():
28
+ assert query_relpath("clickhouse", "top errors") == "queries/clickhouse/top errors.yaml"
29
+ assert dashboard_reldir("sales/eu") == "dashboards/sales%2Feu"
30
+
31
+
32
+ def test_query_yaml_round_trip():
33
+ row = {
34
+ "query_name": "top errors",
35
+ "query": "SELECT *\nFROM errors\nORDER BY n DESC",
36
+ "cell_view": "cve_id:\n type: link\n value: https://x/{cell}\n",
37
+ "order_by": '[{"name": "n", "dir": "DESC"}]',
38
+ "fields": '["cve_id", "n"]',
39
+ }
40
+ text = query_to_yaml(row)
41
+ assert "SELECT *" in text # multiline SQL is a readable block, not \n escapes
42
+ assert query_from_yaml(text) == row
43
+
44
+
45
+ def test_query_yaml_omits_and_restores_none_fields():
46
+ row = {
47
+ "query_name": "plain",
48
+ "query": "SELECT 1",
49
+ "cell_view": None,
50
+ "order_by": None,
51
+ "fields": None,
52
+ }
53
+ text = query_to_yaml(row)
54
+ assert "cell_view" not in text
55
+ assert query_from_yaml(text) == row
56
+
57
+
58
+ def test_query_from_yaml_rejects_malformed():
59
+ with pytest.raises(GitSyncError):
60
+ query_from_yaml("just a scalar")
61
+ with pytest.raises(GitSyncError):
62
+ query_from_yaml("query_name: x\n") # no query
63
+
64
+
65
+ def test_dashboard_files_round_trip():
66
+ d = {
67
+ "name": "sales",
68
+ "connection": "prod",
69
+ "html": "<html>\n<body>hi — ünicode</body>\n</html>",
70
+ "queries": {"revenue": "SELECT 1", "multi": "SELECT a\nFROM b"},
71
+ }
72
+ files = dashboard_to_files(d)
73
+ assert set(files) == {"meta.yaml", "dashboard.html", "queries.yaml"}
74
+ assert files["dashboard.html"] == d["html"] # verbatim
75
+ assert dashboard_from_files(files) == d
76
+
77
+
78
+ def test_dashboard_from_files_rejects_malformed_meta():
79
+ with pytest.raises(GitSyncError):
80
+ dashboard_from_files({"meta.yaml": "connection: x", "dashboard.html": "", "queries.yaml": ""})
81
+
82
+
83
+ # The git_env fixture (bare repo + GIT_SYNC_* env vars) lives in conftest.py.
84
+
85
+
86
+ def _default_ws_id() -> int:
87
+ from queryview.workspaces import DEFAULT_WORKSPACE, resolve
88
+
89
+ return _run(resolve(DEFAULT_WORKSPACE)).id
90
+
91
+
92
+ def _default_ws():
93
+ """The default workspace, re-resolved so per-test remote changes are seen."""
94
+ from queryview.workspaces import DEFAULT_WORKSPACE, resolve
95
+
96
+ return _run(resolve(DEFAULT_WORKSPACE))
97
+
98
+
99
+ def _remote_log(remote) -> str:
100
+ return subprocess.run(
101
+ ["git", "log", "--format=%H %s", "main"],
102
+ cwd=remote,
103
+ check=True,
104
+ capture_output=True,
105
+ text=True,
106
+ ).stdout
107
+
108
+
109
+ def test_store_unconfigured_is_409():
110
+ # Outside git_env the default workspace has no remote configured.
111
+ with pytest.raises(GitSyncError) as e:
112
+ _run(gitsync.store(_default_ws(), "query", "anything", "clickhouse"))
113
+ assert e.value.status == 409
114
+ assert "no git remote" in str(e.value)
115
+
116
+
117
+ def test_unknown_kind_is_400():
118
+ with pytest.raises(GitSyncError) as e:
119
+ _run(gitsync.store(_default_ws(), "quert", "x", "clickhouse"))
120
+ assert e.value.status == 400
121
+ assert "kind" in str(e.value)
122
+
123
+
124
+ def test_query_without_conn_type_is_400():
125
+ with pytest.raises(GitSyncError) as e:
126
+ _run(gitsync.history(_default_ws(), "query", "x"))
127
+ assert e.value.status == 400
128
+ assert "conn_type" in str(e.value)
129
+
130
+
131
+ def test_store_missing_entity_is_404(git_env):
132
+ with pytest.raises(GitSyncError) as e:
133
+ _run(gitsync.store(_default_ws(), "query", "no such query xyz", "clickhouse"))
134
+ assert e.value.status == 404
135
+
136
+
137
+ def test_store_query_commits_and_pushes(git_env):
138
+ from queryview.queries import save_predefined_query
139
+
140
+ _run(save_predefined_query("gs top", "clickhouse", "SELECT 1", workspace_id=_default_ws_id()))
141
+ r = _run(gitsync.store(_default_ws(), "query", "gs top", "clickhouse"))
142
+ assert r["committed"] is True and r["sha"]
143
+ log = _remote_log(git_env)
144
+ assert r["sha"] in log
145
+ assert "store query clickhouse/gs top" in log
146
+
147
+
148
+ def test_store_no_change_makes_no_commit(git_env):
149
+ from queryview.queries import save_predefined_query
150
+
151
+ _run(save_predefined_query("gs same", "clickhouse", "SELECT 2", workspace_id=_default_ws_id()))
152
+ r1 = _run(gitsync.store(_default_ws(), "query", "gs same", "clickhouse"))
153
+ r2 = _run(gitsync.store(_default_ws(), "query", "gs same", "clickhouse"))
154
+ assert r1["committed"] is True
155
+ assert r2 == {"committed": False, "sha": None, "message": "no changes"}
156
+ assert _remote_log(git_env).count("\n") == 1 # exactly one commit
157
+
158
+
159
+ def test_store_dashboard_touches_only_its_dir(git_env):
160
+ from queryview.dashboards import upsert_dashboard
161
+
162
+ _run(upsert_dashboard("gs dash", "prod", "<html>v1</html>", {"q": "SELECT 1"}, workspace_id=_default_ws_id()))
163
+ r = _run(gitsync.store(_default_ws(), "dashboard", "gs dash"))
164
+ assert r["committed"] is True
165
+ out = subprocess.run(
166
+ ["git", "show", "--name-only", "--format=", r["sha"]],
167
+ cwd=git_env,
168
+ check=True,
169
+ capture_output=True,
170
+ text=True,
171
+ ).stdout
172
+ files = [line for line in out.splitlines() if line.strip()]
173
+ assert files and all(f.startswith("dashboards/gs dash/") for f in files)
174
+ assert set(files) == {
175
+ "dashboards/gs dash/meta.yaml",
176
+ "dashboards/gs dash/dashboard.html",
177
+ "dashboards/gs dash/queries.yaml",
178
+ }
179
+
180
+
181
+ def test_store_custom_message(git_env):
182
+ from queryview.queries import save_predefined_query
183
+
184
+ _run(save_predefined_query("gs msg", "clickhouse", "SELECT 3", workspace_id=_default_ws_id()))
185
+ _run(gitsync.store(_default_ws(), "query", "gs msg", "clickhouse", message="before migration"))
186
+ assert "before migration" in _remote_log(git_env)
187
+
188
+
189
+ def test_store_unreachable_remote_raises_without_init(tmp_path, monkeypatch):
190
+ from queryview.queries import save_predefined_query
191
+ from queryview.workspaces import DEFAULT_WORKSPACE, update_workspace
192
+
193
+ monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
194
+ _run(update_workspace(DEFAULT_WORKSPACE, remote=str(tmp_path / "does-not-exist.git")))
195
+ try:
196
+ _run(save_predefined_query("gs unreachable", "clickhouse", "SELECT 1", workspace_id=_default_ws_id()))
197
+ ws = _default_ws()
198
+ with pytest.raises(GitSyncError) as e:
199
+ _run(gitsync.store(ws, "query", "gs unreachable", "clickhouse"))
200
+ assert e.value.status == 502
201
+ # No spurious local repo in this workspace's clone dir.
202
+ assert not (tmp_path / "clones" / str(ws.id) / ".git").exists()
203
+ finally:
204
+ _run(update_workspace(DEFAULT_WORKSPACE, remote=None))
205
+
206
+
207
+ def _clone_head() -> str:
208
+ import os
209
+
210
+ wd = os.path.join(os.environ["GIT_SYNC_DIR"], str(_default_ws_id()))
211
+ return subprocess.run(
212
+ ["git", "rev-parse", "HEAD"],
213
+ cwd=wd,
214
+ check=True,
215
+ capture_output=True,
216
+ text=True,
217
+ ).stdout.strip()
218
+
219
+
220
+ def _store_query_versions(name: str, sqls: list[str]) -> list[str]:
221
+ """Save+store the query once per SQL; returns the commit shas in order."""
222
+ from queryview.queries import save_predefined_query
223
+
224
+ shas = []
225
+ for sql in sqls:
226
+ _run(save_predefined_query(name, "clickhouse", sql, workspace_id=_default_ws_id()))
227
+ shas.append(_run(gitsync.store(_default_ws(), "query", name, "clickhouse"))["sha"])
228
+ return shas
229
+
230
+
231
+ def test_history_pages_newest_first(git_env):
232
+ shas = _store_query_versions("gs hist", ["SELECT 1", "SELECT 2", "SELECT 3"])
233
+ page1 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", limit=2))
234
+ assert [r["sha"] for r in page1["revisions"]] == [shas[2], shas[1]]
235
+ assert page1["has_more"] is True
236
+ assert all(isinstance(r["date"], int) and r["message"] for r in page1["revisions"])
237
+ page2 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", before=shas[1], limit=2))
238
+ assert [r["sha"] for r in page2["revisions"]] == [shas[0]]
239
+ assert page2["has_more"] is False
240
+ # Paging past the oldest commit yields an empty page, not an error.
241
+ page3 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", before=shas[0], limit=2))
242
+ assert page3 == {"revisions": [], "has_more": False}
243
+
244
+
245
+ def test_history_garbage_before_is_404(git_env):
246
+ _store_query_versions("gs badbefore", ["SELECT 1"])
247
+ with pytest.raises(GitSyncError) as e:
248
+ _run(gitsync.history(_default_ws(), "query", "gs badbefore", "clickhouse", before="not-a-sha"))
249
+ assert e.value.status == 404
250
+
251
+
252
+ def test_history_only_sees_own_entity(git_env):
253
+ _store_query_versions("gs mine", ["SELECT 1"])
254
+ _store_query_versions("gs other", ["SELECT 9"])
255
+ h = _run(gitsync.history(_default_ws(), "query", "gs mine", "clickhouse"))
256
+ assert len(h["revisions"]) == 1
257
+
258
+
259
+ def test_restore_old_version_overwrites_db_without_moving_head(git_env):
260
+ from queryview.queries import list_predefined_queries
261
+
262
+ shas = _store_query_versions("gs restore", ["SELECT 1", "SELECT 2"])
263
+ head_before = _clone_head()
264
+ r = _run(gitsync.restore(_default_ws(), "query", "gs restore", "clickhouse", ref=shas[0]))
265
+ assert r["restored"] is True
266
+ rows = _run(list_predefined_queries("clickhouse", _default_ws_id()))
267
+ row = next(x for x in rows if x["query_name"] == "gs restore")
268
+ assert row["query"] == "SELECT 1"
269
+ assert _clone_head() == head_before # HEAD never moves
270
+
271
+
272
+ def test_restore_default_ref_is_remote_head(git_env):
273
+ from queryview.queries import list_predefined_queries, save_predefined_query
274
+
275
+ _store_query_versions("gs latest", ["SELECT 1", "SELECT 2"])
276
+ _run(save_predefined_query("gs latest", "clickhouse", "SELECT 999", workspace_id=_default_ws_id())) # local drift
277
+ _run(gitsync.restore(_default_ws(), "query", "gs latest", "clickhouse"))
278
+ rows = _run(list_predefined_queries("clickhouse", _default_ws_id()))
279
+ row = next(x for x in rows if x["query_name"] == "gs latest")
280
+ assert row["query"] == "SELECT 2"
281
+
282
+
283
+ def test_restore_dashboard_round_trip(git_env):
284
+ from queryview.dashboards import get_dashboard, upsert_dashboard
285
+
286
+ _run(upsert_dashboard("gs rdash", "prod", "<html>v1</html>", {"q": "SELECT 1"}, workspace_id=_default_ws_id()))
287
+ _run(gitsync.store(_default_ws(), "dashboard", "gs rdash"))
288
+ _run(upsert_dashboard("gs rdash", "other", "<html>v2</html>", {"q": "SELECT 2"}, workspace_id=_default_ws_id()))
289
+ _run(gitsync.restore(_default_ws(), "dashboard", "gs rdash"))
290
+ d = _run(get_dashboard("gs rdash", _default_ws_id()))
291
+ assert d == {
292
+ "name": "gs rdash",
293
+ "connection": "prod",
294
+ "html": "<html>v1</html>",
295
+ "queries": {"q": "SELECT 1"},
296
+ }
297
+
298
+
299
+ def test_restore_missing_at_ref_is_404(git_env):
300
+ _store_query_versions("gs exists", ["SELECT 1"])
301
+ with pytest.raises(GitSyncError) as e:
302
+ _run(gitsync.restore(_default_ws(), "query", "never stored", "clickhouse"))
303
+ assert e.value.status == 404
304
+
305
+
306
+ def _bare(tmp_path, name):
307
+ remote = tmp_path / name
308
+ subprocess.run(
309
+ ["git", "init", "--bare", "-b", "main", str(remote)],
310
+ check=True,
311
+ capture_output=True,
312
+ )
313
+ return remote
314
+
315
+
316
+ def test_store_is_isolated_per_workspace(tmp_path, monkeypatch):
317
+ """A commit in workspace A lands only in A's remote; B's stays empty; a
318
+ workspace without a remote is 409."""
319
+ from queryview.queries import save_predefined_query
320
+ from queryview.workspaces import create_workspace, resolve
321
+
322
+ monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
323
+ ra, rb = _bare(tmp_path, "a.git"), _bare(tmp_path, "b.git")
324
+ _run(create_workspace("t5-a", remote=str(ra)))
325
+ _run(create_workspace("t5-b", remote=str(rb)))
326
+ _run(create_workspace("t5-none"))
327
+ wa, wb = _run(resolve("t5-a")), _run(resolve("t5-b"))
328
+
329
+ _run(save_predefined_query("iso", "clickhouse", "SELECT 1", workspace_id=wa.id))
330
+ r = _run(gitsync.store(wa, "query", "iso", "clickhouse"))
331
+ assert r["committed"] is True
332
+ assert "store query clickhouse/iso" in _remote_log(ra)
333
+ # B's remote has no commits at all (git log fails on an empty bare repo).
334
+ p = subprocess.run(["git", "log", "--format=%H", "main"], cwd=rb, capture_output=True, text=True)
335
+ assert p.returncode != 0
336
+
337
+ # And B's history for the same entity name is empty, not A's history.
338
+ _run(save_predefined_query("iso", "clickhouse", "SELECT 2", workspace_id=wb.id))
339
+ h = _run(gitsync.history(wb, "query", "iso", "clickhouse"))
340
+ assert h["revisions"] == []
341
+
342
+ wn = _run(resolve("t5-none"))
343
+ with pytest.raises(GitSyncError) as e:
344
+ _run(gitsync.store(wn, "query", "iso", "clickhouse"))
345
+ assert e.value.status == 409
346
+ assert "no git remote" in str(e.value)
queryview/test_main.py ADDED
@@ -0,0 +1,18 @@
1
+ """Tests for the console-script entry point (port resolution)."""
2
+
3
+ from queryview import main
4
+
5
+
6
+ def test_port_flag_beats_env(monkeypatch):
7
+ monkeypatch.setenv("PORT", "7777")
8
+ assert main._resolve_port(["--port", "9000"]) == 9000
9
+
10
+
11
+ def test_port_env_used_without_flag(monkeypatch):
12
+ monkeypatch.setenv("PORT", "7777")
13
+ assert main._resolve_port([]) == 7777
14
+
15
+
16
+ def test_port_defaults_to_8000(monkeypatch):
17
+ monkeypatch.delenv("PORT", raising=False)
18
+ assert main._resolve_port([]) == 8000
@@ -0,0 +1,72 @@
1
+ """The git-sync MCP tools: registration and delegation to gitsync (errors come
2
+ back as {ok: False} rather than raising, matching the other tools)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+
8
+
9
+ def _run(coro):
10
+ return asyncio.run(coro)
11
+
12
+
13
+ def test_git_tools_are_registered():
14
+ from queryview.mcp_server import mcp
15
+
16
+ names = {t.name for t in _run(mcp.list_tools())}
17
+ assert {"git_store", "git_history", "git_restore"} <= names
18
+
19
+
20
+ def test_git_restore_tool_reports_unconfigured(monkeypatch):
21
+ monkeypatch.delenv("GIT_SYNC_REMOTE", raising=False)
22
+ from queryview.mcp_server import git_restore
23
+
24
+ r = _run(git_restore("dashboard", "x"))
25
+ assert r["ok"] is False
26
+
27
+
28
+ def test_git_store_resolves_workspace_from_session(tmp_path, monkeypatch):
29
+ """An armed session on workspace B routes git_store to B's remote; no
30
+ session_id falls back to the default workspace."""
31
+ import subprocess
32
+
33
+ from queryview import remote
34
+ from queryview.mcp_server import git_store
35
+ from queryview.queries import save_predefined_query
36
+ from queryview.workspaces import create_workspace, resolve
37
+
38
+ monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
39
+ rb = tmp_path / "b.git"
40
+ subprocess.run(
41
+ ["git", "init", "--bare", "-b", "main", str(rb)],
42
+ check=True,
43
+ capture_output=True,
44
+ )
45
+ _run(create_workspace("t7-b", remote=str(rb)))
46
+ wid = _run(resolve("t7-b")).id
47
+ _run(save_predefined_query("sess q", "clickhouse", "SELECT 1", workspace_id=wid))
48
+
49
+ rid = remote.register()
50
+ try:
51
+ remote.set_session_workspace(rid, "t7-b")
52
+ r = _run(git_store("query", "sess q", "clickhouse", session_id=rid))
53
+ assert r["ok"] is True and r["committed"] is True
54
+ log = subprocess.run(
55
+ ["git", "log", "--format=%s", "main"],
56
+ cwd=rb,
57
+ check=True,
58
+ capture_output=True,
59
+ text=True,
60
+ ).stdout
61
+ assert "sess q" in log
62
+ finally:
63
+ remote.unregister(rid)
64
+
65
+
66
+ def test_git_store_without_session_uses_default_workspace():
67
+ from queryview.mcp_server import git_store
68
+
69
+ # Outside git_env the default workspace has no remote -> unconfigured.
70
+ r = _run(git_store("query", "anything", "clickhouse"))
71
+ assert r["ok"] is False
72
+ assert "no git remote" in r["message"]