Files
openobserve/tests/api-testing/support/endpoints/dashboards.py
T
ktx-akshay 33c6e9e0e1 test(dashboards): cover CTE/subquery match_all queries through the panel search path (#13838)
## What

First test coverage for the dashboard **panel** endpoints and the
**dashboard
search path**, running CTE/subquery `match_all` queries as real
dashboard panels.

Nothing in the repo previously touched either:
`grep -rn "/panels\|search_type=dashboards" tests/api-testing/` returned
zero hits.

## Why

Panels query `_search?type=logs&search_type=dashboards&dashboard_id=…` —
a
different route into the engine than the Logs page (`search_type=ui`).
The
`match_all` + FST projection rewrite fixed in #13808 runs inside it, so
a query
that passes via Logs can still regress on a dashboard.

The rewrite has to preserve the projection of the block the predicate
sits in.
When that block is the whole statement, the projection is obvious. When
it's a
CTE body feeding a JOIN, a window, or a four-branch UNION ALL, it has to
preserve
the *CTE's* output schema while the outer query reads different columns
from it —
which is where it went wrong.

## Coverage

**71 distinct queries across 29 combination families**, all with
`match_all`
inside a `WITH …` body or a derived table:

| Axis | Variants |
|---|---|
| Nesting depth | 1, 2, 3, and 5-level CTE chains |
| Nesting kind | CTEs and derived tables (and one query using both) |
| UNION ALL | 2, 3, and 4 separate `match_all` branches |
| JOIN | CTE⋈CTE, derived⋈derived, cross-stream |
| Window | `ROW_NUMBER`, `RANK`, `DENSE_RANK`, partitioned aggregates |
| Negation | `NOT match_all`, and mixed with positive `match_all` |
| Combined with | `COUNT(DISTINCT)`, `CASE`, `HAVING`, `histogram()` |

Each case creates a real panel, runs it via the dashboard search path,
then
asserts row count against the DuckDB oracle and that no expected column
was
dropped from the projection. A further 30 cases run the same SQL through
**both**
Logs and dashboard and diff the results — that's what catches a rewrite
firing on
only one path.

Queries are reused from the existing query-agent corpus rather than
duplicated,
so the two suites can't drift: query-agent verifies the values via the
Logs path,
this verifies the same SQL survives the dashboard path.

Cases are **discovered by classifying the SQL**, not from a hand-written
ID list,
so new combinations added to the corpus are picked up automatically.
Test ids
name the shape (`Q1022-cte1_union_cdist_4ma`), so a failure identifies
the
combination without opening the corpus.

Plus 14 tests for the panel endpoints themselves — AddPanel /
UpdatePanel /
DeletePanel, contract + CRUD lifecycle + 7 error paths derived from
`DashboardError` in `src/core/src/http.rs`.

## Changes

| File | |
|---|---|
| `tests/dashboards/test_dashboard_cte_panels.py` | new — 103 tests |
| `tests/dashboards/test_dashboard_panels.py` | new — 14 tests |
| `tests/dashboards/conftest.py` | new — shared fixtures |
| `support/panel_queries.py` | new — discovery + panel helpers |
| `support/endpoints/dashboards.py` | +78, additive |
| `support/endpoints/search.py` | +36, additive |
| `support/factories.py` | +73, additive |

The three shared-file edits are **187 added lines, 0 modified** —
nothing
existing can break.

## Results

168 tests in tests/api-testing/tests/dashboards/ (117 new + 51
pre-existing)
166 passed, 1 skipped, 1 xfailed     # full nested-query corpus present
74 passed, 2 skipped, 1 xfailed     # current corpus on main


Repeated 2× and 4× respectively, no flakes.

Discovery scales with the corpus: on main it finds 8 nested `match_all`
cases,
and 71 once the larger sentinel set is present. Cases that aren't in the
corpus
skip with an explanatory message instead of failing, so this is safe to
merge in
either order.
2026-08-17 09:47:17 +00:00

104 lines
3.7 KiB
Python

"""Dashboard endpoint wrappers."""
from __future__ import annotations
from typing import Any
import requests
class DashboardsAPI:
def __init__(self, client): self.c = client
def list(self, *, org: str | None = None) -> list[dict[str, Any]]:
r = self.c.get("dashboards", org=org, raise_for_status=True)
return r.json().get("dashboards", [])
def create(self, payload: dict[str, Any], *, org: str | None = None) -> dict[str, Any]:
"""Create a dashboard and return the response body."""
r = self.c.post("dashboards", json=payload, org=org, raise_for_status=True)
return r.json()
def get(self, dashboard_id: str, *, org: str | None = None) -> requests.Response:
return self.c.get(f"dashboards/{dashboard_id}", org=org)
def delete(self, dashboard_id: str, *, org: str | None = None) -> requests.Response:
return self.c.delete(f"dashboards/{dashboard_id}", org=org)
def hash(self, dashboard_id: str, *, org: str | None = None) -> str:
"""Current optimistic-concurrency hash, read from the GET response.
Every panel mutation consumes a hash and mints a new one, so callers
must re-read (or chain the `hash` from the previous response) before
the next write — a stale hash is a 409, not a retryable error.
"""
r = self.get(dashboard_id, org=org)
r.raise_for_status()
return r.json()["hash"]
# ----- panel operations (v8 dashboards only) -----
def add_panel(
self,
dashboard_id: str,
panel: dict[str, Any],
*,
hash: str,
tab_id: str | None = None,
folder: str | None = None,
org: str | None = None,
) -> requests.Response:
"""POST /api/{org}/dashboards/{id}/panels — `hash` is a query param, not body."""
params: dict[str, str] = {"hash": hash}
if folder is not None:
params["folder"] = folder
body: dict[str, Any] = {"panel": panel}
if tab_id is not None:
body["tabId"] = tab_id
return self.c.post(f"dashboards/{dashboard_id}/panels", params=params, json=body, org=org)
def update_panel(
self,
dashboard_id: str,
panel_id: str,
panel: dict[str, Any],
*,
hash: str,
tab_id: str | None = None,
folder: str | None = None,
org: str | None = None,
) -> requests.Response:
"""PUT /api/{org}/dashboards/{id}/panels/{panel_id}."""
params: dict[str, str] = {"hash": hash}
if folder is not None:
params["folder"] = folder
body: dict[str, Any] = {"panel": panel}
if tab_id is not None:
body["tabId"] = tab_id
return self.c.put(
f"dashboards/{dashboard_id}/panels/{panel_id}", params=params, json=body, org=org
)
def delete_panel(
self,
dashboard_id: str,
panel_id: str,
*,
hash: str,
tab_id: str | None = None,
folder: str | None = None,
org: str | None = None,
) -> requests.Response:
"""DELETE /api/{org}/dashboards/{id}/panels/{panel_id}."""
params: dict[str, str] = {"hash": hash}
if folder is not None:
params["folder"] = folder
if tab_id is not None:
params["tabId"] = tab_id
return self.c.delete(f"dashboards/{dashboard_id}/panels/{panel_id}", params=params, org=org)
def panels(self, dashboard_id: str, *, org: str | None = None) -> list[dict[str, Any]]:
"""Flatten every panel across every tab of a v8 dashboard."""
r = self.get(dashboard_id, org=org)
r.raise_for_status()
return [p for tab in r.json()["v8"].get("tabs", []) for p in tab.get("panels", [])]