mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
test: catch Postgres-only column type bugs in PR CI and on alpha1 (#15670)
This commit is contained in:
@@ -108,9 +108,18 @@ def test_my_validation(ingest_test_data, db_cursor, test_org, test_stream):
|
||||
- `query_api` - Function to query OpenObserve search API
|
||||
- `openobserve_base_url` - OpenObserve API base URL
|
||||
- `auth_credentials` - Authentication credentials tuple
|
||||
- `api` - Call `/api/{org}/...` (or `/api/v2/...` with `v2=True`) as root
|
||||
- `destination` - An http template + webhook destination for alert tests
|
||||
- `test_org` - Test organization name (default: "default")
|
||||
- `test_stream` - Test stream name (default: "db_test_stream")
|
||||
|
||||
### Postgres-only drift checks
|
||||
|
||||
Cloud runs on Postgres; SQLite (local CI) accepts any value in any column, so type drift only shows up here.
|
||||
|
||||
- **Entity vs schema** (`test_entity_column_types.py`): parses every SeaORM entity under `src/infra/src/table` and checks each column's Postgres type and nullability against the migrated schema. No server data needed. `ENTITY_DIRS` adds entity roots and `BUILD_FEATURES` resolves `#[cfg(feature)]` fields (o2-enterprise reuses it for cloud builds).
|
||||
- **API round-trips** (`test_alert_roundtrip.py`, `test_object_roundtrips.py`): create an object with every optional field filled, read it back, update it. Empty optional fields are stored as NULL, which skips sqlx's type check, so only filled values exercise every column.
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests run automatically in the `db-testing.yml` GitHub Actions workflow on:
|
||||
|
||||
@@ -3,6 +3,7 @@ Pytest configuration and fixtures for DB validation tests.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import pytest
|
||||
import psycopg2
|
||||
import requests
|
||||
@@ -127,3 +128,47 @@ def query_api(openobserve_base_url, auth_credentials, test_org):
|
||||
return response.json()
|
||||
|
||||
return _query
|
||||
|
||||
|
||||
# The SSRF guard refuses loopback and private destination URLs.
|
||||
WEBHOOK_URL = "https://example.com/o2-db-testing"
|
||||
|
||||
|
||||
def unique_name(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(openobserve_base_url, auth_credentials, test_org):
|
||||
"""Call `/api/{org}/<path>` (or `/api/v2/...` with v2=True) as root."""
|
||||
session = requests.Session()
|
||||
session.auth = auth_credentials
|
||||
|
||||
def _call(method: str, path: str, *, v2: bool = False, **kwargs) -> requests.Response:
|
||||
prefix = "api/v2" if v2 else "api"
|
||||
return session.request(method, f"{openobserve_base_url}/{prefix}/{test_org}/{path}", **kwargs)
|
||||
|
||||
yield _call
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def destination(api, ingest_test_data):
|
||||
"""An http template + webhook destination, on a stream that exists."""
|
||||
ingest_test_data([{"level": "error", "message": "roundtrip seed"}])
|
||||
template = unique_name("rt_template")
|
||||
resp = api("POST", "alerts/templates", json={"name": template, "body": '{"text": "{alert_name}"}', "type": "http", "title": ""})
|
||||
assert resp.status_code == 200, f"template create: {resp.status_code} {resp.text}"
|
||||
|
||||
dest = unique_name("rt_dest")
|
||||
resp = api(
|
||||
"POST",
|
||||
"alerts/destinations",
|
||||
json={"name": dest, "url": WEBHOOK_URL, "method": "post", "template": template, "headers": {}, "skip_tls_verify": False},
|
||||
)
|
||||
assert resp.status_code == 200, f"destination create: {resp.status_code} {resp.text}"
|
||||
|
||||
yield dest
|
||||
|
||||
api("DELETE", f"alerts/destinations/{dest}")
|
||||
api("DELETE", f"alerts/templates/{template}")
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Alerts with every optional field set must save, read back, update and clear on Postgres (empty fields are NULL and skip sqlx's type check)."""
|
||||
import pytest
|
||||
|
||||
from conftest import unique_name
|
||||
|
||||
LONG_TEXT = "Überwachung ✓ 監視 " * 120
|
||||
|
||||
|
||||
def _alert_payload(name: str, stream: str, dest: str) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"stream_type": "logs",
|
||||
"stream_name": stream,
|
||||
"is_real_time": False,
|
||||
"query_condition": {
|
||||
"type": "custom",
|
||||
"conditions": {"version": 2, "conditions": {"filterType": "group", "logicalOperator": "AND", "conditions": []}},
|
||||
"sql": None, "promql": None, "promql_condition": None, "aggregation": None,
|
||||
"vrl_function": None, "search_event_type": None, "multi_time_range": [],
|
||||
},
|
||||
"trigger_condition": {
|
||||
"period": 10, "operator": ">=", "threshold": 5, "frequency": 10, "cron": "",
|
||||
"frequency_type": "minutes", "silence": 10, "timezone": "UTC", "align_time": True,
|
||||
"warning_threshold": 2, "notify_on_warning": True,
|
||||
},
|
||||
"destinations": [dest],
|
||||
"context_attributes": {"team": "qa", "service": "db-testing"},
|
||||
"row_template": "{level}: {message}",
|
||||
"enabled": True,
|
||||
"description": "db-testing roundtrip",
|
||||
"priority": 2,
|
||||
"tags": ["roundtrip", "service:db-testing"],
|
||||
"runbook_url": "https://example.com/runbooks/roundtrip",
|
||||
"deduplication": {"enabled": True, "fingerprint_fields": ["level"], "time_window_minutes": 30},
|
||||
"notify_on_recovery": True,
|
||||
# Regression for #15434: a non-empty list forces the decode that broke on Postgres.
|
||||
"recovery_destinations": [dest],
|
||||
"pending_period_sec": 60,
|
||||
"keep_firing_for": 60,
|
||||
}
|
||||
|
||||
|
||||
def _realtime(payload: dict) -> dict:
|
||||
# Realtime alerts reject pending, warning, recovery and keep-firing settings (alert.rs validate).
|
||||
payload.update(is_real_time=True, pending_period_sec=0, notify_on_recovery=False, recovery_destinations=[], keep_firing_for=0)
|
||||
payload["trigger_condition"].pop("warning_threshold")
|
||||
payload["trigger_condition"].pop("notify_on_warning")
|
||||
return payload
|
||||
|
||||
|
||||
def _grouped(payload: dict) -> dict:
|
||||
payload["query_condition"]["aggregation"] = {
|
||||
"group_by": ["level"], "function": "count",
|
||||
"having": {"column": "level", "operator": ">=", "value": 1},
|
||||
}
|
||||
payload["query_condition"]["multi_time_range"] = [{"offSet": "1h"}]
|
||||
payload["trigger_condition"]["threshold"] = 1
|
||||
payload["trigger_condition"].pop("warning_threshold")
|
||||
payload["trigger_condition"].pop("notify_on_warning")
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def created_alerts(api):
|
||||
ids = []
|
||||
yield ids
|
||||
for alert_id in ids:
|
||||
api("DELETE", f"alerts/{alert_id}", v2=True)
|
||||
|
||||
|
||||
def _create(api, created_alerts, payload: dict) -> str:
|
||||
resp = api("POST", "alerts?folder=default", v2=True, json=payload)
|
||||
assert resp.status_code == 200, f"create {payload['name']}: {resp.status_code} {resp.text}"
|
||||
alert_id = resp.json().get("id")
|
||||
assert alert_id, f"create must return the new id: {resp.text}"
|
||||
created_alerts.append(alert_id)
|
||||
return alert_id
|
||||
|
||||
|
||||
def _read(api, alert_id: str) -> dict:
|
||||
resp = api("GET", f"alerts/{alert_id}", v2=True)
|
||||
assert resp.status_code == 200, f"read back {alert_id}: {resp.status_code} {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _update(api, alert_id: str, alert: dict) -> dict:
|
||||
resp = api("PUT", f"alerts/{alert_id}?folder=default", v2=True, json=alert)
|
||||
assert resp.status_code == 200, f"update {alert_id}: {resp.status_code} {resp.text}"
|
||||
return _read(api, alert_id)
|
||||
|
||||
|
||||
def _assert_filled(stored: dict, dest: str, *, recovery: bool = True) -> None:
|
||||
assert stored["recovery_destinations"] == ([dest] if recovery else [])
|
||||
assert stored["destinations"] == [dest]
|
||||
assert stored["context_attributes"] == {"team": "qa", "service": "db-testing"}
|
||||
assert stored["tags"] == ["roundtrip", "service:db-testing"]
|
||||
assert stored["deduplication"]["fingerprint_fields"] == ["level"]
|
||||
assert stored["runbook_url"] == "https://example.com/runbooks/roundtrip"
|
||||
|
||||
|
||||
class TestAlertRoundtrip:
|
||||
@pytest.mark.parametrize("realtime", [False, True], ids=["scheduled", "realtime"])
|
||||
def test_alert_with_every_optional_field_saves_reads_and_updates(self, api, destination, test_stream, created_alerts, realtime):
|
||||
payload = _alert_payload(unique_name("rt_alert"), test_stream, destination)
|
||||
alert_id = _create(api, created_alerts, _realtime(payload) if realtime else payload)
|
||||
stored = _read(api, alert_id)
|
||||
_assert_filled(stored, destination, recovery=not realtime)
|
||||
if realtime:
|
||||
assert "warning_threshold" not in stored["trigger_condition"]
|
||||
else:
|
||||
assert stored["trigger_condition"]["warning_threshold"] == 2
|
||||
|
||||
stored["description"] = "db-testing roundtrip updated"
|
||||
updated = _update(api, alert_id, stored)
|
||||
assert updated["description"] == "db-testing roundtrip updated"
|
||||
_assert_filled(updated, destination, recovery=not realtime)
|
||||
|
||||
resp = api("GET", "alerts", v2=True)
|
||||
assert resp.status_code == 200, f"list: {resp.status_code} {resp.text}"
|
||||
assert alert_id in [a.get("alert_id") for a in resp.json()["list"]]
|
||||
|
||||
def test_grouped_alert_with_comparison_window_roundtrips(self, api, destination, test_stream, created_alerts):
|
||||
alert_id = _create(api, created_alerts, _grouped(_alert_payload(unique_name("rt_grouped"), test_stream, destination)))
|
||||
stored = _read(api, alert_id)
|
||||
assert stored["query_condition"]["aggregation"]["group_by"] == ["level"]
|
||||
assert stored["query_condition"]["multi_time_range"] == [{"offSet": "1h"}]
|
||||
_assert_filled(stored, destination)
|
||||
|
||||
_update(api, alert_id, stored)
|
||||
|
||||
def test_optional_fields_clear_to_empty_and_fill_again(self, api, destination, test_stream, created_alerts):
|
||||
alert_id = _create(api, created_alerts, _alert_payload(unique_name("rt_clear"), test_stream, destination))
|
||||
stored = _read(api, alert_id)
|
||||
|
||||
stored.update(recovery_destinations=[], tags=[], context_attributes={}, deduplication={"enabled": False})
|
||||
stored.pop("runbook_url")
|
||||
cleared = _update(api, alert_id, stored)
|
||||
assert cleared["recovery_destinations"] == []
|
||||
assert cleared["context_attributes"] == {}
|
||||
# The API omits empty tags, an unset runbook and disabled dedup instead of sending empties.
|
||||
for absent in ("tags", "runbook_url", "deduplication"):
|
||||
assert absent not in cleared, f"{absent} must be cleared, got {cleared[absent]!r}"
|
||||
|
||||
filled = _alert_payload(cleared["name"], test_stream, destination)
|
||||
refill = {k: filled[k] for k in ("recovery_destinations", "tags", "context_attributes", "runbook_url", "deduplication")}
|
||||
_assert_filled(_update(api, alert_id, {**cleared, **refill}), destination)
|
||||
|
||||
def test_unicode_and_long_values_roundtrip(self, api, destination, test_stream, created_alerts):
|
||||
payload = _alert_payload(unique_name("rt_unicode"), test_stream, destination)
|
||||
payload["description"] = LONG_TEXT
|
||||
payload["context_attributes"] = {"owner": "équipe-监控", "note": LONG_TEXT}
|
||||
alert_id = _create(api, created_alerts, payload)
|
||||
|
||||
stored = _read(api, alert_id)
|
||||
assert stored["description"] == LONG_TEXT
|
||||
assert stored["context_attributes"] == {"owner": "équipe-监控", "note": LONG_TEXT}
|
||||
|
||||
|
||||
class TestCompositeAlertRoundtrip:
|
||||
def test_composite_with_every_optional_field_roundtrips(self, api, destination, test_stream, created_alerts):
|
||||
children = [_create(api, created_alerts, _alert_payload(unique_name("rt_child"), test_stream, destination)) for _ in range(2)]
|
||||
composite = {
|
||||
"alert_type": "composite",
|
||||
"name": unique_name("rt_composite"),
|
||||
"description": "db-testing composite",
|
||||
"enabled": False,
|
||||
"destinations": [destination],
|
||||
"context_attributes": {"service": "db-testing"},
|
||||
"trigger_condition": {"silence": 15},
|
||||
"creates_incident": False,
|
||||
"workflows": [],
|
||||
"priority": 2,
|
||||
"tags": ["env:test", "kind:composite"],
|
||||
"composite_condition": {
|
||||
"expression": " && ".join(f"{{{c}}}" for c in children),
|
||||
"warning_counts_as_firing": True,
|
||||
"stale_child_policy": "use_last_state",
|
||||
},
|
||||
}
|
||||
resp = api("POST", "alerts?folder=default", v2=True, json=composite)
|
||||
assert resp.status_code == 200, f"composite create: {resp.status_code} {resp.text}"
|
||||
listed = api("GET", "alerts", v2=True)
|
||||
assert listed.status_code == 200, f"list: {listed.status_code} {listed.text}"
|
||||
row = next((a for a in listed.json()["list"] if a.get("name") == composite["name"]), None)
|
||||
assert row, f"{composite['name']} missing from the alert list"
|
||||
# A composite must be deleted before the children it references.
|
||||
created_alerts.insert(0, row["alert_id"])
|
||||
|
||||
stored = _read(api, row["alert_id"])
|
||||
assert stored["destinations"] == [destination]
|
||||
assert stored["context_attributes"] == {"service": "db-testing"}
|
||||
assert stored["tags"] == ["env:test", "kind:composite"]
|
||||
|
||||
# Composite saves reject read-only fields (e.g. `id`) that a read returns.
|
||||
update = {**composite, "description": "db-testing composite updated"}
|
||||
assert _update(api, row["alert_id"], update)["description"] == "db-testing composite updated"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Every SeaORM entity column must match the migrated Postgres column type and nullability (SQLite hides drift)."""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ENTITY_DIRS adds entity roots (e.g. o2-enterprise cloud tables); BUILD_FEATURES resolves #[cfg(feature)] fields.
|
||||
OSS_ENTITY_DIR = Path(__file__).resolve().parents[3] / "src" / "infra" / "src" / "table"
|
||||
ENTITY_DIRS = [OSS_ENTITY_DIR] + [Path(p) for p in os.getenv("ENTITY_DIRS", "").split(os.pathsep) if p]
|
||||
BUILD_FEATURES = {f.strip() for f in os.getenv("BUILD_FEATURES", "").split(",") if f.strip()}
|
||||
|
||||
PG_TYPES = {
|
||||
"Json": {"json", "jsonb"},
|
||||
"serde_json::Value": {"json", "jsonb"},
|
||||
"String": {"text", "character varying", "character"},
|
||||
"i64": {"bigint"},
|
||||
"i32": {"integer"},
|
||||
"i16": {"smallint"},
|
||||
"bool": {"boolean"},
|
||||
"f64": {"double precision"},
|
||||
"f32": {"real"},
|
||||
"Vec<u8>": {"bytea"},
|
||||
"DateTime": {"timestamp without time zone"},
|
||||
"DateTimeWithTimeZone": {"timestamp with time zone"},
|
||||
}
|
||||
|
||||
TABLE_RE = re.compile(r'#\[sea_orm\([^\]]*table_name\s*=\s*"([^"]+)"')
|
||||
MODEL_RE = re.compile(r"pub struct Model\s*\{(.*?)\n\}", re.S)
|
||||
FIELD_RE = re.compile(r"^\s*pub\s+(?:r#)?(\w+)\s*:\s*([^,]+),", re.M)
|
||||
ATTR_RE = re.compile(r"#\[sea_orm\(((?:(?!#\[).)*?)\)\]\s*$", re.S)
|
||||
COLUMN_NAME_RE = re.compile(r'column_name\s*=\s*"([^"]+)"')
|
||||
ACTIVE_ENUM_RE = re.compile(r'#\[sea_orm\(rs_type\s*=\s*"([^"]+)"[^\]]*\)\]\s*pub enum (\w+)')
|
||||
CFG_RE = re.compile(r'#\[cfg\((not\()?feature\s*=\s*"([^"]+)"\)?\)\]')
|
||||
|
||||
# Columns whose migrations live in o2-enterprise's cloud migrator, so only a cloud build adds them.
|
||||
ENTERPRISE_CLOUD_COLUMNS = {
|
||||
("trial_quota_usage", "usage_limit"),
|
||||
("trial_quota_usage", "period"),
|
||||
}
|
||||
|
||||
# Nullable in the migration but always written as a String by the app, so no NULL reaches the decoder.
|
||||
WRITTEN_NON_NULL_COLUMNS = {
|
||||
("org_ingestion_tokens", "description"),
|
||||
}
|
||||
|
||||
|
||||
def _entity_files() -> list[Path]:
|
||||
files = []
|
||||
for root in ENTITY_DIRS:
|
||||
# Hand-written table modules first: the code uses them, `entity/` can hold stale codegen copies.
|
||||
files += sorted(root.glob("*.rs")) + sorted((root / "entity").glob("*.rs"))
|
||||
return [f for f in files if "DeriveEntityModel" in f.read_text()]
|
||||
|
||||
|
||||
def _compiled(attrs: str) -> bool:
|
||||
for negated, feature in CFG_RE.findall(attrs):
|
||||
if (feature in BUILD_FEATURES) == bool(negated):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _columns(model_body: str) -> list[tuple[str, str, bool]]:
|
||||
columns = []
|
||||
prev_end = 0
|
||||
for m in FIELD_RE.finditer(model_body):
|
||||
name, rust_type = m.group(1), m.group(2).strip()
|
||||
attrs = model_body[prev_end : m.start()]
|
||||
prev_end = m.end()
|
||||
# A field cfg'd out of this build is never decoded by it.
|
||||
if not _compiled(attrs):
|
||||
continue
|
||||
attr = ATTR_RE.search(model_body[: m.start()])
|
||||
if attr:
|
||||
renamed = COLUMN_NAME_RE.search(attr.group(1))
|
||||
if renamed:
|
||||
name = renamed.group(1)
|
||||
inner = re.fullmatch(r"Option<(.+)>", rust_type)
|
||||
columns.append((name, inner.group(1) if inner else rust_type, inner is not None))
|
||||
return columns
|
||||
|
||||
|
||||
def _entities() -> list[tuple[str, list[tuple[str, str, bool]]]]:
|
||||
entities = {}
|
||||
for path in _entity_files():
|
||||
source = path.read_text().split("#[cfg(test)]")[0]
|
||||
table, model = TABLE_RE.search(source), MODEL_RE.search(source)
|
||||
if table and model:
|
||||
entities.setdefault(table.group(1), _columns(model.group(1)))
|
||||
return sorted(entities.items())
|
||||
|
||||
|
||||
def _schema(db_cursor, table: str) -> dict[str, tuple[str, bool]]:
|
||||
db_cursor.execute(
|
||||
"SELECT column_name, data_type, is_nullable FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND table_name = %s",
|
||||
(table,),
|
||||
)
|
||||
schema = {name: (data_type, nullable == "YES") for name, data_type, nullable in db_cursor.fetchall()}
|
||||
assert schema, f"table {table} has an entity but no migration created it"
|
||||
return schema
|
||||
|
||||
|
||||
def _checked(table: str, columns, schema):
|
||||
for column, rust_type, optional in columns:
|
||||
if (table, column) in ENTERPRISE_CLOUD_COLUMNS and "cloud" not in BUILD_FEATURES:
|
||||
continue
|
||||
yield column, rust_type, optional, schema.get(column)
|
||||
|
||||
|
||||
def _active_enums() -> dict[str, str]:
|
||||
"""DeriveActiveEnum name -> the Rust type it is stored as."""
|
||||
enums = {}
|
||||
for root in ENTITY_DIRS:
|
||||
for path in sorted((root / "entity").glob("*.rs")) + sorted(root.glob("*.rs")):
|
||||
for rs_type, name in ACTIVE_ENUM_RE.findall(path.read_text()):
|
||||
enums[name] = rs_type
|
||||
return enums
|
||||
|
||||
|
||||
ENTITIES = _entities()
|
||||
ACTIVE_ENUMS = _active_enums()
|
||||
|
||||
|
||||
def test_entity_sources_were_found():
|
||||
assert len(ENTITIES) > 100, f"expected the entity sources under {ENTITY_DIRS}, parsed {len(ENTITIES)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table,columns", ENTITIES, ids=[t for t, _ in ENTITIES])
|
||||
def test_entity_column_types_match_postgres(db_cursor, table, columns):
|
||||
schema = _schema(db_cursor, table)
|
||||
mismatches = []
|
||||
for column, rust_type, _, actual in _checked(table, columns, schema):
|
||||
expected = PG_TYPES.get(ACTIVE_ENUMS.get(rust_type, rust_type))
|
||||
if expected is None:
|
||||
mismatches.append(f"{column}: entity type {rust_type} has no Postgres mapping; add it to PG_TYPES")
|
||||
elif actual is None:
|
||||
mismatches.append(f"{column}: entity has it, table does not")
|
||||
elif actual[0] not in expected:
|
||||
mismatches.append(f"{column}: entity {rust_type} needs {sorted(expected)}, table has {actual[0]}")
|
||||
assert not mismatches, f"{table} entity and Postgres schema disagree:\n " + "\n ".join(mismatches)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table,columns", ENTITIES, ids=[t for t, _ in ENTITIES])
|
||||
def test_entity_nullability_matches_postgres(db_cursor, table, columns):
|
||||
schema = _schema(db_cursor, table)
|
||||
mismatches = []
|
||||
for column, _, optional, actual in _checked(table, columns, schema):
|
||||
if actual is None:
|
||||
continue
|
||||
if actual[1] and not optional and (table, column) not in WRITTEN_NON_NULL_COLUMNS:
|
||||
mismatches.append(f"{column}: column allows NULL, entity field is not Option and cannot decode it")
|
||||
elif optional and not actual[1]:
|
||||
mismatches.append(f"{column}: entity field is Option, column is NOT NULL so saving None fails")
|
||||
assert not mismatches, f"{table} entity and Postgres nullability disagree:\n " + "\n ".join(mismatches)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Objects stored in JSON columns (dashboards, annotations, reports, SLOs, saved views) must save, read back and update on Postgres."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import unique_name
|
||||
|
||||
PANEL_ID = "panel_1"
|
||||
|
||||
|
||||
def _ok(resp, what: str):
|
||||
assert resp.status_code == 200, f"{what}: {resp.status_code} {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _dashboard_payload(title: str) -> dict:
|
||||
return {
|
||||
"version": 8,
|
||||
"title": title,
|
||||
"description": "db-testing dashboard ✓",
|
||||
"tabs": [{
|
||||
"tabId": "default",
|
||||
"name": "Default",
|
||||
"panels": [{
|
||||
"id": PANEL_ID,
|
||||
"type": "bar",
|
||||
"title": "Roundtrip panel",
|
||||
"description": "",
|
||||
"config": {"show_legends": True, "legends_position": None, "axis_border_show": False},
|
||||
"queryType": "sql",
|
||||
"queries": [],
|
||||
"layout": {"x": 0, "y": 0, "w": 12, "h": 6, "i": 1},
|
||||
}],
|
||||
}],
|
||||
"variables": {"list": [{"type": "constant", "name": "env", "label": "env", "value": "prod"}], "showDynamicFilters": True},
|
||||
}
|
||||
|
||||
|
||||
def _inner(body: dict) -> dict:
|
||||
return body.get(f"v{body.get('version')}") or body
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard(api):
|
||||
created = _ok(api("POST", "dashboards?folder=default", json=_dashboard_payload(unique_name("rt_dash"))), "dashboard create")
|
||||
dashboard_id = _inner(created)["dashboardId"]
|
||||
yield dashboard_id
|
||||
api("DELETE", f"dashboards/{dashboard_id}?folder=default")
|
||||
|
||||
|
||||
class TestDashboardRoundtrip:
|
||||
def test_dashboard_with_panels_and_variables_saves_reads_and_updates(self, api, dashboard):
|
||||
body = _ok(api("GET", f"dashboards/{dashboard}?folder=default"), "dashboard read")
|
||||
stored = _inner(body)
|
||||
assert stored["tabs"][0]["panels"][0]["id"] == PANEL_ID
|
||||
assert stored["variables"]["list"][0]["value"] == "prod"
|
||||
|
||||
stored["description"] = "db-testing dashboard updated"
|
||||
_ok(api("PUT", f"dashboards/{dashboard}?folder=default&hash={body['hash']}", json=stored), "dashboard update")
|
||||
assert _inner(_ok(api("GET", f"dashboards/{dashboard}?folder=default"), "dashboard re-read"))["description"] == "db-testing dashboard updated"
|
||||
|
||||
def test_timed_annotation_with_tags_roundtrips(self, api, dashboard):
|
||||
end = int(time.time() * 1_000_000)
|
||||
start = end - 3_600_000_000
|
||||
annotation = {"title": "Deploy ✓", "text": "db-testing release", "panels": [PANEL_ID], "start_time": start, "end_time": end, "tags": ["deploy", "db-testing"]}
|
||||
_ok(api("POST", f"dashboards/{dashboard}/annotations", json={"timed_annotations": [annotation]}), "annotation create")
|
||||
|
||||
listed = _ok(api("GET", f"dashboards/{dashboard}/annotations", params={"panels": PANEL_ID, "start_time": start - 3_600_000_000, "end_time": end + 3_600_000_000}), "annotation read")
|
||||
rows = listed if isinstance(listed, list) else listed.get("timed_annotations", listed.get("list", []))
|
||||
match = [r for r in rows if r.get("title") == "Deploy ✓"]
|
||||
assert match, f"annotation missing from read back: {listed}"
|
||||
assert match[0]["tags"] == ["deploy", "db-testing"]
|
||||
|
||||
|
||||
class TestReportRoundtrip:
|
||||
def test_report_with_every_dashboard_option_roundtrips(self, api, dashboard):
|
||||
name = unique_name("rt_report")
|
||||
report = {
|
||||
"name": name,
|
||||
"title": "db-testing report",
|
||||
"orgId": "default",
|
||||
"description": "db-testing report ✓",
|
||||
"message": "weekly numbers",
|
||||
"enabled": False,
|
||||
"frequency": {"type": "days", "interval": 1, "cron": "", "align_time": False},
|
||||
"start": int(time.time() * 1_000_000) + 3_600_000_000,
|
||||
"timezone": "Asia/Kolkata",
|
||||
"timezoneOffset": 330,
|
||||
"dashboards": [{
|
||||
"dashboard": dashboard,
|
||||
"folder": "default",
|
||||
"tabs": ["default"],
|
||||
"variables": [{"key": "env", "value": "prod"}],
|
||||
"timerange": {"type": "relative", "period": "30m", "from": 0, "to": 0},
|
||||
"report_type": "png",
|
||||
"email_attachment_type": "inline",
|
||||
"attachment_dimensions": {"width": 1280, "height": 720},
|
||||
}],
|
||||
"destinations": [{"email": "qa@example.com"}],
|
||||
}
|
||||
try:
|
||||
_ok(api("POST", "reports?folder=default", json=report), "report create")
|
||||
stored = _ok(api("GET", f"reports/{name}?folder=default"), "report read")
|
||||
entry = stored["dashboards"][0]
|
||||
assert entry["variables"] == [{"key": "env", "value": "prod"}]
|
||||
assert entry["timerange"]["period"] == "30m"
|
||||
assert entry["attachment_dimensions"] == {"width": 1280, "height": 720}
|
||||
assert stored["destinations"] == [{"email": "qa@example.com"}]
|
||||
|
||||
stored["description"] = "db-testing report updated"
|
||||
_ok(api("PUT", f"reports/{name}?folder=default", json=stored), "report update")
|
||||
assert _ok(api("GET", f"reports/{name}?folder=default"), "report re-read")["description"] == "db-testing report updated"
|
||||
finally:
|
||||
api("DELETE", f"reports/{name}?folder=default")
|
||||
|
||||
|
||||
class TestSloRoundtrip:
|
||||
def test_grouped_slo_with_tags_roundtrips(self, api, ingest_test_data, test_stream):
|
||||
ingest_test_data([{"level": "info", "status_code": 200, "service": "checkout"}])
|
||||
slo = {
|
||||
"name": unique_name("rt_slo"),
|
||||
"description": "db-testing slo ✓",
|
||||
"sli_type": "count",
|
||||
"config": {"source": {"mode": "single_query", "query": {"stream": test_stream, "stream_type": "logs", "good_expr": "status_code < 500"}}},
|
||||
"group_by": ["service"],
|
||||
"groups_estimate": 3,
|
||||
"window_secs": 604800,
|
||||
"slice_interval_secs": 300,
|
||||
"target": 99,
|
||||
"tags": ["db-testing", "tier:1"],
|
||||
"enabled": False,
|
||||
}
|
||||
slo_id = _ok(api("POST", "slos", json=slo), "slo create")["id"]
|
||||
try:
|
||||
stored = _ok(api("GET", f"slos/{slo_id}"), "slo read")
|
||||
assert stored["group_by"] == ["service"]
|
||||
assert stored["tags"] == ["db-testing", "tier:1"]
|
||||
assert stored["config"]["source"]["query"]["good_expr"] == "status_code < 500"
|
||||
|
||||
stored["description"] = "db-testing slo updated"
|
||||
_ok(api("PUT", f"slos/{slo_id}", json=stored), "slo update")
|
||||
assert _ok(api("GET", f"slos/{slo_id}"), "slo re-read")["description"] == "db-testing slo updated"
|
||||
finally:
|
||||
api("DELETE", f"slos/{slo_id}")
|
||||
|
||||
|
||||
class TestSavedViewRoundtrip:
|
||||
def test_saved_view_with_nested_data_roundtrips(self, api):
|
||||
data = {"query": 'SELECT * FROM "default"', "filters": [{"field": "level", "values": ["error", "警告"]}], "nested": {"depth": {"ok": True}}}
|
||||
view_id = _ok(api("POST", "savedviews", json={"view_name": unique_name("rt_view"), "data": data}), "saved view create")["view_id"]
|
||||
try:
|
||||
assert _ok(api("GET", f"savedviews/{view_id}"), "saved view read")["data"] == data
|
||||
finally:
|
||||
api("DELETE", f"savedviews/{view_id}")
|
||||
@@ -0,0 +1,271 @@
|
||||
// cloudApiSmokePage.js - API-only CRUD round-trips against the deployed meta store.
|
||||
// Cloud runs on Postgres, which rejects column-type drift that SQLite (local CI) accepts.
|
||||
const { expect } = require('@playwright/test');
|
||||
const APICleanup = require('../apiCleanup.js');
|
||||
const testLogger = require('../../playwright-tests/utils/test-logger.js');
|
||||
const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
|
||||
const { ingestCustomData, waitForStreamListed } = require('../../playwright-tests/utils/data-ingestion.js');
|
||||
|
||||
// Alpha blocks private and loopback hosts for destinations (SSRF guard).
|
||||
const WEBHOOK_URL = 'https://example.com/o2-cloud-api-smoke';
|
||||
const PANEL_ID = 'panel_1';
|
||||
|
||||
class CloudApiSmokePage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
this.api = new APICleanup(page);
|
||||
this.baseUrl = (process.env.ZO_BASE_URL || '').replace(/\/$/, '');
|
||||
this.org = getOrgIdentifier();
|
||||
}
|
||||
|
||||
uniqueSuffix() {
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
// ===== GENERIC REQUEST =====
|
||||
|
||||
async call(method, path, body) {
|
||||
const response = await this.api._fetch(`${this.baseUrl}/${path}`, {
|
||||
method,
|
||||
headers: { 'Authorization': this.api.authHeader, 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch { /* non-JSON body is reported via text */ }
|
||||
return { status: response.status, json, text };
|
||||
}
|
||||
|
||||
async expectOk(method, path, body) {
|
||||
const res = await this.call(method, path, body);
|
||||
expect(res.status, `${method} /${path} -> ${res.status}: ${res.text.slice(0, 500)}`).toBe(200);
|
||||
return res.json;
|
||||
}
|
||||
|
||||
// ===== SEED DATA =====
|
||||
|
||||
async seedStream(streamName) {
|
||||
const res = await ingestCustomData(this.page, streamName, [{ level: 'error', message: 'cloud api smoke seed' }]);
|
||||
expect(res.status, `ingest into ${streamName}: ${JSON.stringify(res.data)}`).toBe(200);
|
||||
expect(await waitForStreamListed(this.page, streamName), `${streamName} must be listed`).toBe(true);
|
||||
}
|
||||
|
||||
// ===== ALERTS =====
|
||||
|
||||
async createTemplate(name) {
|
||||
await this.expectOk('POST', `api/${this.org}/alerts/templates`, {
|
||||
name, body: '{"text": "{alert_name}"}', type: 'http', title: '',
|
||||
});
|
||||
}
|
||||
|
||||
async createDestination(name, template) {
|
||||
await this.expectOk('POST', `api/${this.org}/alerts/destinations`, {
|
||||
name, url: WEBHOOK_URL, method: 'post', template, headers: {}, skip_tls_verify: false,
|
||||
});
|
||||
}
|
||||
|
||||
async getDestination(name) {
|
||||
return this.expectOk('GET', `api/${this.org}/alerts/destinations/${name}`);
|
||||
}
|
||||
|
||||
alertPayload(name, stream, destination, { realtime = false } = {}) {
|
||||
const payload = {
|
||||
name,
|
||||
stream_type: 'logs',
|
||||
stream_name: stream,
|
||||
is_real_time: realtime,
|
||||
query_condition: {
|
||||
type: 'custom',
|
||||
conditions: { version: 2, conditions: { filterType: 'group', logicalOperator: 'AND', conditions: [] } },
|
||||
sql: null, promql: null, promql_condition: null, aggregation: null,
|
||||
vrl_function: null, search_event_type: null, multi_time_range: [],
|
||||
},
|
||||
trigger_condition: {
|
||||
period: 10, operator: '>=', threshold: 5, frequency: 10, cron: '',
|
||||
frequency_type: 'minutes', silence: 10, timezone: 'UTC', align_time: true,
|
||||
warning_threshold: 2, notify_on_warning: true,
|
||||
},
|
||||
destinations: [destination],
|
||||
context_attributes: { team: 'qa' },
|
||||
row_template: '',
|
||||
enabled: false,
|
||||
description: 'cloud api smoke',
|
||||
priority: 2,
|
||||
tags: ['cloud-api-smoke'],
|
||||
runbook_url: 'https://example.com/runbooks/cloud-api-smoke',
|
||||
deduplication: { enabled: true, fingerprint_fields: ['level'], time_window_minutes: 30 },
|
||||
notify_on_recovery: true,
|
||||
recovery_destinations: [destination],
|
||||
pending_period_sec: 60,
|
||||
keep_firing_for: 60,
|
||||
};
|
||||
if (!realtime) return payload;
|
||||
// Realtime alerts reject pending, warning, recovery and keep-firing settings.
|
||||
const { warning_threshold: _w, notify_on_warning: _n, ...trigger } = payload.trigger_condition;
|
||||
return { ...payload, trigger_condition: trigger, pending_period_sec: 0, notify_on_recovery: false, recovery_destinations: [], keep_firing_for: 0 };
|
||||
}
|
||||
|
||||
async createAlert(payload) {
|
||||
const created = await this.expectOk('POST', `api/v2/${this.org}/alerts?folder=default`, payload);
|
||||
expect(created?.id, 'alert create must return its id').toBeTruthy();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async getAlert(id) {
|
||||
return this.expectOk('GET', `api/v2/${this.org}/alerts/${id}`);
|
||||
}
|
||||
|
||||
async updateAlert(id, payload) {
|
||||
await this.expectOk('PUT', `api/v2/${this.org}/alerts/${id}?folder=default`, payload);
|
||||
}
|
||||
|
||||
clearedAlert(stored) {
|
||||
const { runbook_url: _runbook, ...rest } = stored;
|
||||
return { ...rest, recovery_destinations: [], tags: [], context_attributes: {}, deduplication: { enabled: false } };
|
||||
}
|
||||
|
||||
// ===== DASHBOARDS =====
|
||||
|
||||
async createDashboard(title) {
|
||||
const created = await this.expectOk('POST', `api/${this.org}/dashboards?folder=default`, {
|
||||
version: 8, title, description: 'cloud api smoke',
|
||||
tabs: [{
|
||||
tabId: 'default', name: 'Default',
|
||||
panels: [{
|
||||
id: PANEL_ID, type: 'bar', title: 'Smoke panel', description: '',
|
||||
config: { show_legends: true, legends_position: null, axis_border_show: false },
|
||||
queryType: 'sql', queries: [], layout: { x: 0, y: 0, w: 12, h: 6, i: 1 },
|
||||
}],
|
||||
}],
|
||||
variables: { list: [{ type: 'constant', name: 'env', label: 'env', value: 'prod' }], showDynamicFilters: true },
|
||||
});
|
||||
const inner = created[`v${created.version}`] || created;
|
||||
const id = inner.dashboardId || inner.dashboard_id;
|
||||
expect(id, 'dashboard create must return its id').toBeTruthy();
|
||||
return id;
|
||||
}
|
||||
|
||||
async getDashboard(id) {
|
||||
return this.expectOk('GET', `api/${this.org}/dashboards/${id}?folder=default`);
|
||||
}
|
||||
|
||||
async addAnnotation(dashboardId, title, tags) {
|
||||
const end = Date.now() * 1000;
|
||||
const annotation = { title, text: 'cloud api smoke', panels: [PANEL_ID], start_time: end - 3_600_000_000, end_time: end, tags };
|
||||
await this.expectOk('POST', `api/${this.org}/dashboards/${dashboardId}/annotations`, { timed_annotations: [annotation] });
|
||||
return { start: annotation.start_time, end };
|
||||
}
|
||||
|
||||
async findAnnotation(dashboardId, title, { start, end }) {
|
||||
const query = `panels=${PANEL_ID}&start_time=${start - 3_600_000_000}&end_time=${end + 3_600_000_000}`;
|
||||
const body = await this.expectOk('GET', `api/${this.org}/dashboards/${dashboardId}/annotations?${query}`);
|
||||
const rows = Array.isArray(body) ? body : (body?.timed_annotations || body?.list || []);
|
||||
return rows.find((r) => r.title === title);
|
||||
}
|
||||
|
||||
// ===== SLOS =====
|
||||
|
||||
sloPayload(name, stream) {
|
||||
return {
|
||||
name,
|
||||
description: 'cloud api smoke',
|
||||
sli_type: 'count',
|
||||
config: { source: { mode: 'single_query', query: { stream, stream_type: 'logs', good_expr: "level != 'error'" } } },
|
||||
group_by: ['level'],
|
||||
groups_estimate: 3,
|
||||
window_secs: 604800,
|
||||
slice_interval_secs: 300,
|
||||
target: 99,
|
||||
tags: ['cloud-api-smoke', 'tier:1'],
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
async createSlo(payload) {
|
||||
const created = await this.expectOk('POST', `api/${this.org}/slos`, payload);
|
||||
expect(created?.id, 'SLO create must return its id').toBeTruthy();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async getSlo(id) {
|
||||
return this.expectOk('GET', `api/${this.org}/slos/${id}`);
|
||||
}
|
||||
|
||||
async updateSlo(id, payload) {
|
||||
await this.expectOk('PUT', `api/${this.org}/slos/${id}`, payload);
|
||||
}
|
||||
|
||||
// ===== FUNCTIONS =====
|
||||
|
||||
async createFunction(name) {
|
||||
await this.expectOk('POST', `api/${this.org}/functions`, {
|
||||
name, function: '.smoke = true\n.', params: 'row', transType: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async listFunctionNames() {
|
||||
const body = await this.expectOk('GET', `api/${this.org}/functions`);
|
||||
return (body?.list || []).map((f) => f.name);
|
||||
}
|
||||
|
||||
// ===== SAVED VIEWS =====
|
||||
|
||||
async createSavedView(name) {
|
||||
const created = await this.expectOk('POST', `api/${this.org}/savedviews`, {
|
||||
view_name: name, data: this.savedViewData(),
|
||||
});
|
||||
expect(created?.view_id, 'saved view create must return its id').toBeTruthy();
|
||||
return created.view_id;
|
||||
}
|
||||
|
||||
savedViewData() {
|
||||
return { query: 'SELECT * FROM "smoke"', smoke: true, filters: [{ field: 'level', values: ['error', '警告'] }], nested: { depth: { ok: true } } };
|
||||
}
|
||||
|
||||
async getSavedView(id) {
|
||||
return this.expectOk('GET', `api/${this.org}/savedviews/${id}`);
|
||||
}
|
||||
|
||||
// ===== LIST SWEEP =====
|
||||
|
||||
listEndpoints() {
|
||||
return [
|
||||
`api/v2/${this.org}/alerts`,
|
||||
`api/v2/${this.org}/folders/alerts`,
|
||||
`api/v2/${this.org}/folders/dashboards`,
|
||||
`api/${this.org}/alerts/destinations`,
|
||||
`api/${this.org}/alerts/templates`,
|
||||
`api/${this.org}/dashboards`,
|
||||
`api/${this.org}/functions`,
|
||||
`api/${this.org}/savedviews`,
|
||||
`api/${this.org}/pipelines`,
|
||||
`api/${this.org}/streams`,
|
||||
`api/${this.org}/slos`,
|
||||
`api/v2/${this.org}/downtimes`,
|
||||
`api/${this.org}/anomaly_detection`,
|
||||
];
|
||||
}
|
||||
|
||||
// ===== CLEANUP =====
|
||||
|
||||
async deleteLogged(path) {
|
||||
try {
|
||||
const res = await this.call('DELETE', path);
|
||||
if (res.status >= 300) testLogger.warn('Cloud API smoke cleanup failed', { path, status: res.status, body: res.text.slice(0, 200) });
|
||||
} catch (e) {
|
||||
testLogger.warn('Cloud API smoke cleanup errored', { path, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup({ alertId, alertIds = [], destination, template, dashboardId, functionName, viewId, sloId }) {
|
||||
for (const id of [alertId, ...alertIds].filter(Boolean)) await this.deleteLogged(`api/v2/${this.org}/alerts/${id}`);
|
||||
if (destination) await this.deleteLogged(`api/${this.org}/alerts/destinations/${destination}`);
|
||||
if (template) await this.deleteLogged(`api/${this.org}/alerts/templates/${template}`);
|
||||
if (dashboardId) await this.deleteLogged(`api/${this.org}/dashboards/${dashboardId}?folder=default`);
|
||||
if (functionName) await this.deleteLogged(`api/${this.org}/functions/${functionName}`);
|
||||
if (viewId) await this.deleteLogged(`api/${this.org}/savedviews/${viewId}`);
|
||||
if (sloId) await this.deleteLogged(`api/${this.org}/slos/${sloId}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CloudApiSmokePage };
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Cloud API smoke — create, read back and update core objects through the API only.
|
||||
*
|
||||
* Cloud's meta store is Postgres; local CI runs SQLite, which accepts column-type
|
||||
* drift that Postgres rejects on read (e.g. #15434: alerts.recovery_destinations).
|
||||
* Every optional field is filled because empty ones are stored as NULL and skip
|
||||
* the type check.
|
||||
*
|
||||
* @tags @cloudApiSmoke @smoke @cloud @all
|
||||
*/
|
||||
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const testLogger = require("../utils/test-logger.js");
|
||||
const { CloudApiSmokePage } = require("../../pages/cloudPages/cloudApiSmokePage.js");
|
||||
|
||||
// Shared seed stream for this file; cleanup.spec.js deletes it before each run.
|
||||
const STREAM = "cloud_api_smoke";
|
||||
|
||||
test.describe("Cloud API smoke", () => {
|
||||
let smoke;
|
||||
let created;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
smoke = new CloudApiSmokePage(page);
|
||||
created = {};
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await smoke.cleanup(created);
|
||||
});
|
||||
|
||||
for (const realtime of [false, true]) {
|
||||
test(
|
||||
`${realtime ? "realtime" : "scheduled"} alert with every optional field saves, reads back and updates`,
|
||||
{ tag: ["@cloudApiSmoke", "@alerts", "@smoke", "@P0", "@cloud", "@all"] },
|
||||
async () => {
|
||||
const id = smoke.uniqueSuffix();
|
||||
created.template = `smoke_tpl_${id}`;
|
||||
created.destination = `smoke_dest_${id}`;
|
||||
|
||||
testLogger.step("Seed the stream the alert reads from");
|
||||
await smoke.seedStream(STREAM);
|
||||
|
||||
testLogger.step("Create template and destination");
|
||||
await smoke.createTemplate(created.template);
|
||||
await smoke.createDestination(created.destination, created.template);
|
||||
expect((await smoke.getDestination(created.destination)).name).toBe(created.destination);
|
||||
|
||||
testLogger.step("Create the alert with recovery destinations and every optional field");
|
||||
created.alertId = await smoke.createAlert(smoke.alertPayload(`smoke_alert_${id}`, STREAM, created.destination, { realtime }));
|
||||
|
||||
testLogger.step("Read it back");
|
||||
const stored = await smoke.getAlert(created.alertId);
|
||||
const recovery = realtime ? [] : [created.destination];
|
||||
expect(stored.recovery_destinations).toEqual(recovery);
|
||||
expect(stored.context_attributes).toEqual({ team: "qa" });
|
||||
expect(stored.tags).toEqual(["cloud-api-smoke"]);
|
||||
expect(stored.deduplication.fingerprint_fields).toEqual(["level"]);
|
||||
// The API omits an unset warning threshold rather than sending null.
|
||||
if (realtime) expect(stored.trigger_condition).not.toHaveProperty("warning_threshold");
|
||||
else expect(stored.trigger_condition.warning_threshold).toBe(2);
|
||||
|
||||
testLogger.step("Update it and read it back again");
|
||||
stored.description = "cloud api smoke updated";
|
||||
await smoke.updateAlert(created.alertId, stored);
|
||||
const updated = await smoke.getAlert(created.alertId);
|
||||
expect(updated.description).toBe("cloud api smoke updated");
|
||||
expect(updated.recovery_destinations).toEqual(recovery);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"alert optional fields clear to empty and fill again",
|
||||
{ tag: ["@cloudApiSmoke", "@alerts", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
const id = smoke.uniqueSuffix();
|
||||
created.template = `smoke_tpl_${id}`;
|
||||
created.destination = `smoke_dest_${id}`;
|
||||
await smoke.seedStream(STREAM);
|
||||
await smoke.createTemplate(created.template);
|
||||
await smoke.createDestination(created.destination, created.template);
|
||||
const payload = smoke.alertPayload(`smoke_clear_${id}`, STREAM, created.destination);
|
||||
created.alertId = await smoke.createAlert(payload);
|
||||
|
||||
testLogger.step("Clear recovery destinations, tags, context, runbook and dedup");
|
||||
await smoke.updateAlert(created.alertId, smoke.clearedAlert(await smoke.getAlert(created.alertId)));
|
||||
const cleared = await smoke.getAlert(created.alertId);
|
||||
expect(cleared.recovery_destinations).toEqual([]);
|
||||
expect(cleared.context_attributes).toEqual({});
|
||||
// The API omits empty tags, an unset runbook and disabled dedup instead of sending empties.
|
||||
expect(cleared).not.toHaveProperty("tags");
|
||||
expect(cleared).not.toHaveProperty("runbook_url");
|
||||
expect(cleared).not.toHaveProperty("deduplication");
|
||||
|
||||
testLogger.step("Fill them again");
|
||||
await smoke.updateAlert(created.alertId, { ...cleared, ...payload });
|
||||
const refilled = await smoke.getAlert(created.alertId);
|
||||
expect(refilled.recovery_destinations).toEqual([created.destination]);
|
||||
expect(refilled.tags).toEqual(["cloud-api-smoke"]);
|
||||
expect(refilled.context_attributes).toEqual({ team: "qa" });
|
||||
expect(refilled.deduplication.fingerprint_fields).toEqual(["level"]);
|
||||
expect(refilled.runbook_url).toBe("https://example.com/runbooks/cloud-api-smoke");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"dashboard with panel, variables and an annotation saves and reads back",
|
||||
{ tag: ["@cloudApiSmoke", "@dashboards", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
const title = `smoke_dash_${smoke.uniqueSuffix()}`;
|
||||
created.dashboardId = await smoke.createDashboard(title);
|
||||
const stored = await smoke.getDashboard(created.dashboardId);
|
||||
const inner = stored[`v${stored.version}`] || stored;
|
||||
expect(inner.title).toBe(title);
|
||||
expect(inner.variables.list[0].value).toBe("prod");
|
||||
|
||||
testLogger.step("Add an annotation with tags and read it back");
|
||||
const range = await smoke.addAnnotation(created.dashboardId, "Deploy ✓", ["deploy", "cloud-api-smoke"]);
|
||||
const annotation = await smoke.findAnnotation(created.dashboardId, "Deploy ✓", range);
|
||||
expect(annotation, "annotation must read back").toBeTruthy();
|
||||
expect(annotation.tags).toEqual(["deploy", "cloud-api-smoke"]);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"grouped SLO with tags saves, reads back and updates",
|
||||
{ tag: ["@cloudApiSmoke", "@slos", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
await smoke.seedStream(STREAM);
|
||||
created.sloId = await smoke.createSlo(smoke.sloPayload(`smoke_slo_${smoke.uniqueSuffix()}`, STREAM));
|
||||
const stored = await smoke.getSlo(created.sloId);
|
||||
expect(stored.group_by).toEqual(["level"]);
|
||||
expect(stored.tags).toEqual(["cloud-api-smoke", "tier:1"]);
|
||||
|
||||
stored.description = "cloud api smoke updated";
|
||||
await smoke.updateSlo(created.sloId, stored);
|
||||
expect((await smoke.getSlo(created.sloId)).description).toBe("cloud api smoke updated");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"function saves and is listed",
|
||||
{ tag: ["@cloudApiSmoke", "@functions", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
created.functionName = `smoke_fn_${smoke.uniqueSuffix()}`;
|
||||
await smoke.createFunction(created.functionName);
|
||||
expect(await smoke.listFunctionNames()).toContain(created.functionName);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"saved view saves and reads back",
|
||||
{ tag: ["@cloudApiSmoke", "@savedViews", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
const name = `smoke_view_${smoke.uniqueSuffix()}`;
|
||||
created.viewId = await smoke.createSavedView(name);
|
||||
const stored = await smoke.getSavedView(created.viewId);
|
||||
expect(stored.view_name).toBe(name);
|
||||
expect(stored.data).toEqual(smoke.savedViewData());
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"core list endpoints answer 200",
|
||||
{ tag: ["@cloudApiSmoke", "@smoke", "@P1", "@cloud", "@all"] },
|
||||
async () => {
|
||||
for (const path of smoke.listEndpoints()) {
|
||||
testLogger.step(`GET /${path}`);
|
||||
await smoke.expectOk("GET", path);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -331,6 +331,7 @@ test.describe("Pre-Test Cleanup", () => {
|
||||
await pm.apiCleanup.cleanupStreams(
|
||||
[
|
||||
/^e2e_slo_14269_/, // slo-error-messages.spec.js (#14269) seeded stream
|
||||
/^cloud_api_smoke$/, // Cloud/cloudApiSmoke.spec.js seed stream (re-seeded each run)
|
||||
/^e2e_13354_/, // logs-field-value-rendering.spec.js (#13354)
|
||||
/^e2e_13896_/, // logs-result-display.spec.js (#13896)
|
||||
/^e2e_13990_other_/, // logs-bugs.spec.js (#13990)
|
||||
|
||||
Reference in New Issue
Block a user