mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
The daemon rejects an invalid semantic-query request (unknown source, ambiguous measure, no join path) with a plain ValueError; the Node compute port now maps the daemon's exit code 3 / HTTP 400 to KtxExpectedError so these routine, caller-driven rejections stay out of Error Tracking. A dedicated SemanticLayerRequestError(ValueError) is raised only for engine rejections and routed through both daemon transports and the HTTP handler. Because pydantic v2 ValidationError subclasses ValueError, malformed sources or responses (contract faults) are kept as faults: they are reported and mapped to exit 1 / HTTP 500 / plain Error on every path. Non-object stdin is likewise a fault (exit 1), not exit 3.
573 lines
17 KiB
Python
573 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ktx_daemon.app import create_app
|
|
from ktx_daemon.database_introspection import (
|
|
DatabaseIntrospectionResponse,
|
|
LiveDatabaseColumn,
|
|
LiveDatabaseTable,
|
|
)
|
|
|
|
|
|
ORDERS_SOURCE = {
|
|
"name": "orders",
|
|
"table": "public.orders",
|
|
"grain": ["id"],
|
|
"columns": [
|
|
{"name": "id", "type": "number"},
|
|
{"name": "status", "type": "string"},
|
|
{"name": "amount", "type": "number"},
|
|
],
|
|
"joins": [],
|
|
"measures": [{"name": "order_count", "expr": "count(*)"}],
|
|
}
|
|
|
|
LOOKML_ORDER_VIEW = """
|
|
view: orders {
|
|
sql_table_name: public.orders ;;
|
|
|
|
dimension: id {
|
|
primary_key: yes
|
|
type: number
|
|
sql: ${TABLE}.id ;;
|
|
}
|
|
|
|
dimension: status {
|
|
type: string
|
|
sql: ${TABLE}.status ;;
|
|
}
|
|
|
|
measure: order_count {
|
|
type: count
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class FakeEmbeddingProvider:
|
|
name = "fake"
|
|
dimensions = 3
|
|
max_batch_size = 2
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[list[str]] = []
|
|
|
|
def encode(self, texts: list[str]) -> list[list[float]]:
|
|
self.calls.append(list(texts))
|
|
return [
|
|
[float(len(text)), float(index), 1.0] for index, text in enumerate(texts)
|
|
]
|
|
|
|
|
|
def test_health_endpoint_returns_healthy() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "healthy"}
|
|
|
|
|
|
def test_health_endpoint_returns_managed_runtime_version(monkeypatch) -> None:
|
|
monkeypatch.setenv("KTX_DAEMON_VERSION", "0.2.0")
|
|
client = TestClient(create_app())
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "healthy", "version": "0.2.0"}
|
|
|
|
|
|
def test_app_lifespan_emits_daemon_lifecycle_debug_events(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
capsys,
|
|
) -> None:
|
|
from ktx_daemon.telemetry.daemon_lifecycle import reset_daemon_lifecycle_for_tests
|
|
from ktx_daemon.telemetry.identity import reset_identity_cache
|
|
|
|
reset_daemon_lifecycle_for_tests()
|
|
reset_identity_cache()
|
|
identity_path = tmp_path / ".ktx" / "telemetry.json"
|
|
identity_path.parent.mkdir(parents=True)
|
|
identity_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"installId": "00000000-0000-4000-8000-000000000000",
|
|
"enabled": True,
|
|
"createdAt": "2026-05-22T14:33:02.000Z",
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("KTX_TELEMETRY_DEBUG", "1")
|
|
monkeypatch.setenv("KTX_DAEMON_VERSION", "0.4.1")
|
|
monkeypatch.delenv("CI", raising=False)
|
|
monkeypatch.delenv("KTX_TELEMETRY_DISABLED", raising=False)
|
|
monkeypatch.delenv("DO_NOT_TRACK", raising=False)
|
|
|
|
with TestClient(
|
|
create_app(telemetry_started_at=100.0, clock=lambda: 100.125)
|
|
) as client:
|
|
assert client.get("/health").status_code == 200
|
|
|
|
captured = capsys.readouterr()
|
|
assert '"event": "daemon_started"' in captured.err
|
|
assert '"event": "daemon_stopped"' in captured.err
|
|
|
|
|
|
def test_database_introspect_endpoint_returns_snapshot() -> None:
|
|
calls = []
|
|
|
|
def fake_introspector(request):
|
|
calls.append(request)
|
|
return DatabaseIntrospectionResponse(
|
|
connection_id=request.connection_id,
|
|
extracted_at="2026-04-28T10:00:00+00:00",
|
|
metadata={"driver": request.driver, "schemas": request.schemas},
|
|
tables=[
|
|
LiveDatabaseTable(
|
|
catalog="warehouse",
|
|
db="public",
|
|
name="orders",
|
|
columns=[
|
|
LiveDatabaseColumn(
|
|
name="id",
|
|
type="integer",
|
|
nullable=False,
|
|
primary_key=True,
|
|
)
|
|
],
|
|
)
|
|
],
|
|
)
|
|
|
|
client = TestClient(create_app(database_introspector=fake_introspector))
|
|
|
|
response = client.post(
|
|
"/database/introspect",
|
|
json={
|
|
"connection_id": "warehouse",
|
|
"driver": "postgres",
|
|
"url": "postgresql://readonly@example.test/warehouse",
|
|
"schemas": ["public"],
|
|
"table_scope": [{"db": "public", "name": "orders"}],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["connection_id"] == "warehouse"
|
|
assert response.json()["tables"][0]["name"] == "orders"
|
|
assert calls[0].connection_id == "warehouse"
|
|
assert calls[0].table_scope[0].db == "public"
|
|
assert calls[0].table_scope[0].name == "orders"
|
|
|
|
|
|
def test_database_introspect_endpoint_maps_value_error_to_400() -> None:
|
|
def fake_introspector(request):
|
|
raise ValueError('database introspection supports only driver "postgres"')
|
|
|
|
client = TestClient(create_app(database_introspector=fake_introspector))
|
|
|
|
response = client.post(
|
|
"/database/introspect",
|
|
json={
|
|
"connection_id": "warehouse",
|
|
"driver": "snowflake",
|
|
"url": "snowflake://example",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json() == {
|
|
"detail": 'database introspection supports only driver "postgres"'
|
|
}
|
|
|
|
|
|
def test_embedding_compute_endpoint_returns_embedding() -> None:
|
|
provider = FakeEmbeddingProvider()
|
|
client = TestClient(create_app(embedding_provider=provider))
|
|
|
|
response = client.post("/embeddings/compute", json={"text": "hello"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"embedding": [5.0, 0.0, 1.0]}
|
|
assert provider.calls == [["hello"]]
|
|
|
|
|
|
def test_embedding_compute_bulk_endpoint_returns_embeddings() -> None:
|
|
provider = FakeEmbeddingProvider()
|
|
client = TestClient(create_app(embedding_provider=provider))
|
|
|
|
response = client.post(
|
|
"/embeddings/compute-bulk",
|
|
json={"texts": ["one", "three"]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"embeddings": [[3.0, 0.0, 1.0], [5.0, 1.0, 1.0]]}
|
|
assert provider.calls == [["one", "three"]]
|
|
|
|
|
|
def test_embedding_compute_bulk_endpoint_maps_value_error_to_400() -> None:
|
|
provider = FakeEmbeddingProvider()
|
|
client = TestClient(create_app(embedding_provider=provider))
|
|
|
|
response = client.post(
|
|
"/embeddings/compute-bulk",
|
|
json={"texts": ["one", "two", "three"]},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json() == {"detail": "Maximum 2 texts allowed per batch"}
|
|
assert provider.calls == []
|
|
|
|
|
|
def test_code_execute_endpoint_is_not_registered_by_default() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post("/code/execute", json={"code": "result = 7"})
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_code_execute_endpoint_returns_result_when_enabled() -> None:
|
|
client = TestClient(create_app(enable_code_execution=True))
|
|
|
|
response = client.post(
|
|
"/code/execute",
|
|
json={"code": 'print("ran")\nresult = {"value": 7}'},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["result"] == {"value": 7}
|
|
assert body["console_output"] == "ran\n"
|
|
assert body["error"] is None
|
|
assert body["message"] is None
|
|
assert body["visualizations"] is None
|
|
assert "=== Console Output ===" in body["formatted_result"]
|
|
assert "=== Result ===" in body["formatted_result"]
|
|
|
|
|
|
def test_code_execute_endpoint_serializes_numpy_result_when_enabled() -> None:
|
|
client = TestClient(create_app(enable_code_execution=True))
|
|
|
|
response = client.post(
|
|
"/code/execute",
|
|
json={"code": "import numpy as np\nresult = {'value': np.float64(1.25)}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["result"] == {"value": 1.25}
|
|
assert body["error"] is None
|
|
|
|
|
|
def test_code_execute_endpoint_uses_host_free_boundary_when_enabled() -> None:
|
|
client = TestClient(create_app(enable_code_execution=True))
|
|
|
|
response = client.post(
|
|
"/code/execute",
|
|
json={
|
|
"source_id": "chat_123",
|
|
"message_id": "message_456",
|
|
"code": (
|
|
"import pandas as pd\n"
|
|
"result = save_df_to_scratchpad(pd.DataFrame({'value': [1]}), 'out.json')"
|
|
),
|
|
},
|
|
headers={"Authorization": "Bearer should-not-forward"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["result"] is None
|
|
assert (
|
|
body["error"]
|
|
== "nest_api_url, Authorization header, and source_id are required for scratchpad operations"
|
|
)
|
|
assert "=== Error ===" in body["formatted_result"]
|
|
|
|
|
|
def test_sql_parse_table_identifier_endpoint() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/sql/parse-table-identifier",
|
|
json={
|
|
"items": [
|
|
{
|
|
"key": "orders",
|
|
"sql_table_name": "public.orders",
|
|
"dialect": "postgres",
|
|
},
|
|
{
|
|
"key": "template",
|
|
"sql_table_name": "${orders.SQL_TABLE_NAME}",
|
|
"dialect": "postgres",
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["results"]["orders"]["ok"] is True
|
|
assert body["results"]["orders"]["schema"] == "public"
|
|
assert body["results"]["orders"]["name"] == "orders"
|
|
assert body["results"]["template"]["ok"] is False
|
|
assert body["results"]["template"]["reason"] == "looker_template_unresolved"
|
|
|
|
|
|
def test_sql_validate_read_only_endpoint() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
ok_response = client.post(
|
|
"/sql/validate-read-only",
|
|
json={"dialect": "postgres", "sql": "select * from public.orders"},
|
|
)
|
|
bad_response = client.post(
|
|
"/sql/validate-read-only",
|
|
json={
|
|
"dialect": "postgres",
|
|
"sql": "with x as (insert into audit.events values (1) returning *) select * from x",
|
|
},
|
|
)
|
|
|
|
assert ok_response.status_code == 200
|
|
assert ok_response.json() == {"ok": True, "error": None}
|
|
assert bad_response.status_code == 200
|
|
assert bad_response.json() == {
|
|
"ok": False,
|
|
"error": "SQL contains read/write operation: Insert",
|
|
}
|
|
|
|
|
|
def test_sql_analyze_batch_endpoint_returns_per_item_results() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/sql/analyze-batch",
|
|
json={
|
|
"dialect": "postgres",
|
|
"max_workers": 1,
|
|
"items": [
|
|
{
|
|
"id": "orders",
|
|
"sql": "select status from public.orders where created_at is not null",
|
|
},
|
|
{"id": "broken", "sql": "select * from where"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["results"]["orders"]["tables_touched"] == [
|
|
{"catalog": None, "db": "public", "name": "orders"}
|
|
]
|
|
assert body["results"]["orders"]["columns_by_clause"] == {
|
|
"select": ["status"],
|
|
"where": ["created_at"],
|
|
}
|
|
assert body["results"]["orders"]["error"] is None
|
|
assert body["results"]["broken"]["tables_touched"] == []
|
|
assert body["results"]["broken"]["columns_by_clause"] == {}
|
|
assert body["results"]["broken"]["error"] is not None
|
|
|
|
|
|
def test_semantic_query_endpoint_returns_sql() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/semantic-layer/query",
|
|
json={
|
|
"sources": [ORDERS_SOURCE],
|
|
"dialect": "postgres",
|
|
"query": {
|
|
"measures": ["orders.order_count"],
|
|
"dimensions": ["orders.status"],
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["dialect"] == "postgres"
|
|
assert "public.orders" in body["sql"]
|
|
assert body["columns"][0]["name"] == "orders.status"
|
|
|
|
|
|
def test_semantic_query_endpoint_maps_value_error_to_400() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/semantic-layer/query",
|
|
json={
|
|
"sources": [ORDERS_SOURCE],
|
|
"dialect": "postgres",
|
|
"query": {
|
|
"measures": ["missing.order_count"],
|
|
"dimensions": [],
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "missing.order_count" in response.json()["detail"]
|
|
|
|
|
|
def test_semantic_query_endpoint_maps_malformed_source_to_fault() -> None:
|
|
client = TestClient(create_app(), raise_server_exceptions=False)
|
|
invalid_source = {
|
|
key: value for key, value in ORDERS_SOURCE.items() if key != "table"
|
|
}
|
|
|
|
response = client.post(
|
|
"/semantic-layer/query",
|
|
json={
|
|
"sources": [invalid_source],
|
|
"dialect": "postgres",
|
|
"query": {"measures": ["orders.order_count"], "dimensions": []},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
|
|
|
|
def test_semantic_validate_endpoint_returns_structured_validation() -> None:
|
|
client = TestClient(create_app())
|
|
invalid_source = {
|
|
**ORDERS_SOURCE,
|
|
"measures": [
|
|
{"name": "revenue", "expr": "sum(amount)"},
|
|
{"name": "revenue", "expr": "sum(amount)"},
|
|
],
|
|
}
|
|
|
|
response = client.post(
|
|
"/semantic-layer/validate",
|
|
json={"sources": [invalid_source], "dialect": "postgres"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["valid"] is False
|
|
assert any("Duplicate measure" in error for error in body["errors"])
|
|
assert body["warnings"] == []
|
|
assert body["per_source_warnings"] == {}
|
|
|
|
|
|
def test_semantic_generate_sources_endpoint_returns_sources() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/semantic-layer/generate-sources",
|
|
json={
|
|
"tables": [
|
|
{
|
|
"name": "orders",
|
|
"db": "public",
|
|
"comment": "Orders table",
|
|
"columns": [
|
|
{
|
|
"name": "id",
|
|
"type": "integer",
|
|
"primary_key": True,
|
|
"nullable": False,
|
|
"comment": "Order ID",
|
|
},
|
|
{"name": "customer_id", "type": "integer"},
|
|
{
|
|
"name": "amount",
|
|
"type": "decimal",
|
|
"comment": "Order amount",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"name": "customers",
|
|
"db": "public",
|
|
"columns": [
|
|
{"name": "id", "type": "integer", "primary_key": True},
|
|
{"name": "email", "type": "varchar"},
|
|
],
|
|
},
|
|
],
|
|
"links": [
|
|
{
|
|
"from_table": "orders",
|
|
"from_column": "customer_id",
|
|
"to_table": "customers",
|
|
"to_column": "id",
|
|
"relationship_type": "MANY_TO_ONE",
|
|
}
|
|
],
|
|
"dialect": "postgres",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["source_count"] == 2
|
|
sources = {source["name"]: source for source in body["sources"]}
|
|
assert sources["orders"]["table"] == "public.orders"
|
|
assert sources["orders"]["description"] == "Orders table"
|
|
assert sources["orders"]["grain"] == ["id"]
|
|
assert sources["orders"]["joins"] == [
|
|
{
|
|
"to": "customers",
|
|
"on": "customer_id = customers.id",
|
|
"relationship": "many_to_one",
|
|
}
|
|
]
|
|
assert [measure["name"] for measure in sources["orders"]["measures"]] == [
|
|
"record_count",
|
|
"total_amount",
|
|
"avg_amount",
|
|
]
|
|
|
|
|
|
def test_lookml_parse_endpoint_returns_resolved_views() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
response = client.post(
|
|
"/lookml/parse",
|
|
json={
|
|
"files": [
|
|
{
|
|
"path": "views/orders.view.lkml",
|
|
"content": LOOKML_ORDER_VIEW,
|
|
}
|
|
],
|
|
"dialect": "postgres",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["joins"] == []
|
|
assert body["skipped_views"] == []
|
|
assert body["warnings"] == []
|
|
assert len(body["views"]) == 1
|
|
view = body["views"][0]
|
|
assert view["name"] == "orders"
|
|
assert view["source_type"] == "table"
|
|
assert view["table_ref"] == "public.orders"
|
|
assert view["grain"] == ["id"]
|
|
assert [column["name"] for column in view["columns"]] == ["id", "status"]
|
|
assert view["measures"] == [
|
|
{
|
|
"name": "order_count",
|
|
"expr": "count(*)",
|
|
"filter": None,
|
|
"description": None,
|
|
}
|
|
]
|