"""Marketplace popularity stats — invocation rollup + sort + Most Popular.""" from __future__ import annotations import datetime as dt import json import pytest from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # Helpers — reuse seeding pattern from test_marketplace_api.py # --------------------------------------------------------------------------- def _seed_curated(user_id: str, marketplace: str, plugin: str) -> None: """Seed a curated plugin with RBAC grant for user_id.""" from src.db import get_system_db from src.repositories.user_groups import UserGroupsRepository from src.repositories.user_group_members import UserGroupMembersRepository from src.repositories.resource_grants import ResourceGrantsRepository conn = get_system_db() try: exists = conn.execute( "SELECT 1 FROM marketplace_registry WHERE id = ?", [marketplace] ).fetchone() if not exists: conn.execute( "INSERT INTO marketplace_registry (id, name, url, registered_at) " "VALUES (?, ?, ?, ?)", [marketplace, marketplace.upper(), f"https://example.test/{marketplace}.git", dt.datetime.now(dt.timezone.utc)], ) conn.execute( "INSERT OR IGNORE INTO marketplace_plugins " "(marketplace_id, name, description, version, category, raw, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", [marketplace, plugin, "desc", "1.0", None, json.dumps({"name": plugin, "version": "1.0", "description": "desc"}), dt.datetime.now(dt.timezone.utc)], ) gname = f"G-{user_id}-{marketplace}-{plugin}" gid = UserGroupsRepository(conn).create(name=gname)["id"] UserGroupMembersRepository(conn).add_member(user_id, gid, source="admin") ResourceGrantsRepository(conn).create( group_id=gid, resource_type="marketplace_plugin", resource_id=f"{marketplace}/{plugin}", ) finally: conn.close() def _seed_rollup(source: str, plugin_name: str, rows: list[tuple]) -> None: """Seed marketplace rollup data for `(source, plugin_name)`. Writes per-day rows into `usage_marketplace_item_daily` (for the trend-pct calculation, which reads daily) AND a single aggregate row into `usage_marketplace_item_window` for ``period_label='last_30d'`` (for the card / detail invocations_30d lookup). `rows` is a list of (day_offset, invocations, distinct_users); the helper sums them to produce the window snapshot. Test data uses ``type='plugin'`` + ``parent_plugin=''`` to model curated plugin-level rollup rows (the shape the marketplace cards look up). """ from src.db import get_system_db today = dt.date.today() conn = get_system_db() try: type_ = "plugin" parent_plugin = "" # Per-day daily-fact rows — drive the trend-pct calculation. for d_offset, inv, users in rows: day = today - dt.timedelta(days=d_offset) conn.execute( "INSERT OR REPLACE INTO usage_marketplace_item_daily " "(day, source, type, parent_plugin, name, count, distinct_users, error_count) " "VALUES (?, ?, ?, ?, ?, ?, ?, 0)", [day, source, type_, parent_plugin, plugin_name, inv, users], ) # 30d window snapshot — sum across days within the window. In real # data this is a TRUE distinct count from usage_events; tests use # sum-of-daily-distinct as an approximation since they control the # underlying data shape directly. total_inv = sum(inv for offset, inv, _ in rows if offset <= 30) total_users = sum(u for offset, _, u in rows if offset <= 30) conn.execute( "INSERT OR REPLACE INTO usage_marketplace_item_window " "(period_label, source, type, parent_plugin, name, invocations, distinct_users) " "VALUES ('last_30d', ?, ?, ?, ?, ?, ?)", [source, type_, parent_plugin, plugin_name, total_inv, total_users], ) finally: conn.close() # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestTelemetryDefaults: def test_unified_item_has_telemetry_fields_default_zero( self, seeded_app, admin_user ): """Fresh response (no rollups seeded) returns invocations_30d=0, not omitted.""" c = seeded_app["client"] # Seed one plugin so there is at least one item to check _seed_curated("admin1", "telemetry-mp", "no-usage-plugin") resp = c.get("/api/marketplace/items?tab=curated&limit=5", headers=admin_user) assert resp.status_code == 200 found = False for item in resp.json()["items"]: assert "invocations_30d" in item, "missing invocations_30d field" assert "distinct_users_30d" in item, "missing distinct_users_30d field" assert "trend_pct" in item, "missing trend_pct field" assert item["invocations_30d"] == 0 assert item["distinct_users_30d"] == 0 assert item["trend_pct"] is None found = True assert found, "Expected at least one item" class TestInvocationsReturned: def test_invocations_returned_after_rollup_seeded(self, seeded_app, admin_user): """Seed rollup for a curated plugin and confirm items endpoint returns the correct invocations_30d sum.""" _seed_curated("admin1", "test-mp", "test-plug") # Days 1, 3, 10 ago — all within 30d window _seed_rollup("curated", "test-plug", [ (1, 100, 10), (3, 50, 5), (10, 20, 2), ]) c = seeded_app["client"] resp = c.get("/api/marketplace/items?tab=curated", headers=admin_user) assert resp.status_code == 200 items = {i["name"]: i for i in resp.json()["items"]} assert "test-plug" in items, f"plugin not in response: {list(items)}" assert items["test-plug"]["invocations_30d"] == 170 # 100+50+20 assert items["test-plug"]["distinct_users_30d"] == 17 # 10+5+2 def test_old_rollups_excluded_from_30d_sum(self, seeded_app, admin_user): """Rows older than 30 days must NOT appear in invocations_30d.""" _seed_curated("admin1", "test-mp2", "old-plug") _seed_rollup("curated", "old-plug", [ (31, 999, 99), # outside 30d window (1, 5, 1), # inside ]) c = seeded_app["client"] resp = c.get("/api/marketplace/items?tab=curated", headers=admin_user) assert resp.status_code == 200 items = {i["name"]: i for i in resp.json()["items"]} assert "old-plug" in items assert items["old-plug"]["invocations_30d"] == 5 class TestSortMostUsed: def test_sort_most_used_descending(self, seeded_app, admin_user): """sort=most_used returns items in descending invocations_30d order.""" _seed_curated("admin1", "sort-mp", "low-plug") _seed_curated("admin1", "sort-mp", "high-plug") _seed_rollup("curated", "low-plug", [(1, 10, 1)]) _seed_rollup("curated", "high-plug", [(1, 500, 50)]) c = seeded_app["client"] resp = c.get( "/api/marketplace/items?tab=curated&sort=most_used", headers=admin_user, ) assert resp.status_code == 200 names = [i["name"] for i in resp.json()["items"]] # high-plug should come before low-plug assert names.index("high-plug") < names.index("low-plug"), ( f"Expected high-plug before low-plug; got {names}" ) def test_sort_recent_preserves_default_order(self, seeded_app, admin_user): """sort=recent (default) doesn't break the existing endpoint contract.""" _seed_curated("admin1", "order-mp", "alpha-plug") c = seeded_app["client"] resp1 = c.get( "/api/marketplace/items?tab=curated", headers=admin_user, ) resp2 = c.get( "/api/marketplace/items?tab=curated&sort=recent", headers=admin_user, ) assert resp1.status_code == 200 assert resp2.status_code == 200 assert [i["name"] for i in resp1.json()["items"]] == \ [i["name"] for i in resp2.json()["items"]] class TestSortTrending: def test_sort_trending_excludes_low_prior_invocations(self, seeded_app, admin_user): """Items with prior-week invocations < 3 must not appear in trending sort.""" _seed_curated("admin1", "trend-mp", "noisy-plug") # Only recent-week data (prior = 0) — trend_pct is None _seed_rollup("curated", "noisy-plug", [(1, 50, 5)]) c = seeded_app["client"] resp = c.get( "/api/marketplace/items?tab=curated&sort=trending", headers=admin_user, ) assert resp.status_code == 200 names = [i["name"] for i in resp.json()["items"]] assert "noisy-plug" not in names, ( "noisy-plug should be excluded from trending (prior invocations < 3)" ) def test_sort_trending_includes_item_with_sufficient_prior( self, seeded_app, admin_user ): """An item with >=3 prior-week invocations must appear in trending sort.""" _seed_curated("admin1", "trend-mp2", "trend-plug") # prior week (8-14 days ago): 12 invocations across 3 days # recent week (1-6 days ago): 30 invocations across 3 days _seed_rollup("curated", "trend-plug", [ (8, 4, 1), (10, 4, 1), (12, 4, 1), (1, 10, 2), (3, 10, 2), (5, 10, 2), ]) c = seeded_app["client"] resp = c.get( "/api/marketplace/items?tab=curated&sort=trending", headers=admin_user, ) assert resp.status_code == 200 names = [i["name"] for i in resp.json()["items"]] assert "trend-plug" in names, ( f"trend-plug should appear in trending sort; got {names}" ) class TestMostPopularSection: def test_most_popular_api_empty_when_no_data(self, seeded_app, admin_user): """When no rollups exist, sort=most_used returns items with invocations_30d=0 — the JS layer uses this to hide the Most Popular section.""" _seed_curated("admin1", "nodata-mp", "nodata-plug") c = seeded_app["client"] resp = c.get( "/api/marketplace/items?tab=curated&sort=most_used&page_size=8", headers=admin_user, ) assert resp.status_code == 200 items_with_inv = [ i for i in resp.json()["items"] if i["invocations_30d"] > 0 ] # No rollups seeded — all zero, JS hides the section assert items_with_inv == [] def test_most_popular_section_placeholder_in_html(self, seeded_app, admin_user): """The Most Popular container div is always emitted by the template; JS shows/hides it based on API data.""" c = seeded_app["client"] resp = c.get("/marketplace", headers=admin_user) assert resp.status_code == 200 assert "mp-popular-section" in resp.text def test_most_popular_section_visible_with_data(self, seeded_app, admin_user): """After seeding usage_plugin_daily, sort=most_used returns items with invocations_30d > 0, which the JS uses to un-hide the section.""" _seed_curated("admin1", "pop-mp", "popular-plug") _seed_rollup("curated", "popular-plug", [(1, 100, 10)]) c = seeded_app["client"] resp = c.get( "/api/marketplace/items?tab=curated&sort=most_used&page_size=8", headers=admin_user, ) assert resp.status_code == 200 items_with_inv = [ i for i in resp.json()["items"] if i["invocations_30d"] > 0 ] assert len(items_with_inv) >= 1, "Expected at least one item with invocations > 0" class TestDetailTelemetry: def test_detail_endpoint_telemetry_absent_when_no_data( self, seeded_app, admin_user ): """GET /api/marketplace/curated/{mp}/{plugin} returns telemetry=null when no rollup data exists.""" _seed_curated("admin1", "detail-mp", "detail-plug") c = seeded_app["client"] resp = c.get( "/api/marketplace/curated/detail-mp/detail-plug", headers=admin_user, ) assert resp.status_code == 200 assert resp.json()["telemetry"] is None def test_detail_endpoint_telemetry_present_with_data( self, seeded_app, admin_user ): """GET /api/marketplace/curated/{mp}/{plugin} returns telemetry dict with invocations_30d and 30-entry daily_series when rollup data exists.""" _seed_curated("admin1", "detail-mp2", "detail-plug2") _seed_rollup("curated", "detail-plug2", [ (1, 50, 5), (5, 30, 3), ]) c = seeded_app["client"] resp = c.get( "/api/marketplace/curated/detail-mp2/detail-plug2", headers=admin_user, ) assert resp.status_code == 200 tele = resp.json()["telemetry"] assert tele is not None assert tele["invocations_30d"] == 80 # 50+30 assert tele["distinct_users_30d"] == 8 # 5+3 assert "daily_series" in tele assert len(tele["daily_series"]) == 30 # Each entry has day + invocations for entry in tele["daily_series"]: assert "day" in entry assert "invocations" in entry # Total of the series must match invocations_30d total = sum(e["invocations"] for e in tele["daily_series"]) assert total == 80