mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 13:36:58 +02:00
Prototype of new /-/jump menu plus plugin hook
This commit is contained in:
parent
d3330695fa
commit
fae847ac10
15 changed files with 1007 additions and 132 deletions
|
|
@ -52,7 +52,7 @@ async def test_ds():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_global_access(test_ds):
|
||||
"""Test /-/tables with global access permissions"""
|
||||
"""Test allowed_resources() with global access permissions"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "alice":
|
||||
|
|
@ -91,7 +91,7 @@ async def test_tables_endpoint_global_access(test_ds):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_database_restriction(test_ds):
|
||||
"""Test /-/tables with database-level restriction"""
|
||||
"""Test allowed_resources() with database-level restriction"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("role") == "analyst":
|
||||
|
|
@ -133,7 +133,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_table_exception(test_ds):
|
||||
"""Test /-/tables with table-level exception (deny database, allow specific table)"""
|
||||
"""Test allowed_resources() with table-level exception (deny database, allow specific table)"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "carol":
|
||||
|
|
@ -217,7 +217,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_no_permissions():
|
||||
"""Test /-/tables when user has no custom permissions (only defaults)"""
|
||||
"""Test allowed_resources() when user has no custom permissions (only defaults)"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
|
@ -241,7 +241,7 @@ async def test_tables_endpoint_no_permissions():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_specific_table_only(test_ds):
|
||||
"""Test /-/tables when only specific tables are allowed (no parent/global rules)"""
|
||||
"""Test allowed_resources() when only specific tables are allowed (no parent/global rules)"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "dave":
|
||||
|
|
@ -283,7 +283,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_empty_result(test_ds):
|
||||
"""Test /-/tables when all tables are explicitly denied"""
|
||||
"""Test allowed_resources() when all tables are explicitly denied"""
|
||||
|
||||
def rules_callback(datasette, actor, action):
|
||||
if actor and actor.get("id") == "blocked":
|
||||
|
|
@ -314,7 +314,7 @@ async def test_tables_endpoint_empty_result(test_ds):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_no_query_returns_all():
|
||||
"""Test /-/tables without query parameter returns all tables"""
|
||||
"""Test allowed_resources() without query parameter returns all tables"""
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
|
|
@ -338,7 +338,7 @@ async def test_tables_endpoint_no_query_returns_all():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_truncation():
|
||||
"""Test /-/tables truncates at 100 tables and sets truncated flag"""
|
||||
"""Test allowed_resources() truncates at 100 tables and sets truncated flag"""
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
||||
|
|
@ -359,7 +359,7 @@ async def test_tables_endpoint_truncation():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_single_term():
|
||||
"""Test /-/tables?q=user to filter tables matching 'user'"""
|
||||
"""Test allowed_resources()?q=user to filter tables matching 'user'"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
|
@ -396,7 +396,7 @@ async def test_tables_endpoint_search_single_term():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_search_multiple_terms():
|
||||
"""Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*"""
|
||||
"""Test allowed_resources()?q=user+profile to filter tables matching .*user.*profile.*"""
|
||||
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,13 @@ async def test_navigation_menu_links(
|
|||
search_button.find("kbd")["title"]
|
||||
== "Keyboard shortcut: press / to open Jump to"
|
||||
)
|
||||
navigation_search_script = soup.find(
|
||||
"script", {"src": re.compile(r"navigation-search\.js")}
|
||||
)
|
||||
assert navigation_search_script["src"] == (
|
||||
"/-/static/navigation-search.js?"
|
||||
+ ds_client.ds.static_hash("navigation-search.js")
|
||||
)
|
||||
assert details.find("li").find("button") == search_button
|
||||
if not actor_id:
|
||||
# The app menu is always visible, but anonymous users do not see logout
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ async def test_orphan_stale_catalog_child_entries_removed(tmp_path):
|
|||
""")
|
||||
assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")]
|
||||
|
||||
response = await ds2.client.get("/-/tables.json")
|
||||
response = await ds2.client.get("/-/jump.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
ds2.close()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ async def test_skip_permission_checks_with_admin_actor(datasette_with_permission
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_permission_checks_shows_denied_tables():
|
||||
"""Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json"""
|
||||
"""Test that skip_permission_checks=True shows tables from denied databases in /-/jump.json"""
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
|
|
@ -211,8 +211,8 @@ async def test_skip_permission_checks_shows_denied_tables():
|
|||
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Without skip_permission_checks, tables from denied database should not appear in /-/tables.json
|
||||
response = await ds.client.get("/-/tables.json")
|
||||
# Without skip_permission_checks, tables from denied database should not appear in /-/jump.json
|
||||
response = await ds.client.get("/-/jump.json")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
table_names = [match["name"] for match in data["matches"]]
|
||||
|
|
@ -221,7 +221,7 @@ async def test_skip_permission_checks_shows_denied_tables():
|
|||
assert len(fixtures_tables) == 0
|
||||
|
||||
# With skip_permission_checks=True, tables from denied database SHOULD appear
|
||||
response = await ds.client.get("/-/tables.json", skip_permission_checks=True)
|
||||
response = await ds.client.get("/-/jump.json", skip_permission_checks=True)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
table_names = [match["name"] for match in data["matches"]]
|
||||
|
|
|
|||
224
tests/test_jump.py
Normal file
224
tests/test_jump.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
from markupsafe import Markup
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
from datasette.plugins import pm
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ds_for_jump():
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
"content": {
|
||||
"allow": {"id": "*"},
|
||||
"tables": {
|
||||
"articles": {"allow": {"id": "editor"}},
|
||||
"comments": {"allow": True},
|
||||
},
|
||||
"queries": {
|
||||
"recent_comments": {
|
||||
"sql": "select * from comments",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent comments",
|
||||
},
|
||||
"release_notes": {
|
||||
"sql": "select 1",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent Datasette releases",
|
||||
},
|
||||
"editor_report": {
|
||||
"sql": "select * from articles",
|
||||
"allow": {"id": "editor"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"private": {
|
||||
"allow": False,
|
||||
"queries": {
|
||||
"private_report": "select 1",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
await ds.invoke_startup()
|
||||
|
||||
content_db = ds.add_memory_database("jump_test_content", name="content")
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE VIEW IF NOT EXISTS comment_summary AS SELECT body FROM comments"
|
||||
)
|
||||
|
||||
private_db = ds.add_memory_database("jump_test_private", name="private")
|
||||
await private_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
|
||||
)
|
||||
|
||||
public_db = ds.add_memory_database("jump_test_public", name="public")
|
||||
await public_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
|
||||
)
|
||||
|
||||
await ds._refresh_schemas()
|
||||
return ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=content", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
matches_by_type_and_name = {
|
||||
(match["type"], match["name"]): match for match in data["matches"]
|
||||
}
|
||||
assert ("database", "content") in matches_by_type_and_name
|
||||
assert ("table", "content: comments") in matches_by_type_and_name
|
||||
assert ("view", "content: comment_summary") in matches_by_type_and_name
|
||||
assert ("query", "content: recent_comments") in matches_by_type_and_name
|
||||
assert matches_by_type_and_name[("database", "content")]["url"] == "/content"
|
||||
assert (
|
||||
matches_by_type_and_name[("query", "content: recent_comments")]["display_name"]
|
||||
== "Recent comments"
|
||||
)
|
||||
assert (
|
||||
matches_by_type_and_name[("query", "content: recent_comments")]["url"]
|
||||
== "/content/recent_comments"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_and_displays_canned_query_titles(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=datasette", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["matches"] == [
|
||||
{
|
||||
"name": "content: release_notes",
|
||||
"display_name": "Recent Datasette releases",
|
||||
"url": "/content/release_notes",
|
||||
"type": "query",
|
||||
"description": "Canned query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_respects_resource_permissions(ds_for_jump):
|
||||
regular = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "regular"}
|
||||
)
|
||||
editor = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "editor"}
|
||||
)
|
||||
private = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=secrets", actor={"id": "editor"}
|
||||
)
|
||||
|
||||
assert {match["name"] for match in regular.json()["matches"]} == {
|
||||
"public: articles"
|
||||
}
|
||||
assert {match["name"] for match in editor.json()["matches"]} == {
|
||||
"content: articles",
|
||||
"public: articles",
|
||||
}
|
||||
assert private.json()["matches"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return JumpSQL(
|
||||
sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'plugin-dashboard: ' || :actor_id AS label,
|
||||
'Plugin supplied item' AS description,
|
||||
'/-/plugin-dashboard' AS url,
|
||||
NULL AS database_name,
|
||||
NULL AS resource_name,
|
||||
'plugin dashboard ' || :actor_id AS search_text,
|
||||
80 AS sort_key,
|
||||
'test-plugin' AS source,
|
||||
'Plugin dashboard for ' || :actor_id AS display_name
|
||||
""",
|
||||
params={"actor_id": actor["id"] if actor else "anonymous"},
|
||||
has_display_name=True,
|
||||
)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=dashboard", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
plugin_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
||||
]
|
||||
assert plugin_matches == [
|
||||
{
|
||||
"name": "plugin-dashboard: alice",
|
||||
"display_name": "Plugin dashboard for alice",
|
||||
"url": "/-/plugin-dashboard",
|
||||
"type": "plugin",
|
||||
"description": "Plugin supplied item",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_start_hook_renders_empty_state_template(ds_for_jump):
|
||||
class JumpStartPlugin:
|
||||
@hookimpl
|
||||
def jump_start(self, datasette, actor, request):
|
||||
if not actor:
|
||||
return None
|
||||
return Markup(
|
||||
'<section class="agent-jump-start">'
|
||||
"<h3>Agent chat</h3>"
|
||||
'<a href="/-/agent/new">Start a new agent chat</a>'
|
||||
"</section>"
|
||||
)
|
||||
|
||||
plugin = JumpStartPlugin()
|
||||
pm.register(plugin, name="test-jump-start-plugin")
|
||||
try:
|
||||
anonymous = await ds_for_jump.client.get("/")
|
||||
authenticated = await ds_for_jump.client.get("/", actor={"id": "alice"})
|
||||
finally:
|
||||
pm.unregister(name="test-jump-start-plugin")
|
||||
|
||||
assert 'url="/-/jump"' in authenticated.text
|
||||
assert "<template data-jump-start>" not in anonymous.text
|
||||
assert "<template data-jump-start>" in authenticated.text
|
||||
assert "Start a new agent chat" in authenticated.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_removed(ds_for_jump):
|
||||
response = await ds_for_jump.client.get("/-/tables.json")
|
||||
assert response.status_code == 404
|
||||
199
tests/test_navigation_search_js.py
Normal file
199
tests/test_navigation_search_js.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
|
||||
def test_navigation_search_tracks_and_renders_recent_items():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.value = "";
|
||||
this.dataset = {};
|
||||
this.open = false;
|
||||
}
|
||||
addEventListener() {}
|
||||
close() { this.open = false; }
|
||||
focus() {}
|
||||
querySelector() {
|
||||
return { scrollIntoView() {} };
|
||||
}
|
||||
showModal() { this.open = true; }
|
||||
}
|
||||
|
||||
class FakeShadowRoot {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.dialog = new FakeElement();
|
||||
this.input = new FakeElement();
|
||||
this.results = new FakeElement();
|
||||
}
|
||||
querySelector(selector) {
|
||||
if (selector == "dialog") return this.dialog;
|
||||
if (selector == ".search-input") return this.input;
|
||||
if (selector == ".results-container") return this.results;
|
||||
return new FakeElement();
|
||||
}
|
||||
}
|
||||
|
||||
global.HTMLElement = class {
|
||||
constructor() {
|
||||
this.attributes = {};
|
||||
}
|
||||
attachShadow() {
|
||||
this.shadowRoot = new FakeShadowRoot();
|
||||
return this.shadowRoot;
|
||||
}
|
||||
dispatchEvent() {}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] || null;
|
||||
}
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
}
|
||||
};
|
||||
global.CustomEvent = class {
|
||||
constructor(name, options) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
}
|
||||
};
|
||||
global.customElements = {
|
||||
registry: new Map(),
|
||||
define(name, cls) {
|
||||
this.registry.set(name, cls);
|
||||
},
|
||||
};
|
||||
global.document = {
|
||||
addEventListener() {},
|
||||
activeElement: null,
|
||||
createElement() {
|
||||
return {
|
||||
set textContent(value) {
|
||||
this.innerHTML = String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
global.localStorage = {
|
||||
store: {},
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key)
|
||||
? this.store[key]
|
||||
: null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value);
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
},
|
||||
};
|
||||
global.window = { location: { href: "" } };
|
||||
|
||||
vm.runInThisContext(
|
||||
fs.readFileSync("datasette/static/navigation-search.js", "utf8"),
|
||||
{ filename: "navigation-search.js" }
|
||||
);
|
||||
|
||||
const Component = customElements.registry.get("navigation-search");
|
||||
const element = new Component();
|
||||
const items = Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `Item ${index + 1}`,
|
||||
url: `/item-${index + 1}`,
|
||||
type: "table",
|
||||
description: "Table",
|
||||
}));
|
||||
items[5].name = "content: recent_datasette_releases";
|
||||
items[5].display_name = "Recent Datasette releases";
|
||||
|
||||
for (const item of items) {
|
||||
element.matches = [item];
|
||||
element.renderedMatches = [item];
|
||||
element.selectedIndex = 0;
|
||||
element.selectCurrentItem();
|
||||
}
|
||||
|
||||
const stored = JSON.parse(
|
||||
Object.values(localStorage.store).find((value) => value.includes("/item-6"))
|
||||
);
|
||||
if (stored.length !== 5) {
|
||||
throw new Error(`Expected 5 recent items, got ${stored.length}`);
|
||||
}
|
||||
if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") {
|
||||
throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
if (stored[0].display_name !== "Recent Datasette releases") {
|
||||
throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`);
|
||||
}
|
||||
|
||||
element.matches = [
|
||||
items[5],
|
||||
items[4],
|
||||
{
|
||||
name: "Other",
|
||||
url: "/other",
|
||||
type: "database",
|
||||
description: "Database",
|
||||
},
|
||||
];
|
||||
element.shadowRoot.input.value = "";
|
||||
element.renderResults();
|
||||
|
||||
const html = element.shadowRoot.results.innerHTML;
|
||||
if (!html.includes("Recent")) {
|
||||
throw new Error(`Missing Recent heading: ${html}`);
|
||||
}
|
||||
if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) {
|
||||
throw new Error(`Missing recent items: ${html}`);
|
||||
}
|
||||
if (!html.includes("content: recent_datasette_releases")) {
|
||||
throw new Error(`Missing canonical item name for display_name item: ${html}`);
|
||||
}
|
||||
if (!html.includes("Item 4") || !html.includes("Item 2")) {
|
||||
throw new Error(`Expected all stored recent items in empty state: ${html}`);
|
||||
}
|
||||
if (html.includes("Other")) {
|
||||
throw new Error(`Rendered non-recent item in empty state: ${html}`);
|
||||
}
|
||||
if (!html.includes("Clear recent")) {
|
||||
throw new Error(`Missing Clear recent control: ${html}`);
|
||||
}
|
||||
|
||||
element.clearRecentItems();
|
||||
if (localStorage.getItem(element.recentItemsStorageKey()) !== null) {
|
||||
throw new Error("Expected recent items to be cleared");
|
||||
}
|
||||
element.renderResults();
|
||||
if (element.shadowRoot.results.innerHTML.includes("Clear recent")) {
|
||||
throw new Error("Clear recent should disappear after clearing");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(stored));
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=".",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert [item["url"] for item in json.loads(result.stdout)] == [
|
||||
"/item-6",
|
||||
"/item-5",
|
||||
"/item-4",
|
||||
"/item-3",
|
||||
"/item-2",
|
||||
]
|
||||
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"
|
||||
|
|
@ -33,7 +33,7 @@ async def ds_with_tables():
|
|||
await ds.invoke_startup()
|
||||
|
||||
# Add content database with some tables
|
||||
content_db = ds.add_memory_database("content")
|
||||
content_db = ds.add_memory_database("search_tables_content", name="content")
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
|
||||
)
|
||||
|
|
@ -45,27 +45,28 @@ async def ds_with_tables():
|
|||
)
|
||||
|
||||
# Add private database with a table
|
||||
private_db = ds.add_memory_database("private")
|
||||
private_db = ds.add_memory_database("search_tables_private", name="private")
|
||||
await private_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
|
||||
)
|
||||
|
||||
# Add another public database
|
||||
public_db = ds.add_memory_database("public")
|
||||
public_db = ds.add_memory_database("search_tables_public", name="public")
|
||||
await public_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
|
||||
)
|
||||
await ds._refresh_schemas()
|
||||
|
||||
return ds
|
||||
|
||||
|
||||
# /-/tables.json tests
|
||||
# /-/jump.json table search tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_basic_search(ds_with_tables):
|
||||
"""Test basic table search functionality."""
|
||||
# Search for "articles" - should find it in both content and public databases
|
||||
# but only return public.articles for anonymous user (content.articles requires auth)
|
||||
response = await ds_with_tables.client.get("/-/tables.json?q=articles")
|
||||
response = await ds_with_tables.client.get("/-/jump.json?q=articles")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ async def test_tables_search_with_auth(ds_with_tables):
|
|||
"""Test that authenticated users see more tables."""
|
||||
# Editor user should see content.articles
|
||||
response = await ds_with_tables.client.get(
|
||||
"/-/tables.json?q=articles",
|
||||
"/-/jump.json?q=articles",
|
||||
actor={"id": "editor"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -103,7 +104,7 @@ async def test_tables_search_partial_match(ds_with_tables):
|
|||
"""Test that search matches partial table names."""
|
||||
# Search for "com" should match "comments"
|
||||
response = await ds_with_tables.client.get(
|
||||
"/-/tables.json?q=com",
|
||||
"/-/jump.json?q=com",
|
||||
actor={"id": "user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -119,7 +120,7 @@ async def test_tables_search_respects_database_permissions(ds_with_tables):
|
|||
# Search for "secrets" which is in the private database
|
||||
# Even authenticated users shouldn't see it because database is denied
|
||||
response = await ds_with_tables.client.get(
|
||||
"/-/tables.json?q=secrets",
|
||||
"/-/jump.json?q=secrets",
|
||||
actor={"id": "user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -134,7 +135,7 @@ async def test_tables_search_respects_table_permissions(ds_with_tables):
|
|||
"""Test that tables with specific permissions are filtered correctly."""
|
||||
# Regular authenticated user searching for "users"
|
||||
response = await ds_with_tables.client.get(
|
||||
"/-/tables.json?q=users",
|
||||
"/-/jump.json?q=users",
|
||||
actor={"id": "regular"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -149,7 +150,7 @@ async def test_tables_search_respects_table_permissions(ds_with_tables):
|
|||
async def test_tables_search_response_structure(ds_with_tables):
|
||||
"""Test that response has correct structure."""
|
||||
response = await ds_with_tables.client.get(
|
||||
"/-/tables.json?q=users",
|
||||
"/-/jump.json?q=users",
|
||||
actor={"id": "user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue