step 10 complete: plugin marketplace with catalog, review workflow, and revenue split
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -356,20 +356,20 @@ adiuva-api/
|
|||||||
|
|
||||||
- **Outcome:** Secure, rate-limited API with prompt IP protection.
|
- **Outcome:** Secure, rate-limited API with prompt IP protection.
|
||||||
|
|
||||||
### Step 10 — Plugin Marketplace
|
### Step 10 — Plugin Marketplace ✅
|
||||||
- [ ] `app/marketplace/plugin_registry.py`:
|
- [x] `app/marketplace/plugin_registry.py`:
|
||||||
- `PluginRegistry`:
|
- `PluginRegistry`:
|
||||||
- `async list_plugins(category, query, page, sort) -> PluginListResponse`
|
- `async list_plugins(category, query, page, sort) -> PluginListResponse`
|
||||||
- `async get_plugin(plugin_id) -> PluginManifest | None`
|
- `async get_plugin(plugin_id) -> PluginManifest | None`
|
||||||
- `async submit_plugin(manifest: PluginManifest, package_s3_key: str) -> str` — returns plugin_id, sets status = 'pending_review'
|
- `async submit_plugin(manifest: PluginManifest, package_s3_key: str) -> str` — returns plugin_id, sets status = 'pending_review'
|
||||||
- `async approve_plugin(plugin_id) -> None` — admin only, sets status = 'approved'
|
- `async approve_plugin(plugin_id) -> None` — admin only, sets status = 'approved'
|
||||||
- `async reject_plugin(plugin_id, reason: str) -> None`
|
- `async reject_plugin(plugin_id, reason: str) -> None`
|
||||||
- [ ] `app/marketplace/plugin_review.py`:
|
- [x] `app/marketplace/plugin_review.py`:
|
||||||
- `ReviewQueue`:
|
- `ReviewQueue`:
|
||||||
- `async get_pending() -> list[dict]`
|
- `async get_pending() -> list[dict]`
|
||||||
- `async submit_review(plugin_id, reviewer_id, decision, notes) -> None`
|
- `async submit_review(plugin_id, reviewer_id, decision, notes) -> None`
|
||||||
- Security checklist enforced before approval: manifest schema valid, permissions are from allowed set, no binary blobs in manifest
|
- Security checklist enforced before approval: manifest schema valid, permissions are from allowed set, no binary blobs in manifest
|
||||||
- [ ] `app/marketplace/revenue_share.py`:
|
- [x] `app/marketplace/revenue_share.py`:
|
||||||
- `RevenueShare`:
|
- `RevenueShare`:
|
||||||
- `async record_install(plugin_id, user_id, amount_cents) -> None`
|
- `async record_install(plugin_id, user_id, amount_cents) -> None`
|
||||||
- `async payout_developer(plugin_id, period) -> None` — Stripe Connect transfer: 70% to developer
|
- `async payout_developer(plugin_id, period) -> None` — Stripe Connect transfer: 70% to developer
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Plugins routes: browse and install plugins from the marketplace.
|
"""Plugins routes: browse and install plugins from the marketplace.
|
||||||
|
|
||||||
The catalog and installation records are kept in-memory as stubs.
|
Backed by ``PluginRegistry`` and ``RevenueShare`` service classes introduced
|
||||||
Step 10 replaces these with PluginRegistry, RevenueShare, and the plugins DB table.
|
in Step 10. Step 12 will swap those services' in-memory stores for
|
||||||
|
PostgreSQL persistence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,49 +13,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.config.settings import settings
|
from app.marketplace.plugin_registry import registry
|
||||||
|
from app.marketplace.revenue_share import revenue_share
|
||||||
from app.schemas import PluginInstallRequest, PluginListResponse, PluginManifest, UserProfile
|
from app.schemas import PluginInstallRequest, PluginListResponse, PluginManifest, UserProfile
|
||||||
|
|
||||||
router = APIRouter(prefix="/plugins", tags=["plugins"])
|
router = APIRouter(prefix="/plugins", tags=["plugins"])
|
||||||
|
|
||||||
# ── In-memory catalog (Step 10 replaces with PluginRegistry + DB) ─────
|
|
||||||
|
|
||||||
_plugin_catalog: list[PluginManifest] = [
|
|
||||||
PluginManifest(
|
|
||||||
id="plugin-github-sync",
|
|
||||||
name="GitHub Sync",
|
|
||||||
description="Sync tasks with GitHub Issues and pull requests.",
|
|
||||||
version="1.0.0",
|
|
||||||
author="Adiuva",
|
|
||||||
permissions=["read:tasks", "write:tasks"],
|
|
||||||
category="productivity",
|
|
||||||
price_cents=0,
|
|
||||||
),
|
|
||||||
PluginManifest(
|
|
||||||
id="plugin-slack-notify",
|
|
||||||
name="Slack Notifier",
|
|
||||||
description="Post task and checkpoint updates to Slack channels.",
|
|
||||||
version="1.2.0",
|
|
||||||
author="Adiuva",
|
|
||||||
permissions=["read:tasks", "read:checkpoints"],
|
|
||||||
category="communication",
|
|
||||||
price_cents=499,
|
|
||||||
),
|
|
||||||
PluginManifest(
|
|
||||||
id="plugin-time-tracker",
|
|
||||||
name="Time Tracker",
|
|
||||||
description="Track time spent on tasks with automatic reporting.",
|
|
||||||
version="0.9.1",
|
|
||||||
author="Third Party",
|
|
||||||
permissions=["read:tasks", "write:tasks"],
|
|
||||||
category="productivity",
|
|
||||||
price_cents=999,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# plugin_id → set of user_ids who have installed it
|
|
||||||
_installations: dict[str, set[str]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tier gate ─────────────────────────────────────────────────────────
|
# ── Tier gate ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -67,43 +31,12 @@ def _require_plugin_tier(user: UserProfile) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Filter + sort helpers ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _apply_filters(
|
|
||||||
plugins: list[PluginManifest],
|
|
||||||
category: str | None,
|
|
||||||
q: str | None,
|
|
||||||
) -> list[PluginManifest]:
|
|
||||||
result = plugins
|
|
||||||
if category:
|
|
||||||
result = [p for p in result if p.category == category]
|
|
||||||
if q:
|
|
||||||
q_lower = q.lower()
|
|
||||||
result = [
|
|
||||||
p for p in result
|
|
||||||
if q_lower in p.name.lower() or q_lower in p.description.lower()
|
|
||||||
]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_sort(
|
|
||||||
plugins: list[PluginManifest],
|
|
||||||
sort: str,
|
|
||||||
) -> list[PluginManifest]:
|
|
||||||
if sort == "installs":
|
|
||||||
return sorted(plugins, key=lambda p: len(_installations.get(p.id, set())), reverse=True)
|
|
||||||
if sort == "rating":
|
|
||||||
# Placeholder until Step 10 introduces avg_rating from DB
|
|
||||||
return sorted(plugins, key=lambda p: -p.price_cents)
|
|
||||||
return plugins # "newest" = catalog insertion order
|
|
||||||
|
|
||||||
|
|
||||||
# ── Local detail schema ────────────────────────────────────────────────
|
# ── Local detail schema ────────────────────────────────────────────────
|
||||||
|
|
||||||
class _PluginDetail(BaseModel):
|
class _PluginDetail(BaseModel):
|
||||||
plugin: PluginManifest
|
plugin: PluginManifest
|
||||||
install_count: int
|
install_count: int
|
||||||
ratings: list[Any] # Step 10 populates from plugin_reviews table
|
ratings: list[Any] # Step 12 populates from plugin_reviews table
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────
|
# ── Routes ────────────────────────────────────────────────────────────
|
||||||
@@ -118,9 +51,7 @@ async def list_plugins(
|
|||||||
) -> PluginListResponse:
|
) -> PluginListResponse:
|
||||||
"""Browse the plugin marketplace. Requires Power tier or above."""
|
"""Browse the plugin marketplace. Requires Power tier or above."""
|
||||||
_require_plugin_tier(current_user)
|
_require_plugin_tier(current_user)
|
||||||
filtered = _apply_filters(_plugin_catalog, category, q)
|
return await registry.list_plugins(category=category, query=q, page=page, sort=sort)
|
||||||
sorted_plugins = _apply_sort(filtered, sort)
|
|
||||||
return PluginListResponse(plugins=sorted_plugins, total=len(sorted_plugins), page=page)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plugin_id}", response_model=_PluginDetail)
|
@router.get("/{plugin_id}", response_model=_PluginDetail)
|
||||||
@@ -130,13 +61,13 @@ async def get_plugin(
|
|||||||
) -> _PluginDetail:
|
) -> _PluginDetail:
|
||||||
"""Get full plugin details including install count. Requires Power tier or above."""
|
"""Get full plugin details including install count. Requires Power tier or above."""
|
||||||
_require_plugin_tier(current_user)
|
_require_plugin_tier(current_user)
|
||||||
plugin = next((p for p in _plugin_catalog if p.id == plugin_id), None)
|
entry = await registry.get_plugin(plugin_id)
|
||||||
if plugin is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
||||||
return _PluginDetail(
|
return _PluginDetail(
|
||||||
plugin=plugin,
|
plugin=entry["manifest"],
|
||||||
install_count=len(_installations.get(plugin_id, set())),
|
install_count=entry["install_count"],
|
||||||
ratings=[], # Step 10 populates from plugin_reviews table
|
ratings=[], # Step 12 populates from plugin_reviews table
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,20 +77,21 @@ async def install_plugin(
|
|||||||
body: PluginInstallRequest, # noqa: ARG001 — reserved for future fields
|
body: PluginInstallRequest, # noqa: ARG001 — reserved for future fields
|
||||||
current_user: UserProfile = Depends(get_current_user),
|
current_user: UserProfile = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Install a plugin. Triggers Stripe Connect for paid plugins when configured.
|
"""Install a plugin. Triggers Stripe Connect revenue split for paid plugins.
|
||||||
|
|
||||||
Requires Power tier or above.
|
Requires Power tier or above.
|
||||||
"""
|
"""
|
||||||
_require_plugin_tier(current_user)
|
_require_plugin_tier(current_user)
|
||||||
plugin = next((p for p in _plugin_catalog if p.id == plugin_id), None)
|
entry = await registry.get_plugin(plugin_id)
|
||||||
if plugin is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
||||||
|
|
||||||
if plugin.price_cents > 0 and settings.STRIPE_SECRET_KEY:
|
await revenue_share.record_install(
|
||||||
# TODO(Step10): stripe.PaymentIntent.create with destination charge (70/30 split)
|
plugin_id=plugin_id,
|
||||||
pass
|
user_id=current_user.id,
|
||||||
|
amount_cents=entry["manifest"].price_cents,
|
||||||
|
)
|
||||||
|
|
||||||
_installations.setdefault(plugin_id, set()).add(current_user.id)
|
|
||||||
download_url = f"https://cdn.adiuva.app/plugins/{plugin_id}/package.zip"
|
download_url = f"https://cdn.adiuva.app/plugins/{plugin_id}/package.zip"
|
||||||
return {"ok": True, "download_url": download_url}
|
return {"ok": True, "download_url": download_url}
|
||||||
|
|
||||||
@@ -170,5 +102,5 @@ async def uninstall_plugin(
|
|||||||
current_user: UserProfile = Depends(get_current_user),
|
current_user: UserProfile = Depends(get_current_user),
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
"""Unregister a plugin installation."""
|
"""Unregister a plugin installation."""
|
||||||
_installations.get(plugin_id, set()).discard(current_user.id)
|
await registry.record_uninstall(plugin_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
7
app/marketplace/__init__.py
Normal file
7
app/marketplace/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Plugin marketplace package.
|
||||||
|
|
||||||
|
Three service classes introduced in Step 10:
|
||||||
|
- ``PluginRegistry`` — catalog, submit/approve/reject, install counts
|
||||||
|
- ``ReviewQueue`` — approval workflow + security checklist
|
||||||
|
- ``RevenueShare`` — 70/30 split tracking and Stripe Connect payouts
|
||||||
|
"""
|
||||||
211
app/marketplace/plugin_registry.py
Normal file
211
app/marketplace/plugin_registry.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""Plugin catalog registry.
|
||||||
|
|
||||||
|
Maintains the authoritative list of plugins, their review status, and
|
||||||
|
aggregate install counts. Storage is in-memory until Step 12 migrates to
|
||||||
|
the ``plugins`` PostgreSQL table.
|
||||||
|
|
||||||
|
Module-level singleton::
|
||||||
|
|
||||||
|
from app.marketplace.plugin_registry import registry
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.schemas import PluginListResponse, PluginManifest
|
||||||
|
|
||||||
|
# ── Pre-seeded approved plugins (mirrors the Step 8 stub catalog) ─────
|
||||||
|
|
||||||
|
_SEED_PLUGINS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"manifest": PluginManifest(
|
||||||
|
id="plugin-github-sync",
|
||||||
|
name="GitHub Sync",
|
||||||
|
description="Sync tasks with GitHub Issues and pull requests.",
|
||||||
|
version="1.0.0",
|
||||||
|
author="Adiuva",
|
||||||
|
permissions=["read:tasks", "write:tasks"],
|
||||||
|
category="productivity",
|
||||||
|
price_cents=0,
|
||||||
|
),
|
||||||
|
"status": "approved",
|
||||||
|
"s3_package_key": "plugins/plugin-github-sync/1.0.0/package.zip",
|
||||||
|
"install_count": 0,
|
||||||
|
"avg_rating": 0.0,
|
||||||
|
"rejection_reason": None,
|
||||||
|
"submitted_at": int(time.time()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": PluginManifest(
|
||||||
|
id="plugin-slack-notify",
|
||||||
|
name="Slack Notifier",
|
||||||
|
description="Post task and checkpoint updates to Slack channels.",
|
||||||
|
version="1.2.0",
|
||||||
|
author="Adiuva",
|
||||||
|
permissions=["read:tasks", "read:checkpoints"],
|
||||||
|
category="communication",
|
||||||
|
price_cents=499,
|
||||||
|
),
|
||||||
|
"status": "approved",
|
||||||
|
"s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip",
|
||||||
|
"install_count": 0,
|
||||||
|
"avg_rating": 0.0,
|
||||||
|
"rejection_reason": None,
|
||||||
|
"submitted_at": int(time.time()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"manifest": PluginManifest(
|
||||||
|
id="plugin-time-tracker",
|
||||||
|
name="Time Tracker",
|
||||||
|
description="Track time spent on tasks with automatic reporting.",
|
||||||
|
version="0.9.1",
|
||||||
|
author="Third Party",
|
||||||
|
permissions=["read:tasks", "write:tasks"],
|
||||||
|
category="productivity",
|
||||||
|
price_cents=999,
|
||||||
|
),
|
||||||
|
"status": "approved",
|
||||||
|
"s3_package_key": "plugins/plugin-time-tracker/0.9.1/package.zip",
|
||||||
|
"install_count": 0,
|
||||||
|
"avg_rating": 0.0,
|
||||||
|
"rejection_reason": None,
|
||||||
|
"submitted_at": int(time.time()),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistry:
|
||||||
|
"""In-process plugin catalog.
|
||||||
|
|
||||||
|
All mutating methods are ``async`` to make the future DB swap transparent
|
||||||
|
to callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# plugin_id → entry dict (deep-copied so each instance is independent)
|
||||||
|
self._catalog: dict[str, dict[str, Any]] = {
|
||||||
|
e["manifest"].id: copy.deepcopy(e) for e in _SEED_PLUGINS
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_plugins(
|
||||||
|
self,
|
||||||
|
category: str | None = None,
|
||||||
|
query: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
sort: Literal["rating", "installs", "newest"] = "newest",
|
||||||
|
) -> PluginListResponse:
|
||||||
|
"""Return a page of approved plugins, optionally filtered and sorted."""
|
||||||
|
entries = [e for e in self._catalog.values() if e["status"] == "approved"]
|
||||||
|
|
||||||
|
if category:
|
||||||
|
entries = [e for e in entries if e["manifest"].category == category]
|
||||||
|
|
||||||
|
if query:
|
||||||
|
q_lower = query.lower()
|
||||||
|
entries = [
|
||||||
|
e
|
||||||
|
for e in entries
|
||||||
|
if q_lower in e["manifest"].name.lower()
|
||||||
|
or q_lower in e["manifest"].description.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if sort == "installs":
|
||||||
|
entries = sorted(entries, key=lambda e: e["install_count"], reverse=True)
|
||||||
|
elif sort == "rating":
|
||||||
|
entries = sorted(entries, key=lambda e: e["avg_rating"], reverse=True)
|
||||||
|
# "newest" = catalog insertion order (dict preserves insertion in Python 3.7+)
|
||||||
|
|
||||||
|
total = len(entries)
|
||||||
|
start = (page - 1) * _PAGE_SIZE
|
||||||
|
page_entries = entries[start : start + _PAGE_SIZE]
|
||||||
|
|
||||||
|
return PluginListResponse(
|
||||||
|
plugins=[e["manifest"] for e in page_entries],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_plugin(self, plugin_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Return ``{manifest, status, install_count, avg_rating}`` or ``None``."""
|
||||||
|
entry = self._catalog.get(plugin_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"manifest": entry["manifest"],
|
||||||
|
"status": entry["status"],
|
||||||
|
"install_count": entry["install_count"],
|
||||||
|
"avg_rating": entry["avg_rating"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def submit_plugin(
|
||||||
|
self,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
package_s3_key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Add *manifest* to the catalog with ``status='pending_review'``.
|
||||||
|
|
||||||
|
Returns the plugin_id. If a plugin with the same id already exists
|
||||||
|
it is overwritten (re-submission after rejection).
|
||||||
|
"""
|
||||||
|
plugin_id = manifest.id or str(uuid.uuid4())
|
||||||
|
self._catalog[plugin_id] = {
|
||||||
|
"manifest": manifest,
|
||||||
|
"status": "pending_review",
|
||||||
|
"s3_package_key": package_s3_key,
|
||||||
|
"install_count": 0,
|
||||||
|
"avg_rating": 0.0,
|
||||||
|
"rejection_reason": None,
|
||||||
|
"submitted_at": int(time.time()),
|
||||||
|
}
|
||||||
|
return plugin_id
|
||||||
|
|
||||||
|
async def approve_plugin(self, plugin_id: str) -> None:
|
||||||
|
"""Set *plugin_id* status to ``'approved'``.
|
||||||
|
|
||||||
|
Raises ``KeyError`` if the plugin is not found.
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._catalog:
|
||||||
|
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||||
|
self._catalog[plugin_id]["status"] = "approved"
|
||||||
|
self._catalog[plugin_id]["rejection_reason"] = None
|
||||||
|
|
||||||
|
async def reject_plugin(self, plugin_id: str, reason: str) -> None:
|
||||||
|
"""Set *plugin_id* status to ``'rejected'`` and record the reason.
|
||||||
|
|
||||||
|
Raises ``KeyError`` if the plugin is not found.
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._catalog:
|
||||||
|
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||||
|
self._catalog[plugin_id]["status"] = "rejected"
|
||||||
|
self._catalog[plugin_id]["rejection_reason"] = reason
|
||||||
|
|
||||||
|
async def record_install(self, plugin_id: str) -> None:
|
||||||
|
"""Increment the install count for *plugin_id* (no-op if not found)."""
|
||||||
|
if plugin_id in self._catalog:
|
||||||
|
self._catalog[plugin_id]["install_count"] += 1
|
||||||
|
|
||||||
|
async def record_uninstall(self, plugin_id: str) -> None:
|
||||||
|
"""Decrement the install count for *plugin_id*, floored at 0."""
|
||||||
|
if plugin_id in self._catalog:
|
||||||
|
current = self._catalog[plugin_id]["install_count"]
|
||||||
|
self._catalog[plugin_id]["install_count"] = max(0, current - 1)
|
||||||
|
|
||||||
|
# ── Internal helpers used by ReviewQueue ─────────────────────────
|
||||||
|
|
||||||
|
def _get_pending_entries(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return all entries with status='pending_review' (synchronous helper)."""
|
||||||
|
return [e for e in self._catalog.values() if e["status"] == "pending_review"]
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
registry = PluginRegistry()
|
||||||
127
app/marketplace/plugin_review.py
Normal file
127
app/marketplace/plugin_review.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Plugin review workflow.
|
||||||
|
|
||||||
|
Manages the approval queue for newly submitted plugins and enforces a
|
||||||
|
security checklist before any plugin is made visible in the marketplace.
|
||||||
|
|
||||||
|
Module-level singleton::
|
||||||
|
|
||||||
|
from app.marketplace.plugin_review import review_queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.marketplace.plugin_registry import registry
|
||||||
|
from app.schemas import PluginManifest
|
||||||
|
|
||||||
|
# ── Security policy ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"read:tasks",
|
||||||
|
"write:tasks",
|
||||||
|
"read:projects",
|
||||||
|
"write:projects",
|
||||||
|
"read:notes",
|
||||||
|
"write:notes",
|
||||||
|
"read:checkpoints",
|
||||||
|
"write:checkpoints",
|
||||||
|
"read:calendar",
|
||||||
|
"write:calendar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_PLUGIN_ID_RE = re.compile(r"^[a-z0-9-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manifest(manifest: PluginManifest) -> None:
|
||||||
|
"""Enforce the plugin security checklist.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
``ValueError`` on the first violation found. Callers should catch
|
||||||
|
this and return HTTP 422 / reject the submission.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. Plugin id matches ``^[a-z0-9-]+$``
|
||||||
|
2. All declared permissions are in ``ALLOWED_PERMISSIONS``
|
||||||
|
3. No manifest field contains raw binary data
|
||||||
|
"""
|
||||||
|
if not _PLUGIN_ID_RE.match(manifest.id):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid plugin id format: '{manifest.id}'. "
|
||||||
|
"Only lowercase letters, digits, and hyphens are allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
for perm in manifest.permissions:
|
||||||
|
if perm not in ALLOWED_PERMISSIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown permission: '{perm}'. "
|
||||||
|
f"Allowed permissions: {sorted(ALLOWED_PERMISSIONS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field_name, value in manifest.model_dump().items():
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
raise ValueError(
|
||||||
|
f"Binary content is not allowed in manifest field '{field_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewQueue:
|
||||||
|
"""Approval queue for pending plugin submissions.
|
||||||
|
|
||||||
|
Delegates status changes to the shared ``PluginRegistry`` singleton so
|
||||||
|
there is a single source of truth for plugin state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Completed reviews — Step 12 stores in plugin_reviews table
|
||||||
|
self._reviews: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def get_pending(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return all plugins currently awaiting review.
|
||||||
|
|
||||||
|
Each item is ``{plugin_id, manifest, submitted_at}``.
|
||||||
|
"""
|
||||||
|
entries = registry._get_pending_entries()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"plugin_id": e["manifest"].id,
|
||||||
|
"manifest": e["manifest"],
|
||||||
|
"submitted_at": e["submitted_at"],
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
async def submit_review(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
reviewer_id: str,
|
||||||
|
decision: Literal["approved", "rejected"],
|
||||||
|
notes: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Record a review decision and update the plugin's status.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
``KeyError`` if *plugin_id* is not found in the registry.
|
||||||
|
"""
|
||||||
|
if decision == "approved":
|
||||||
|
await registry.approve_plugin(plugin_id)
|
||||||
|
else:
|
||||||
|
await registry.reject_plugin(plugin_id, reason=notes)
|
||||||
|
|
||||||
|
self._reviews.append(
|
||||||
|
{
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"reviewer_id": reviewer_id,
|
||||||
|
"decision": decision,
|
||||||
|
"notes": notes,
|
||||||
|
"reviewed_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
review_queue = ReviewQueue()
|
||||||
205
app/marketplace/revenue_share.py
Normal file
205
app/marketplace/revenue_share.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Revenue share tracking and Stripe Connect payouts.
|
||||||
|
|
||||||
|
Records every plugin installation as a revenue event and facilitates
|
||||||
|
70 % / 30 % payouts to developers via Stripe Connect. Storage is
|
||||||
|
in-memory until Step 12 migrates to the ``revenue_events`` table.
|
||||||
|
|
||||||
|
Module-level singleton::
|
||||||
|
|
||||||
|
from app.marketplace.revenue_share import revenue_share
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import stripe as stripe_lib
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.marketplace.plugin_registry import registry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Revenue split constants ───────────────────────────────────────────
|
||||||
|
|
||||||
|
DEVELOPER_SHARE: float = 0.70
|
||||||
|
PLATFORM_SHARE: float = 0.30
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueShare:
|
||||||
|
"""Records installation revenue events and coordinates developer payouts.
|
||||||
|
|
||||||
|
Stripe Connect calls are gracefully stubbed when ``STRIPE_SECRET_KEY``
|
||||||
|
is not configured, consistent with the rest of the billing layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Step 12 replaces with revenue_events DB table
|
||||||
|
self._events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stripe_configured() -> bool:
|
||||||
|
return bool(settings.STRIPE_SECRET_KEY)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stripe() -> Any:
|
||||||
|
stripe_lib.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
return stripe_lib
|
||||||
|
|
||||||
|
# ── Core operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def record_install(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
user_id: str,
|
||||||
|
amount_cents: int,
|
||||||
|
) -> None:
|
||||||
|
"""Record a plugin installation and trigger a Stripe Connect charge if paid.
|
||||||
|
|
||||||
|
For free plugins (``amount_cents == 0``) no payment is initiated but
|
||||||
|
the event is still recorded for analytics.
|
||||||
|
|
||||||
|
For paid plugins the developer receives 70 % via a Stripe Connect
|
||||||
|
destination charge. If Stripe is not configured or the charge fails
|
||||||
|
the installation still succeeds (the event is recorded and the install
|
||||||
|
count is incremented) — a warning is logged for monitoring.
|
||||||
|
"""
|
||||||
|
developer_share_cents = int(amount_cents * DEVELOPER_SHARE)
|
||||||
|
stripe_transfer_id: str | None = None
|
||||||
|
|
||||||
|
if amount_cents > 0 and self._stripe_configured():
|
||||||
|
plugin_entry = registry._catalog.get(plugin_id)
|
||||||
|
developer_stripe_account: str | None = None
|
||||||
|
if plugin_entry:
|
||||||
|
# Step 12: look up developer's Stripe account from DB
|
||||||
|
# For now, the author field is used as a placeholder key.
|
||||||
|
developer_stripe_account = None # no real account yet
|
||||||
|
|
||||||
|
if developer_stripe_account:
|
||||||
|
try:
|
||||||
|
s = self._stripe()
|
||||||
|
transfer = s.Transfer.create(
|
||||||
|
amount=developer_share_cents,
|
||||||
|
currency="eur",
|
||||||
|
destination=developer_stripe_account,
|
||||||
|
description=f"Revenue share for plugin {plugin_id}",
|
||||||
|
metadata={"plugin_id": plugin_id, "user_id": user_id},
|
||||||
|
)
|
||||||
|
stripe_transfer_id = transfer["id"]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Stripe Connect transfer failed for plugin %s: %s",
|
||||||
|
plugin_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"No Stripe account on file for plugin %s developer; "
|
||||||
|
"skipping transfer.",
|
||||||
|
plugin_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._events.append(
|
||||||
|
{
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount_cents": amount_cents,
|
||||||
|
"developer_share_cents": developer_share_cents,
|
||||||
|
"stripe_transfer_id": stripe_transfer_id,
|
||||||
|
"paid_at": None,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await registry.record_install(plugin_id)
|
||||||
|
|
||||||
|
async def get_earnings(
|
||||||
|
self,
|
||||||
|
developer_id: str,
|
||||||
|
period: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return aggregated earnings for *developer_id*.
|
||||||
|
|
||||||
|
``period`` is an optional ``YYYY-MM`` string to restrict the window.
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"developer_id": str,
|
||||||
|
"period": str | None,
|
||||||
|
"total_installs": int,
|
||||||
|
"total_revenue_cents": int,
|
||||||
|
"developer_share_cents": int,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Find plugin ids belonging to this developer
|
||||||
|
developer_plugin_ids: set[str] = {
|
||||||
|
pid
|
||||||
|
for pid, entry in registry._catalog.items()
|
||||||
|
if entry["manifest"].author == developer_id
|
||||||
|
}
|
||||||
|
|
||||||
|
events = [e for e in self._events if e["plugin_id"] in developer_plugin_ids]
|
||||||
|
|
||||||
|
if period:
|
||||||
|
# Filter by YYYY-MM prefix of the created_at timestamp
|
||||||
|
events = [
|
||||||
|
e
|
||||||
|
for e in events
|
||||||
|
if time.strftime("%Y-%m", time.gmtime(e["created_at"])) == period
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"developer_id": developer_id,
|
||||||
|
"period": period,
|
||||||
|
"total_installs": len(events),
|
||||||
|
"total_revenue_cents": sum(e["amount_cents"] for e in events),
|
||||||
|
"developer_share_cents": sum(e["developer_share_cents"] for e in events),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def payout_developer(self, plugin_id: str, period: str) -> None:
|
||||||
|
"""Aggregate unpaid revenue for *period* and issue a Stripe Transfer.
|
||||||
|
|
||||||
|
Marks processed events with ``paid_at`` timestamp.
|
||||||
|
Stubs gracefully when Stripe is not configured.
|
||||||
|
"""
|
||||||
|
unpaid = [
|
||||||
|
e
|
||||||
|
for e in self._events
|
||||||
|
if e["plugin_id"] == plugin_id
|
||||||
|
and e["paid_at"] is None
|
||||||
|
and time.strftime("%Y-%m", time.gmtime(e["created_at"])) == period
|
||||||
|
]
|
||||||
|
|
||||||
|
total_dev_share = sum(e["developer_share_cents"] for e in unpaid)
|
||||||
|
if total_dev_share <= 0 or not unpaid:
|
||||||
|
logger.debug("Nothing to pay out for plugin %s in period %s", plugin_id, period)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._stripe_configured():
|
||||||
|
plugin_entry = registry._catalog.get(plugin_id)
|
||||||
|
developer_stripe_account: str | None = None # Step 12: fetch from DB
|
||||||
|
if plugin_entry and developer_stripe_account:
|
||||||
|
try:
|
||||||
|
s = self._stripe()
|
||||||
|
s.Transfer.create(
|
||||||
|
amount=total_dev_share,
|
||||||
|
currency="eur",
|
||||||
|
destination=developer_stripe_account,
|
||||||
|
description=f"Payout for plugin {plugin_id} period {period}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Payout transfer failed for plugin %s: %s", plugin_id, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
paid_ts = int(time.time())
|
||||||
|
for event in unpaid:
|
||||||
|
event["paid_at"] = paid_ts
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
revenue_share = RevenueShare()
|
||||||
387
tests/test_plugins.py
Normal file
387
tests/test_plugins.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"""Tests for Step 10: Plugin Marketplace.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- PluginRegistry: catalog management, filtering, sorting, install counts
|
||||||
|
- ReviewQueue: pending queue, review decisions, manifest security checklist
|
||||||
|
- RevenueShare: install event recording, earnings aggregation
|
||||||
|
- Route integration: tier gate, list/get/install/uninstall via TestClient
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from jose import jwt
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.marketplace.plugin_registry import PluginRegistry
|
||||||
|
from app.marketplace.plugin_review import ReviewQueue, validate_manifest
|
||||||
|
from app.marketplace.revenue_share import RevenueShare
|
||||||
|
from app.schemas import PluginManifest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jwt(tier: str = "power", user_id: str | None = None) -> str:
|
||||||
|
uid = user_id or str(uuid.uuid4())
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"sub": uid,
|
||||||
|
"email": f"{uid[:8]}@example.com",
|
||||||
|
"tier": tier,
|
||||||
|
"exp": now + 3600,
|
||||||
|
"iat": now,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(tier: str = "power") -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {_make_jwt(tier)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_manifest(
|
||||||
|
plugin_id: str | None = None,
|
||||||
|
category: str = "productivity",
|
||||||
|
price_cents: int = 0,
|
||||||
|
permissions: list[str] | None = None,
|
||||||
|
) -> PluginManifest:
|
||||||
|
pid = plugin_id or f"plugin-{uuid.uuid4().hex[:8]}"
|
||||||
|
return PluginManifest(
|
||||||
|
id=pid,
|
||||||
|
name=f"Plugin {pid}",
|
||||||
|
description=f"Description for {pid}",
|
||||||
|
version="1.0.0",
|
||||||
|
author="test-author",
|
||||||
|
permissions=permissions or ["read:tasks"],
|
||||||
|
category=category,
|
||||||
|
price_cents=price_cents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PluginRegistry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginRegistry:
|
||||||
|
"""Each test uses a fresh PluginRegistry instance to avoid catalog pollution."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reg(self) -> PluginRegistry:
|
||||||
|
return PluginRegistry()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_plugins_are_approved(self, reg: PluginRegistry) -> None:
|
||||||
|
result = await reg.list_plugins()
|
||||||
|
assert result.total == 3
|
||||||
|
assert all(p.id.startswith("plugin-") for p in result.plugins)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_approved_only(self, reg: PluginRegistry) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "plugins/key.zip")
|
||||||
|
result = await reg.list_plugins()
|
||||||
|
ids = [p.id for p in result.plugins]
|
||||||
|
assert manifest.id not in ids # still pending
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_filter_by_category(self, reg: PluginRegistry) -> None:
|
||||||
|
result = await reg.list_plugins(category="communication")
|
||||||
|
assert result.total == 1
|
||||||
|
assert result.plugins[0].id == "plugin-slack-notify"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_filter_by_query(self, reg: PluginRegistry) -> None:
|
||||||
|
result = await reg.list_plugins(query="time")
|
||||||
|
assert result.total == 1
|
||||||
|
assert result.plugins[0].id == "plugin-time-tracker"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sort_by_installs(self, reg: PluginRegistry) -> None:
|
||||||
|
await reg.record_install("plugin-slack-notify")
|
||||||
|
await reg.record_install("plugin-slack-notify")
|
||||||
|
result = await reg.list_plugins(sort="installs")
|
||||||
|
assert result.plugins[0].id == "plugin-slack-notify"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_plugin_found(self, reg: PluginRegistry) -> None:
|
||||||
|
entry = await reg.get_plugin("plugin-github-sync")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["manifest"].id == "plugin-github-sync"
|
||||||
|
assert "install_count" in entry
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_plugin_not_found(self, reg: PluginRegistry) -> None:
|
||||||
|
entry = await reg.get_plugin("no-such-plugin")
|
||||||
|
assert entry is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_submit_sets_pending(self, reg: PluginRegistry) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
plugin_id = await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
assert plugin_id == manifest.id
|
||||||
|
assert reg._catalog[plugin_id]["status"] == "pending_review"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_makes_visible(self, reg: PluginRegistry) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
await reg.approve_plugin(manifest.id)
|
||||||
|
result = await reg.list_plugins()
|
||||||
|
assert manifest.id in [p.id for p in result.plugins]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_stores_reason(self, reg: PluginRegistry) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
await reg.reject_plugin(manifest.id, reason="Unsafe permissions")
|
||||||
|
assert reg._catalog[manifest.id]["status"] == "rejected"
|
||||||
|
assert reg._catalog[manifest.id]["rejection_reason"] == "Unsafe permissions"
|
||||||
|
result = await reg.list_plugins()
|
||||||
|
assert manifest.id not in [p.id for p in result.plugins]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_unknown_raises_key_error(self, reg: PluginRegistry) -> None:
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await reg.approve_plugin("ghost-plugin")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_install_increments_count(self, reg: PluginRegistry) -> None:
|
||||||
|
await reg.record_install("plugin-github-sync")
|
||||||
|
entry = await reg.get_plugin("plugin-github-sync")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["install_count"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_uninstall_decrements_count(self, reg: PluginRegistry) -> None:
|
||||||
|
await reg.record_install("plugin-github-sync")
|
||||||
|
await reg.record_install("plugin-github-sync")
|
||||||
|
await reg.record_uninstall("plugin-github-sync")
|
||||||
|
entry = await reg.get_plugin("plugin-github-sync")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["install_count"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_uninstall_floors_at_zero(self, reg: PluginRegistry) -> None:
|
||||||
|
await reg.record_uninstall("plugin-github-sync") # already 0
|
||||||
|
entry = await reg.get_plugin("plugin-github-sync")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["install_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ReviewQueue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewQueue:
|
||||||
|
@pytest.fixture
|
||||||
|
def reg(self) -> PluginRegistry:
|
||||||
|
return PluginRegistry()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queue(self, reg: PluginRegistry) -> ReviewQueue:
|
||||||
|
# Patch the 'registry' name as bound inside plugin_review.py
|
||||||
|
with patch("app.marketplace.plugin_review.registry", reg):
|
||||||
|
yield ReviewQueue()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_pending_returns_submitted_plugins(
|
||||||
|
self, reg: PluginRegistry, queue: ReviewQueue
|
||||||
|
) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
pending = await queue.get_pending()
|
||||||
|
assert any(p["plugin_id"] == manifest.id for p in pending)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_submit_review_approved(
|
||||||
|
self, reg: PluginRegistry, queue: ReviewQueue
|
||||||
|
) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
await queue.submit_review(manifest.id, "reviewer-1", "approved", "Looks good")
|
||||||
|
assert reg._catalog[manifest.id]["status"] == "approved"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_submit_review_rejected(
|
||||||
|
self, reg: PluginRegistry, queue: ReviewQueue
|
||||||
|
) -> None:
|
||||||
|
manifest = _fresh_manifest()
|
||||||
|
await reg.submit_plugin(manifest, "key.zip")
|
||||||
|
await queue.submit_review(manifest.id, "reviewer-1", "rejected", "Bad permissions")
|
||||||
|
assert reg._catalog[manifest.id]["status"] == "rejected"
|
||||||
|
|
||||||
|
def test_validate_manifest_ok(self) -> None:
|
||||||
|
manifest = _fresh_manifest(permissions=["read:tasks", "write:notes"])
|
||||||
|
validate_manifest(manifest) # should not raise
|
||||||
|
|
||||||
|
def test_validate_manifest_unknown_permission(self) -> None:
|
||||||
|
manifest = _fresh_manifest(permissions=["read:tasks", "read:secrets"])
|
||||||
|
with pytest.raises(ValueError, match="Unknown permission"):
|
||||||
|
validate_manifest(manifest)
|
||||||
|
|
||||||
|
def test_validate_manifest_invalid_id_format(self) -> None:
|
||||||
|
manifest = _fresh_manifest(plugin_id="Plugin_ID_Invalid")
|
||||||
|
with pytest.raises(ValueError, match="Invalid plugin id format"):
|
||||||
|
validate_manifest(manifest)
|
||||||
|
|
||||||
|
def test_validate_manifest_id_with_uppercase(self) -> None:
|
||||||
|
manifest = _fresh_manifest(plugin_id="UpperCase")
|
||||||
|
with pytest.raises(ValueError, match="Invalid plugin id format"):
|
||||||
|
validate_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RevenueShare
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevenueShare:
|
||||||
|
@pytest.fixture
|
||||||
|
def reg(self) -> PluginRegistry:
|
||||||
|
return PluginRegistry()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rs(self, reg: PluginRegistry) -> RevenueShare:
|
||||||
|
# Patch the 'registry' name as bound inside revenue_share.py
|
||||||
|
with patch("app.marketplace.revenue_share.registry", reg):
|
||||||
|
yield RevenueShare()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_install_free_plugin(
|
||||||
|
self, reg: PluginRegistry, rs: RevenueShare
|
||||||
|
) -> None:
|
||||||
|
await rs.record_install("plugin-github-sync", "user-1", amount_cents=0)
|
||||||
|
assert len(rs._events) == 1
|
||||||
|
assert rs._events[0]["developer_share_cents"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_install_paid_plugin_no_stripe(
|
||||||
|
self, reg: PluginRegistry, rs: RevenueShare
|
||||||
|
) -> None:
|
||||||
|
# No STRIPE_SECRET_KEY configured in test env — should not crash
|
||||||
|
await rs.record_install("plugin-slack-notify", "user-2", amount_cents=499)
|
||||||
|
assert len(rs._events) == 1
|
||||||
|
assert rs._events[0]["amount_cents"] == 499
|
||||||
|
assert rs._events[0]["developer_share_cents"] == int(499 * 0.70)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_install_increments_registry_count(
|
||||||
|
self, reg: PluginRegistry, rs: RevenueShare
|
||||||
|
) -> None:
|
||||||
|
await rs.record_install("plugin-github-sync", "user-1", amount_cents=0)
|
||||||
|
entry = await reg.get_plugin("plugin-github-sync")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["install_count"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_earnings_empty(
|
||||||
|
self, reg: PluginRegistry, rs: RevenueShare
|
||||||
|
) -> None:
|
||||||
|
result = await rs.get_earnings("unknown-dev")
|
||||||
|
assert result["total_installs"] == 0
|
||||||
|
assert result["total_revenue_cents"] == 0
|
||||||
|
assert result["developer_share_cents"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_earnings_aggregates(
|
||||||
|
self, reg: PluginRegistry, rs: RevenueShare
|
||||||
|
) -> None:
|
||||||
|
# "Adiuva" is the author of the seeded plugins
|
||||||
|
await rs.record_install("plugin-slack-notify", "u1", amount_cents=499)
|
||||||
|
await rs.record_install("plugin-slack-notify", "u2", amount_cents=499)
|
||||||
|
result = await rs.get_earnings("Adiuva")
|
||||||
|
assert result["total_installs"] == 2
|
||||||
|
assert result["total_revenue_cents"] == 998
|
||||||
|
assert result["developer_share_cents"] == int(499 * 0.70) * 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginRoutes:
|
||||||
|
def test_list_plugins_requires_power_tier(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins", headers=_auth("free"))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_list_plugins_pro_tier_blocked(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins", headers=_auth("pro"))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_list_plugins_power_tier_ok(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins", headers=_auth("power"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "plugins" in data
|
||||||
|
assert data["total"] >= 3
|
||||||
|
|
||||||
|
def test_list_plugins_team_tier_ok(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins", headers=_auth("team"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_get_plugin_found(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins/plugin-github-sync", headers=_auth())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["plugin"]["id"] == "plugin-github-sync"
|
||||||
|
assert "install_count" in data
|
||||||
|
|
||||||
|
def test_get_plugin_not_found(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.get("/api/v1/plugins/no-such-plugin", headers=_auth())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_install_plugin_free(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/plugins/plugin-github-sync/install",
|
||||||
|
json={"plugin_id": "plugin-github-sync"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "download_url" in data
|
||||||
|
|
||||||
|
def test_install_plugin_not_found(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/plugins/ghost/install",
|
||||||
|
json={"plugin_id": "ghost"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_uninstall_plugin_ok(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/v1/plugins/plugin-github-sync/install",
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
|
||||||
|
def test_install_requires_power_tier(self) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/plugins/plugin-github-sync/install",
|
||||||
|
json={"plugin_id": "plugin-github-sync"},
|
||||||
|
headers=_auth("free"),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
Reference in New Issue
Block a user