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,99 @@
1
+ """Alembic owns the schema: a fresh DB must be migrated to head (all three
2
+ tables present and stamped in alembic_version), not built by create_all."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import os
8
+ import sqlite3
9
+
10
+ from queryview.connect import _ensure_schema
11
+
12
+
13
+ def _run(coro):
14
+ return asyncio.run(coro)
15
+
16
+
17
+ def test_fresh_db_is_migrated_to_head():
18
+ _run(_ensure_schema())
19
+
20
+ con = sqlite3.connect(os.environ["DB_PATH"])
21
+ try:
22
+ names = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
23
+ # Alembic ran (not create_all): the version table exists alongside the
24
+ # three application tables.
25
+ assert {
26
+ "connections",
27
+ "predefined_queries",
28
+ "dashboards",
29
+ "workspaces",
30
+ "alembic_version",
31
+ } <= names, f"missing tables, got {sorted(names)}"
32
+
33
+ versions = [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
34
+ finally:
35
+ con.close()
36
+
37
+ assert len(versions) == 1 and versions[0], versions
38
+
39
+ # The stamped revision is the latest in the migration tree.
40
+ from alembic.script import ScriptDirectory
41
+
42
+ from queryview.connect import _alembic_config
43
+
44
+ head = ScriptDirectory.from_config(_alembic_config()).get_current_head()
45
+ assert versions[0] == head, f"stamped {versions[0]} != head {head}"
46
+
47
+
48
+ def test_config_blob_migration_backfills_existing_clickhouse_row(tmp_path, monkeypatch):
49
+ """A row written at the pre-blob revision is rewrapped into an encrypted
50
+ JSON config that decrypts back to the original host/port/user/password.
51
+ Runs on a private DB: downgrading the shared session DB below the
52
+ workspaces revision would collide on the restored global-unique names."""
53
+ import json
54
+ import sqlite3
55
+
56
+ from alembic import command
57
+
58
+ import queryview.connect as _c
59
+ from queryview.connect import _alembic_config, _db_path, _decrypt_str, _encrypt_str
60
+
61
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "blob.db"))
62
+ monkeypatch.setenv("DB_KEY_PATH", str(tmp_path / "blob.db.key"))
63
+ monkeypatch.setattr(_c, "_engine", None)
64
+ monkeypatch.setattr(_c, "_schema_ready", False)
65
+ monkeypatch.setattr(_c, "_key", None)
66
+
67
+ cfg = _alembic_config()
68
+ command.upgrade(cfg, "9a536b7c0328") # the per-column schema, built fresh
69
+
70
+ con = sqlite3.connect(_db_path())
71
+ try:
72
+ con.execute(
73
+ "INSERT INTO connections (name, type, host, port, username, password, "
74
+ "database, last_active_at) VALUES (?,?,?,?,?,?,?,?)",
75
+ ("legacy", "clickhouse", "h", 8123, "u", _encrypt_str("pw"), "db", 1),
76
+ )
77
+ con.commit()
78
+ finally:
79
+ con.close()
80
+
81
+ command.upgrade(cfg, "head")
82
+
83
+ con = sqlite3.connect(_db_path())
84
+ try:
85
+ blob = con.execute("SELECT config FROM connections WHERE name='legacy'").fetchone()[0]
86
+ finally:
87
+ con.close()
88
+ data = json.loads(_decrypt_str(blob))
89
+ assert data == {"host": "h", "port": 8123, "username": "u", "password": "pw"}
90
+
91
+
92
+ def test_predefined_queries_has_presentation_columns():
93
+ _run(_ensure_schema())
94
+ con = sqlite3.connect(os.environ["DB_PATH"])
95
+ try:
96
+ cols = {r[1] for r in con.execute("PRAGMA table_info(predefined_queries)")}
97
+ finally:
98
+ con.close()
99
+ assert {"order_by", "fields"} <= cols, f"missing columns, got {sorted(cols)}"
@@ -0,0 +1,170 @@
1
+ """Tests for the predefined-queries store: cell_view round-trips through
2
+ save/list, and workspace scoping."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+
8
+ from queryview.queries import (
9
+ get_predefined_query,
10
+ list_predefined_queries,
11
+ save_predefined_query,
12
+ )
13
+
14
+
15
+ def _run(coro):
16
+ return asyncio.run(coro)
17
+
18
+
19
+ def test_save_and_list_round_trips_cell_view(default_ws_id):
20
+ _run(
21
+ save_predefined_query(
22
+ "cves",
23
+ "clickhouse",
24
+ "SELECT cve_id FROM t",
25
+ cell_view="cve_id:\n type: link\n value: https://nvd.nist.gov/vuln/detail/{cell}\n",
26
+ workspace_id=default_ws_id,
27
+ )
28
+ )
29
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
30
+ row = next(r for r in rows if r["query_name"] == "cves")
31
+ assert row["query"] == "SELECT cve_id FROM t"
32
+ cell_view = row["cell_view"]
33
+ assert cell_view is not None
34
+ assert "nvd.nist.gov" in cell_view
35
+ assert "{cell}" in cell_view
36
+
37
+
38
+ def test_save_without_cell_view_lists_as_none(default_ws_id):
39
+ _run(save_predefined_query("plain", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
40
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
41
+ row = next(r for r in rows if r["query_name"] == "plain")
42
+ assert row["cell_view"] is None
43
+
44
+
45
+ def test_upsert_overwrites_cell_view(default_ws_id):
46
+ _run(
47
+ save_predefined_query(
48
+ "u",
49
+ "clickhouse",
50
+ "SELECT 1",
51
+ cell_view="a: {type: link, value: x}",
52
+ workspace_id=default_ws_id,
53
+ )
54
+ )
55
+ _run(
56
+ save_predefined_query(
57
+ "u",
58
+ "clickhouse",
59
+ "SELECT 1",
60
+ cell_view="b: {type: link, value: y}",
61
+ workspace_id=default_ws_id,
62
+ )
63
+ )
64
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
65
+ row = next(r for r in rows if r["query_name"] == "u")
66
+ cell_view = row["cell_view"]
67
+ assert cell_view is not None
68
+ assert "b:" in cell_view
69
+ assert "a:" not in cell_view
70
+
71
+
72
+ def test_clearing_cell_view_persists_null(default_ws_id):
73
+ _run(
74
+ save_predefined_query(
75
+ "c",
76
+ "clickhouse",
77
+ "SELECT 1",
78
+ cell_view="x: {type: link, value: y}",
79
+ workspace_id=default_ws_id,
80
+ )
81
+ )
82
+ _run(save_predefined_query("c", "clickhouse", "SELECT 1", cell_view=None, workspace_id=default_ws_id))
83
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
84
+ row = next(r for r in rows if r["query_name"] == "c")
85
+ assert row["cell_view"] is None
86
+
87
+
88
+ def test_order_by_and_fields_round_trip(default_ws_id):
89
+ _run(
90
+ save_predefined_query(
91
+ "q1",
92
+ "clickhouse",
93
+ "SELECT 1",
94
+ cell_view=None,
95
+ order_by='[{"name":"id","dir":"DESC"}]',
96
+ fields='["id","name"]',
97
+ workspace_id=default_ws_id,
98
+ )
99
+ )
100
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
101
+ row = next(r for r in rows if r["query_name"] == "q1")
102
+ assert row["order_by"] == '[{"name":"id","dir":"DESC"}]'
103
+ assert row["fields"] == '["id","name"]'
104
+
105
+
106
+ def test_null_presentation_is_preserved(default_ws_id):
107
+ _run(save_predefined_query("q2", "clickhouse", "SELECT 2", workspace_id=default_ws_id))
108
+ rows = _run(list_predefined_queries("clickhouse", default_ws_id))
109
+ row = next(r for r in rows if r["query_name"] == "q2")
110
+ assert row["order_by"] is None and row["fields"] is None
111
+
112
+
113
+ def test_same_name_is_distinct_per_workspace(default_ws_id):
114
+ from queryview.workspaces import create_workspace, resolve
115
+
116
+ _run(create_workspace("t3-iso"))
117
+ other = _run(resolve("t3-iso")).id
118
+ _run(save_predefined_query("iso q", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
119
+ _run(save_predefined_query("iso q", "clickhouse", "SELECT 2", workspace_id=other))
120
+
121
+ q_default = _run(get_predefined_query("clickhouse", "iso q", default_ws_id))
122
+ q_other = _run(get_predefined_query("clickhouse", "iso q", other))
123
+ assert q_default is not None and q_default["query"] == "SELECT 1"
124
+ assert q_other is not None and q_other["query"] == "SELECT 2"
125
+ names_default = [r["query_name"] for r in _run(list_predefined_queries("clickhouse", default_ws_id))]
126
+ names_other = [r["query_name"] for r in _run(list_predefined_queries("clickhouse", other))]
127
+ assert "iso q" in names_default and "iso q" in names_other
128
+
129
+
130
+ def test_api_unknown_workspace_is_404():
131
+ from fastapi.testclient import TestClient
132
+
133
+ from queryview.main import app
134
+
135
+ c = TestClient(app)
136
+ r = c.get("/api/predefined-queries", params={"workspace": "nope-t3"})
137
+ assert r.status_code == 404
138
+
139
+
140
+ def test_mcp_list_queries_parses_presentation(default_ws_id):
141
+ from queryview.mcp_server import list_queries
142
+
143
+ _run(
144
+ save_predefined_query(
145
+ "lq",
146
+ "clickhouse",
147
+ "SELECT 1",
148
+ order_by='[{"name":"id","dir":"ASC"}]',
149
+ fields='["id"]',
150
+ workspace_id=default_ws_id,
151
+ )
152
+ )
153
+ out = _run(list_queries("clickhouse"))
154
+ row = next(r for r in out["queries"] if r["query_name"] == "lq")
155
+ assert row["order_by"] == [{"name": "id", "dir": "ASC"}]
156
+ assert row["fields"] == ["id"]
157
+ assert row["query"] == "SELECT 1"
158
+
159
+
160
+ def test_columns_to_rows():
161
+ from queryview.mcp_server import _columns_to_rows
162
+
163
+ out = _columns_to_rows({"a": ["1", "2"], "b": ["x", "y"]})
164
+ assert out == {"columns": ["a", "b"], "rows": [["1", "x"], ["2", "y"]]}
165
+
166
+
167
+ def test_columns_to_rows_empty():
168
+ from queryview.mcp_server import _columns_to_rows
169
+
170
+ assert _columns_to_rows({}) == {"columns": [], "rows": []}
@@ -0,0 +1,260 @@
1
+ import asyncio
2
+ import time
3
+
4
+ from fastapi.testclient import TestClient
5
+
6
+ from queryview import remote
7
+ from queryview.main import app
8
+
9
+
10
+ def test_register_returns_distinct_ids():
11
+ a = remote.register()
12
+ b = remote.register()
13
+ assert a and b and a != b
14
+ remote.unregister(a)
15
+ remote.unregister(b)
16
+
17
+
18
+ def test_push_to_registered_session_delivers():
19
+ rid = remote.register()
20
+ try:
21
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
22
+ assert ok is True
23
+ msg_in = asyncio.run(remote.next_message(rid, 1.0))
24
+ assert msg_in == {"type": "query", "query": "SELECT 1"}
25
+ finally:
26
+ remote.unregister(rid)
27
+
28
+
29
+ def test_push_to_unknown_session_fails():
30
+ ok, msg = remote.push("deadbeef", {"type": "query", "query": "SELECT 1"})
31
+ assert ok is False
32
+ assert "unknown" in msg.lower()
33
+
34
+
35
+ def test_unregister_makes_push_fail():
36
+ rid = remote.register()
37
+ remote.unregister(rid)
38
+ ok, _ = remote.push(rid, {"type": "query", "query": "SELECT 1"})
39
+ assert ok is False
40
+
41
+
42
+ def test_next_message_times_out_to_none():
43
+ rid = remote.register()
44
+ try:
45
+ assert asyncio.run(remote.next_message(rid, 0.05)) is None
46
+ finally:
47
+ remote.unregister(rid)
48
+
49
+
50
+ def test_push_endpoint_requires_query():
51
+ client = TestClient(app)
52
+ r = client.post("/api/remote/push", json={"session_id": "x", "query": ""})
53
+ assert r.status_code == 400
54
+
55
+
56
+ def test_push_endpoint_unknown_session_returns_not_delivered():
57
+ client = TestClient(app)
58
+ r = client.post(
59
+ "/api/remote/push",
60
+ json={"session_id": "deadbeef", "query": "SELECT 1"},
61
+ )
62
+ assert r.status_code == 200
63
+ assert r.json()["ok"] is False
64
+
65
+
66
+ def test_push_endpoint_delivers_to_registered_session():
67
+
68
+ rid = remote.register()
69
+ try:
70
+ client = TestClient(app)
71
+ r = client.post(
72
+ "/api/remote/push",
73
+ json={
74
+ "session_id": rid,
75
+ "query": "SELECT id, name FROM items",
76
+ "limit": 5,
77
+ "order_by": [{"name": "id", "dir": "DESC"}],
78
+ "fields": ["name"],
79
+ },
80
+ )
81
+ assert r.json()["ok"] is True
82
+ msg = asyncio.run(remote.next_message(rid, 1.0))
83
+ assert msg is not None
84
+ assert msg["type"] == "query"
85
+ assert msg["query"] == "SELECT id, name FROM items"
86
+ assert msg["limit"] == 5
87
+ assert msg["order_by"] == [{"name": "id", "dir": "DESC"}]
88
+ assert msg["fields"] == ["name"]
89
+ finally:
90
+ remote.unregister(rid)
91
+
92
+
93
+ def test_push_endpoint_forwards_cell_view():
94
+
95
+ rid = remote.register()
96
+ try:
97
+ client = TestClient(app)
98
+ yaml = "source:\n type: custom\n value: <span>{cell}</span>\n"
99
+ r = client.post(
100
+ "/api/remote/push",
101
+ json={"session_id": rid, "query": "SELECT 1", "cell_view": yaml},
102
+ )
103
+ assert r.json()["ok"] is True
104
+ msg = asyncio.run(remote.next_message(rid, 1.0))
105
+ assert msg is not None
106
+ assert msg["cell_view"] == yaml
107
+ finally:
108
+ remote.unregister(rid)
109
+
110
+
111
+ def test_lock_endpoint_acquire_blocks_push():
112
+
113
+ rid = remote.register()
114
+ try:
115
+ client = TestClient(app)
116
+ r = client.post("/api/remote/lock", json={"session_id": rid, "action": "acquire"})
117
+ assert r.json()["ok"] is True
118
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
119
+ assert ok is False and msg == "blocked, user editing"
120
+ r = client.post("/api/remote/lock", json={"session_id": rid, "action": "release"})
121
+ assert r.json()["ok"] is True
122
+ ok, _ = remote.push(rid, {"type": "query", "query": "SELECT 1"})
123
+ assert ok is True
124
+ finally:
125
+ remote.unregister(rid)
126
+
127
+
128
+ def test_lock_endpoint_bad_action_400():
129
+
130
+ rid = remote.register()
131
+ try:
132
+ client = TestClient(app)
133
+ r = client.post("/api/remote/lock", json={"session_id": rid, "action": "nope"})
134
+ assert r.status_code == 400
135
+ finally:
136
+ remote.unregister(rid)
137
+
138
+
139
+ def test_acquire_blocks_push_then_release_allows():
140
+ rid = remote.register()
141
+ try:
142
+ ok, _ = remote.acquire(rid, "human")
143
+ assert ok is True
144
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
145
+ assert ok is False and msg == "blocked, user editing"
146
+ remote.release(rid, "human")
147
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
148
+ assert ok is True and msg == "delivered"
149
+ finally:
150
+ remote.unregister(rid)
151
+
152
+
153
+ def test_lock_ttl_expiry_allows_push():
154
+ rid = remote.register()
155
+ try:
156
+ remote.acquire(rid, "human")
157
+ # Simulate the heartbeat lapsing: age the lock past its TTL.
158
+ remote._channels[rid].lock_touched = time.monotonic() - (remote.LOCK_TTL_SECONDS + 1)
159
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
160
+ assert ok is True and msg == "delivered"
161
+ finally:
162
+ remote.unregister(rid)
163
+
164
+
165
+ def test_push_rejects_invalid_order_by():
166
+ rid = remote.register()
167
+ try:
168
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1", "order_by": [{"name": "id", "dir": "X"}]})
169
+ assert ok is False and "invalid order_by" in msg
170
+ finally:
171
+ remote.unregister(rid)
172
+
173
+
174
+ def test_push_rejects_malformed_cell_view():
175
+ rid = remote.register()
176
+ try:
177
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1", "cell_view": "col: [unclosed"})
178
+ assert ok is False and "invalid cell_view" in msg
179
+ finally:
180
+ remote.unregister(rid)
181
+
182
+
183
+ def test_release_by_nonowner_is_noop():
184
+ rid = remote.register()
185
+ try:
186
+ remote.acquire(rid, "human")
187
+ remote.release(rid, "agent") # wrong owner: must not clear
188
+ ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
189
+ assert ok is False and msg == "blocked, user editing"
190
+ finally:
191
+ remote.unregister(rid)
192
+
193
+
194
+ def test_push_endpoint_blank_cell_view_is_none():
195
+
196
+ rid = remote.register()
197
+ try:
198
+ client = TestClient(app)
199
+ r = client.post(
200
+ "/api/remote/push",
201
+ json={"session_id": rid, "query": "SELECT 1", "cell_view": " "},
202
+ )
203
+ assert r.json()["ok"] is True
204
+ msg = asyncio.run(remote.next_message(rid, 1.0))
205
+ assert msg is not None
206
+ assert msg["cell_view"] is None
207
+ finally:
208
+ remote.unregister(rid)
209
+
210
+
211
+ def test_session_database_set_and_read():
212
+ rid = remote.register()
213
+ try:
214
+ assert remote.session_database(rid) is None
215
+ assert remote.set_session_database(rid, "acme_db") is True
216
+ assert remote.session_database(rid) == "acme_db"
217
+ finally:
218
+ remote.unregister(rid)
219
+
220
+
221
+ def test_set_session_database_unknown_session():
222
+ assert remote.set_session_database("deadbeef", "x") is False
223
+
224
+
225
+ def test_remote_db_endpoint_sets_channel_database():
226
+
227
+ rid = remote.register()
228
+ try:
229
+ client = TestClient(app)
230
+ r = client.post("/api/remote/db", json={"session_id": rid, "database": "d1"})
231
+ assert r.json()["ok"] is True
232
+ assert remote.session_database(rid) == "d1"
233
+ finally:
234
+ remote.unregister(rid)
235
+
236
+
237
+ def test_push_query_return_includes_database():
238
+
239
+ from queryview.mcp_server import push_query as mcp_push_query
240
+
241
+ rid = remote.register()
242
+ try:
243
+ remote.set_session_database(rid, "acme_db")
244
+ out = asyncio.run(mcp_push_query(rid, "SELECT 1"))
245
+ assert out["ok"] is True and out["database"] == "acme_db"
246
+ finally:
247
+ remote.unregister(rid)
248
+
249
+
250
+ def test_session_workspace_report_and_read():
251
+
252
+ rid = remote.register()
253
+ try:
254
+ assert remote.session_workspace(rid) is None
255
+ assert remote.set_session_workspace(rid, "team-a") is True
256
+ assert remote.session_workspace(rid) == "team-a"
257
+ assert remote.set_session_workspace("nope", "x") is False
258
+ assert remote.session_workspace("nope") is None
259
+ finally:
260
+ remote.unregister(rid)
@@ -0,0 +1,87 @@
1
+ from queryview.validation import MAX_LIMIT, cell_view_error, clamp_paging, presentation_error
2
+
3
+
4
+ def test_none_inputs_are_valid():
5
+ assert presentation_error(None, None) is None
6
+
7
+
8
+ def test_valid_order_by_and_fields():
9
+ assert presentation_error([{"name": "id", "dir": "DESC"}], ["id", "name"]) is None
10
+ assert presentation_error([{"name": "id", "dir": "asc"}], None) is None # case-insensitive
11
+
12
+
13
+ def test_bad_direction_rejected():
14
+ msg = presentation_error([{"name": "id", "dir": "SIDEWAYS"}], None)
15
+ assert msg is not None and "dir" in msg
16
+
17
+
18
+ def test_missing_name_rejected():
19
+ assert presentation_error([{"dir": "ASC"}], None) is not None
20
+
21
+
22
+ def test_backtick_in_name_rejected():
23
+ assert presentation_error([{"name": "a`b", "dir": "ASC"}], None) is not None
24
+
25
+
26
+ def test_non_list_order_by_rejected():
27
+ assert presentation_error("id DESC", None) is not None
28
+
29
+
30
+ def test_non_string_field_rejected():
31
+ assert presentation_error(None, ["ok", 3]) is not None
32
+ assert presentation_error(None, ["ok", ""]) is not None
33
+
34
+
35
+ def test_cell_view_absent_or_empty_is_valid():
36
+ assert cell_view_error(None) is None
37
+ assert cell_view_error("") is None
38
+ assert cell_view_error(" \n") is None
39
+ assert cell_view_error("---\n") is None # empty document
40
+
41
+
42
+ def test_cell_view_valid_entries_and_params():
43
+ doc = (
44
+ "cve_id:\n"
45
+ " type: link\n"
46
+ " value: https://nvd.nist.gov/vuln/detail/{cell}\n"
47
+ "status:\n"
48
+ " type: custom\n"
49
+ " value: <strong>{cell}</strong>\n"
50
+ "params:\n"
51
+ " - name: env\n"
52
+ " options: [prod, staging]\n"
53
+ " - name: host\n"
54
+ " options_sql: SELECT DISTINCT host FROM t\n"
55
+ )
56
+ assert cell_view_error(doc) is None
57
+
58
+
59
+ def test_cell_view_rejects_unparsable_yaml_and_non_mapping():
60
+ assert cell_view_error("col: [unclosed\n") is not None
61
+ assert cell_view_error("- a list\n") is not None
62
+ assert cell_view_error("just a scalar") is not None
63
+ assert cell_view_error(42) is not None # not YAML text at all
64
+
65
+
66
+ def test_cell_view_rejects_bad_entries():
67
+ assert cell_view_error("col: not a mapping\n") is not None
68
+ assert cell_view_error("col:\n type: lnik\n value: x\n") is not None # typo'd type
69
+ assert cell_view_error("col:\n type: link\n") is not None # missing value
70
+ assert cell_view_error("col:\n type: link\n value: ''\n") is not None
71
+
72
+
73
+ def test_cell_view_rejects_bad_params():
74
+ assert cell_view_error("params: notalist\n") is not None
75
+ assert cell_view_error("params:\n - options: [a]\n") is not None # no name
76
+ # options and options_sql are mutually exclusive; one is required
77
+ assert cell_view_error("params:\n - name: p\n") is not None
78
+ assert cell_view_error("params:\n - name: p\n options: [a]\n options_sql: SELECT 1\n") is not None
79
+ assert cell_view_error("params:\n - name: p\n options: []\n") is not None
80
+ assert cell_view_error("params:\n - name: p\n options: [{a: b}]\n") is not None
81
+
82
+
83
+ def test_clamp_paging():
84
+ assert clamp_paging(50, 10) == (50, 10)
85
+ assert clamp_paging(-5, -5) == (0, 0)
86
+ assert clamp_paging(MAX_LIMIT + 1, 0) == (MAX_LIMIT, 0)
87
+ assert clamp_paging("x", None) == (100, 0)