mirror of
https://github.com/apache/superset.git
synced 2026-07-12 17:55:38 +00:00
Implements the dataset activity stream. Per AV-004 datasets have no transitive layer in V2: dataset activity = dataset's own edits only, regardless of whether charts use the dataset. The chart/dashboard endpoints surface dataset edits in their *related* streams (already shipped); the dataset endpoint stays narrowly self-only. * T033 DatasetRestApi.activity — third application of the shared resolve_endpoint_path_entity + parse_activity_query_params pattern. Endpoint body is ~10 lines of real logic, same shape as T016 and T028 but with SqlaTable as the path entity. The activity orchestrator already short-circuits the related-entity resolution for datasets (_resolve_scope returns [] for related when path_kind="SqlaTable"), so the dataset endpoint inherits the V2 semantics for free. * T034 TestDatasetActivityView class added to activity_view_tests.py. * T035 test_dataset_activity_excludes_chart_edits — AS-1 of US3 / AV-004 verbatim. Edit a chart that uses the dataset; assert the chart edit does NOT appear in the dataset's activity stream (datasets are read-only upstream of charts in V2). * T036 test_dataset_activity_includes_dataset_self_edits — confirms the positive path: editing the dataset's own description surfaces a SqlaTable/self record. * New test_dataset_activity_related_only_returns_empty — AV-004 in contract form: ?include=related on a dataset returns an empty result list and count=0 because there are no related entities to draw from. Plus the standard boundary tests (404 for unknown UUID, 400 for malformed UUID / invalid include, 403 for non-owner, 200 envelope shape). Full activity-view suite: 27/27 integration tests + 57/57 unit tests green. All three endpoint families live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
623 lines
24 KiB
Python
623 lines
24 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.connectors.sqla.models import SqlaTable
|
||
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 _get_birth_names_dataset() -> SqlaTable:
|
||
return (
|
||
db.session.query(SqlaTable)
|
||
.filter(SqlaTable.table_name == "birth_names")
|
||
.first()
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
class TestChartActivityView(SupersetTestCase):
|
||
"""T028–T032 — ``GET /api/v1/chart/<uuid>/activity/`` (US2).
|
||
|
||
Chart activity = chart's own edits + datasets the chart pointed at
|
||
during association. **No** dashboard records — even when the chart
|
||
is on a dashboard, sibling-traversal is excluded per the spec's
|
||
Relationship Traversal section (T032).
|
||
"""
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
|
||
pass
|
||
|
||
def _activity(self, chart_uuid: str, **query: Any) -> Any:
|
||
return self.client.get(
|
||
f"/api/v1/chart/{chart_uuid}/activity/",
|
||
query_string=query,
|
||
)
|
||
|
||
def _get_birth_names_chart(self) -> Slice:
|
||
return db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
|
||
|
||
# ---- 4xx boundary cases ----
|
||
|
||
def test_chart_activity_returns_404_for_unknown_uuid(self) -> None:
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("00000000-0000-0000-0000-000000000000")
|
||
assert rv.status_code == 404
|
||
|
||
def test_chart_activity_returns_400_for_invalid_uuid(self) -> None:
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("not-a-uuid")
|
||
assert rv.status_code == 400
|
||
|
||
def test_chart_activity_returns_400_for_invalid_include(self) -> None:
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
assert chart is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(chart.uuid), include="upstream")
|
||
assert rv.status_code == 400
|
||
|
||
def test_chart_activity_denies_non_owner(self) -> None:
|
||
"""Same shape as the dashboard endpoint: Alpha lacks ownership
|
||
on the admin-fixture chart so raise_for_ownership returns 403."""
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
assert chart is not None
|
||
self.login(ALPHA_USERNAME)
|
||
rv = self._activity(str(chart.uuid))
|
||
assert rv.status_code == 403
|
||
|
||
# ---- 200 happy paths ----
|
||
|
||
def test_chart_activity_returns_200_with_envelope_shape(self) -> None:
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
assert chart is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(chart.uuid))
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
assert isinstance(body["result"], list)
|
||
assert isinstance(body["count"], int)
|
||
|
||
def test_chart_activity_self_edit_appears_as_self_record(self) -> None:
|
||
"""Editing the chart itself surfaces a ``source=self``,
|
||
``entity_kind=Slice`` record."""
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
assert chart is not None
|
||
chart_id = chart.id
|
||
chart_uuid = str(chart.uuid)
|
||
original_name = chart.slice_name
|
||
|
||
try:
|
||
chart.slice_name = f"{original_name} (edited self)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(chart_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
self_records = [
|
||
r
|
||
for r in body["result"]
|
||
if r["entity_kind"] == "Slice" and r["source"] == "self"
|
||
]
|
||
got = [(r["entity_kind"], r["source"]) for r in body["result"]]
|
||
assert self_records, (
|
||
f"Expected ≥1 Slice/self record from the chart edit; got: {got}"
|
||
)
|
||
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_chart_activity_includes_dataset_edit_as_related(self) -> None:
|
||
"""T030 / AS-1 of US2: editing the chart's dataset surfaces a
|
||
``source=related``, ``entity_kind=SqlaTable`` record."""
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
dataset = _get_birth_names_dataset()
|
||
assert chart is not None
|
||
assert dataset is not None
|
||
chart_uuid = str(chart.uuid)
|
||
dataset_id = dataset.id
|
||
original_description = dataset.description
|
||
|
||
try:
|
||
dataset.description = "edited for activity-view test"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(chart_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"] == "SqlaTable" and r["source"] == "related"
|
||
]
|
||
assert related, (
|
||
"Expected at least one SqlaTable/related record from the "
|
||
"dataset edit; got: "
|
||
f"{[(r['entity_kind'], r['source']) for r in body['result']]}"
|
||
)
|
||
finally:
|
||
db.session.rollback()
|
||
dataset = (
|
||
db.session.query(SqlaTable).filter(SqlaTable.id == dataset_id).one()
|
||
)
|
||
dataset.description = original_description
|
||
db.session.commit()
|
||
|
||
def test_chart_activity_excludes_sibling_dashboards(self) -> None:
|
||
"""T032: Even when the chart is on a dashboard, dashboard edits
|
||
do NOT appear in the chart's activity. Per the spec's Relationship
|
||
Traversal section: charts don't see "sideways" to the dashboards
|
||
they happen to be on."""
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
dashboard = _get_birth_names_dashboard()
|
||
assert chart is not None
|
||
assert dashboard is not None
|
||
chart_uuid = str(chart.uuid)
|
||
dashboard_id = dashboard.id
|
||
original_title = dashboard.dashboard_title
|
||
|
||
try:
|
||
# Mutate the dashboard the chart is on — that edit MUST NOT
|
||
# appear in the chart's activity stream.
|
||
dashboard.dashboard_title = f"{original_title} (edited sibling)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(chart_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
for record in body["result"]:
|
||
assert record["entity_kind"] != "Dashboard", (
|
||
f"Dashboard edit leaked into chart's activity stream: {record}"
|
||
)
|
||
finally:
|
||
db.session.rollback()
|
||
dashboard = (
|
||
db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
|
||
)
|
||
dashboard.dashboard_title = original_title
|
||
db.session.commit()
|
||
|
||
def test_chart_activity_include_self_excludes_related(self) -> None:
|
||
"""``?include=self`` filters out the dataset records."""
|
||
_persist_fixture_state()
|
||
chart = self._get_birth_names_chart()
|
||
dataset = _get_birth_names_dataset()
|
||
assert chart is not None
|
||
assert dataset is not None
|
||
chart_uuid = str(chart.uuid)
|
||
dataset_id = dataset.id
|
||
original_description = dataset.description
|
||
|
||
try:
|
||
dataset.description = "edited (self filter test)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(chart_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"
|
||
assert record["entity_kind"] == "Slice"
|
||
finally:
|
||
db.session.rollback()
|
||
dataset = (
|
||
db.session.query(SqlaTable).filter(SqlaTable.id == dataset_id).one()
|
||
)
|
||
dataset.description = original_description
|
||
db.session.commit()
|
||
|
||
|
||
class TestDatasetActivityView(SupersetTestCase):
|
||
"""T033–T036 — ``GET /api/v1/dataset/<uuid>/activity/`` (US3).
|
||
|
||
Dataset activity = dataset's own edits only. **No** transitive layer
|
||
in V2 (AV-004) — even when charts use the dataset, those chart edits
|
||
do NOT appear here. ``?include=related`` and ``?include=all``
|
||
collapse to the same self-only stream as ``?include=self``.
|
||
"""
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
|
||
pass
|
||
|
||
def _activity(self, dataset_uuid: str, **query: Any) -> Any:
|
||
return self.client.get(
|
||
f"/api/v1/dataset/{dataset_uuid}/activity/",
|
||
query_string=query,
|
||
)
|
||
|
||
# ---- 4xx boundary cases ----
|
||
|
||
def test_dataset_activity_returns_404_for_unknown_uuid(self) -> None:
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("00000000-0000-0000-0000-000000000000")
|
||
assert rv.status_code == 404
|
||
|
||
def test_dataset_activity_returns_400_for_invalid_uuid(self) -> None:
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity("not-a-uuid")
|
||
assert rv.status_code == 400
|
||
|
||
def test_dataset_activity_returns_400_for_invalid_include(self) -> None:
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
assert dataset is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dataset.uuid), include="upstream")
|
||
assert rv.status_code == 400
|
||
|
||
def test_dataset_activity_denies_non_owner(self) -> None:
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
assert dataset is not None
|
||
self.login(ALPHA_USERNAME)
|
||
rv = self._activity(str(dataset.uuid))
|
||
assert rv.status_code == 403
|
||
|
||
# ---- 200 happy paths ----
|
||
|
||
def test_dataset_activity_returns_200_with_envelope_shape(self) -> None:
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
assert dataset is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dataset.uuid))
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
assert isinstance(body["result"], list)
|
||
assert isinstance(body["count"], int)
|
||
|
||
def test_dataset_activity_includes_dataset_self_edits(self) -> None:
|
||
"""T036: the dataset's own scalar edits appear as ``source=self``,
|
||
``entity_kind=SqlaTable``."""
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
assert dataset is not None
|
||
dataset_id = dataset.id
|
||
dataset_uuid = str(dataset.uuid)
|
||
original_description = dataset.description
|
||
|
||
try:
|
||
dataset.description = "edited self for dataset activity"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dataset_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
self_records = [
|
||
r
|
||
for r in body["result"]
|
||
if r["entity_kind"] == "SqlaTable" and r["source"] == "self"
|
||
]
|
||
got = [(r["entity_kind"], r["source"]) for r in body["result"]]
|
||
assert self_records, (
|
||
f"Expected ≥1 SqlaTable/self record from the dataset edit; got: {got}"
|
||
)
|
||
finally:
|
||
db.session.rollback()
|
||
dataset = (
|
||
db.session.query(SqlaTable).filter(SqlaTable.id == dataset_id).one()
|
||
)
|
||
dataset.description = original_description
|
||
db.session.commit()
|
||
|
||
def test_dataset_activity_excludes_chart_edits(self) -> None:
|
||
"""T035 / AS-1 / AV-004: When a chart that uses the dataset is
|
||
edited, that edit does NOT appear in the dataset's activity stream.
|
||
Datasets are read-only upstream in V2."""
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
chart = db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
|
||
assert dataset is not None
|
||
assert chart is not None
|
||
dataset_uuid = str(dataset.uuid)
|
||
chart_id = chart.id
|
||
chart_original_name = chart.slice_name
|
||
|
||
try:
|
||
# Edit the chart — generates a Slice change record. The
|
||
# dataset's activity MUST NOT surface it.
|
||
chart.slice_name = f"{chart_original_name} (edited from dataset test)"
|
||
db.session.commit()
|
||
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(dataset_uuid)
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
for record in body["result"]:
|
||
assert record["entity_kind"] == "SqlaTable", (
|
||
"Non-dataset record leaked into dataset's activity "
|
||
f"stream: {record}"
|
||
)
|
||
assert record["source"] == "self", (
|
||
f"Dataset activity contains a related record: {record}"
|
||
)
|
||
finally:
|
||
db.session.rollback()
|
||
chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
|
||
chart.slice_name = chart_original_name
|
||
db.session.commit()
|
||
|
||
def test_dataset_activity_related_only_returns_empty(self) -> None:
|
||
"""AV-004: datasets have no transitive layer. ``?include=related``
|
||
returns an empty result list because there are no related entities
|
||
to draw from."""
|
||
_persist_fixture_state()
|
||
dataset = _get_birth_names_dataset()
|
||
assert dataset is not None
|
||
self.login(ADMIN_USERNAME)
|
||
rv = self._activity(str(dataset.uuid), include="related")
|
||
assert rv.status_code == 200
|
||
body = _json.loads(rv.data.decode("utf-8"))
|
||
assert body["result"] == []
|
||
assert body["count"] == 0
|