mirror of
https://github.com/apache/superset.git
synced 2026-07-13 02:05:34 +00:00
Five structural changes — no behaviour change — applied as one commit per the tidy-first discipline. All from the clean-code review of e4070a4716. * Tidy 1 — Fix _fetch_change_records sort key. Was calling .timestamp() on issued_at with an `else 0` fallback; the fallback was dead defense (column is non-null per sc-103156 schema) and .timestamp() introduces non-determinism on tz-naive datetimes. Sort on the datetimes directly. * Tidy 2 — parse_activity_query_params now raises ActivityParamsError (subclass of ValueError) instead of returning (Optional[dict], Optional[str]). The tuple was forcing every caller into a defensive `if error or params is None: return self.response_400(message=error or "Invalid query parameters")`. The new shape — `try: params = parse(...); except ActivityParamsError as exc: return response_400(str(exc))` — is shorter, type-safe, and the contract is enforced at the boundary. * Tidy 3 — Test helper now uses Flask client's query_string= parameter instead of f-string concatenation. Handles URL-encoding correctly for the day a test passes a value containing & / = / + / etc. * Tidy 4 — get_activity pipeline collapses to a single rolling `records` variable instead of the mid-stream `raw / visible_raw / enriched / visible` naming. Each function call's name documents what the step does; no intermediate variable names needed. * Tidy 5 — Extracted four per-parameter parsers: _parse_optional_iso, _parse_include, _parse_page, _parse_page_size. The "parse one parameter" concept now has a name. Cost: four small helpers (each 10-15 lines, one job). Benefit: parse_activity_query_params is a 10-line table-driven dispatcher. Test changes: parser unit tests now use pytest.raises(ActivityParamsError) instead of unpacking the (params, error) tuple. Added one test confirming ActivityParamsError subclasses ValueError so the standard library exception hierarchy still catches it. Total unit tests: 57. Integration tests still 10/10 green. Deferred per the review: the UUID-parse + entity-find + ownership- check dance is duplicated between activity / list_versions / get_version and will grow to T028 / T033. Refactor when T028 lands — three real callers > one prospective one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
10 KiB
Python
264 lines
10 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
||
# or more contributor license agreements. See the NOTICE file
|
||
# distributed with this work for additional information
|
||
# regarding copyright ownership. The ASF licenses this file
|
||
# to you under the Apache License, Version 2.0 (the
|
||
# "License"); you may not use this file except in compliance
|
||
# with the License. You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing,
|
||
# software distributed under the License is distributed on an
|
||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||
# KIND, either express or implied. See the License for the
|
||
# specific language governing permissions and limitations
|
||
# under the License.
|
||
"""Integration tests for the cross-entity activity-view API (sc-107283).
|
||
|
||
US1 — dashboard activity stream: ``GET /api/v1/dashboard/<uuid>/activity/``.
|
||
Tests for US2 (chart activity) and US3 (dataset activity) come in later
|
||
phases.
|
||
|
||
Per spec T053 / sc-103156 T062, every test that mutates a fixture entity
|
||
wraps the test body in ``try``/``finally`` with
|
||
``metadata_db.session.rollback()`` in the ``finally``. The rationale is
|
||
documented in the spec — Continuum captures dirty mappers during
|
||
autoflush, so leaving an instrumented attribute dirty pollutes
|
||
downstream tests via the shadow tables.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from superset.extensions import db
|
||
from superset.models.dashboard import Dashboard
|
||
from superset.models.slice import Slice
|
||
from superset.utils import json as _json
|
||
from tests.integration_tests.base_tests import SupersetTestCase
|
||
from tests.integration_tests.constants import ADMIN_USERNAME, ALPHA_USERNAME
|
||
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
|
||
load_birth_names_dashboard_with_slices,
|
||
load_birth_names_data,
|
||
)
|
||
|
||
|
||
def _persist_fixture_state() -> None:
|
||
"""Force the fixture's pending INSERTs to commit so subsequent edits
|
||
produce *new* version rows instead of being batched into the
|
||
creation transaction. Mirrors the same helper in
|
||
``tests/integration_tests/dashboards/version_history_tests.py``.
|
||
"""
|
||
db.session.commit()
|
||
|
||
|
||
def _get_birth_names_dashboard() -> Dashboard:
|
||
return (
|
||
db.session.query(Dashboard)
|
||
.filter(Dashboard.dashboard_title == "USA Births Names")
|
||
.first()
|
||
)
|
||
|
||
|
||
class TestDashboardActivityView(SupersetTestCase):
|
||
"""T017–T026 — ``GET /api/v1/dashboard/<uuid>/activity/`` (US1)."""
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
|
||
pass
|
||
|
||
def _activity(self, dashboard_uuid: str, **query: Any) -> Any:
|
||
return self.client.get(
|
||
f"/api/v1/dashboard/{dashboard_uuid}/activity/",
|
||
query_string=query,
|
||
)
|
||
|
||
# ---- 4xx boundary cases ----
|
||
|
||
def test_activity_returns_404_for_unknown_uuid(self) -> None:
|
||
"""AV-009: unknown path entity → 404."""
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("00000000-0000-0000-0000-000000000000")
|
||
assert rv.status_code == 404
|
||
|
||
def test_activity_returns_400_for_invalid_uuid(self) -> None:
|
||
"""A malformed UUID is rejected by the endpoint, not by Werkzeug."""
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("not-a-uuid")
|
||
assert rv.status_code == 400
|
||
|
||
def test_activity_returns_400_for_invalid_include(self) -> None:
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dashboard.uuid), include="sibling")
|
||
assert rv.status_code == 400
|
||
|
||
def test_activity_returns_400_for_invalid_since(self) -> None:
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dashboard.uuid), since="yesterday")
|
||
assert rv.status_code == 400
|
||
|
||
def test_activity_denies_non_owner(self) -> None:
|
||
"""Mirrors sc-103156 T056 — Alpha doesn't own the admin-fixture
|
||
dashboard, so raise_for_ownership rejects with 403 before the
|
||
activity layer runs."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
dashboard_uuid = str(dashboard.uuid)
|
||
|
||
self.login(ALPHA_USERNAME)
|
||
rv = self._activity(dashboard_uuid)
|
||
assert rv.status_code == 403
|
||
|
||
# ---- 200 happy paths ----
|
||
|
||
def test_activity_returns_200_with_envelope_shape(self) -> None:
|
||
"""Smoke test: the endpoint returns the documented envelope shape
|
||
(``result`` list + ``count`` integer) even when the dashboard has
|
||
no activity yet."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
dashboard_uuid = str(dashboard.uuid)
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dashboard_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
assert "result" in body
|
||
assert "count" in body
|
||
assert isinstance(body["result"], list)
|
||
assert isinstance(body["count"], int)
|
||
|
||
def test_activity_includes_chart_edit_as_related(self) -> None:
|
||
"""T018 / AS-1 of US1: editing a chart on the dashboard surfaces
|
||
the chart-edit record with ``entity_kind=Slice`` and
|
||
``source=related``."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
dashboard_uuid = str(dashboard.uuid)
|
||
chart_on_dashboard = next(iter(dashboard.slices), None)
|
||
assert chart_on_dashboard is not None
|
||
chart_id = chart_on_dashboard.id
|
||
original_name = chart_on_dashboard.slice_name
|
||
|
||
try:
|
||
chart_on_dashboard.slice_name = f"{original_name} (edited)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dashboard_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
related = [
|
||
r
|
||
for r in body["result"]
|
||
if r["entity_kind"] == "Slice" and r["source"] == "related"
|
||
]
|
||
assert related, (
|
||
"Expected at least one Slice/related record from the chart "
|
||
"edit; got: "
|
||
f"{[(r['entity_kind'], r['source']) for r in body['result']]}"
|
||
)
|
||
# Spot-check the carry-through of denormalized fields
|
||
sample = related[0]
|
||
assert sample["entity_uuid"] is not None
|
||
assert "transaction_id" in sample
|
||
assert "issued_at" in sample
|
||
finally:
|
||
db.session.rollback()
|
||
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
|
||
chart.slice_name = original_name
|
||
db.session.commit()
|
||
|
||
def test_activity_include_self_excludes_related(self) -> None:
|
||
"""T023 / AV-016: ``?include=self`` filters out related records."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
dashboard_uuid = str(dashboard.uuid)
|
||
chart_on_dashboard = next(iter(dashboard.slices), None)
|
||
assert chart_on_dashboard is not None
|
||
chart_id = chart_on_dashboard.id
|
||
original_name = chart_on_dashboard.slice_name
|
||
|
||
try:
|
||
chart_on_dashboard.slice_name = f"{original_name} (edited self)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dashboard_uuid, include="self")
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
for record in body["result"]:
|
||
assert record["source"] == "self", (
|
||
f"include=self leaked a non-self record: {record}"
|
||
)
|
||
assert record["entity_kind"] == "Dashboard"
|
||
finally:
|
||
db.session.rollback()
|
||
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
|
||
chart.slice_name = original_name
|
||
db.session.commit()
|
||
|
||
def test_activity_include_related_excludes_self(self) -> None:
|
||
"""T024 / AV-016: ``?include=related`` returns only related records."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
dashboard_uuid = str(dashboard.uuid)
|
||
original_title = dashboard.dashboard_title
|
||
dashboard_id = dashboard.id
|
||
|
||
try:
|
||
# Edit the dashboard's own field so we have a self record to
|
||
# filter out, and edit a chart on it so we have a related
|
||
# record to keep.
|
||
dashboard.dashboard_title = f"{original_title} (edited dash)"
|
||
db.session.commit()
|
||
chart_on_dashboard = next(iter(dashboard.slices), None)
|
||
assert chart_on_dashboard is not None
|
||
chart_id = chart_on_dashboard.id
|
||
chart_original_name = chart_on_dashboard.slice_name
|
||
chart_on_dashboard.slice_name = f"{chart_original_name} (edited chart)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dashboard_uuid, include="related")
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
for record in body["result"]:
|
||
assert record["source"] == "related", (
|
||
f"include=related leaked a self record: {record}"
|
||
)
|
||
assert record["entity_kind"] != "Dashboard"
|
||
finally:
|
||
db.session.rollback()
|
||
dashboard = (
|
||
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
||
)
|
||
dashboard.dashboard_title = original_title
|
||
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
|
||
chart.slice_name = chart_original_name
|
||
db.session.commit()
|
||
|
||
def test_activity_pagination_clamps_oversized_page_size(self) -> None:
|
||
"""``?page_size=500`` is silently clamped to the contract max
|
||
(200) rather than rejected with 400."""
|
||
_persist_fixture_state()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert dashboard is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dashboard.uuid), page_size="500")
|
||
assert rv.status_code == 200
|