agnes-the-ai-analyst/tests/test_store_repositories.py
Minas Arustamyan d5a7c9ad79 feat(store): /store + /my-ai-stack — community marketplace + per-user composition
Adds a community-driven Store where any authenticated user uploads
skills/agents/plugins as ZIPs, plus /my-ai-stack as the per-user
composition view. The served Claude Code marketplace is now:

    (admin_granted ∖ opt_outs) ∪ store_installs

Skill + agent installs are merged into a single `agnes-store-bundle`
plugin in the served marketplace; type=plugin uploads stay standalone.
Names are suffixed with `-by-<owner-username>` at upload time so two
owners can use the same display name without colliding in Claude Code's
flat skill/agent namespace.

Schema v23 → v24 adds three tables:
  - store_entities       — community-uploaded skills/agents/plugins
  - user_store_installs  — what each user has chosen to install
  - user_plugin_optouts  — opt-out overlay on top of admin grants

Admin grant-delete drops every user's opt-out for that plugin so
re-grant resets cleanly to enabled (no sticky personal preference).

UI:
  - /store      — e-commerce-style listing with type/category/owner
                  filters, search, pagination, owner-aware [Install]
                  buttons, clickable cards
  - /store/new  — 2-step upload wizard with drag & drop, preview
                  validation (POST /api/store/entities/preview), docs
                  multi-upload, photo + video URL
  - /store/{id} — detail page with hero, file list, docs, owner
                  actions (Edit/Delete) for the uploader
  - /my-ai-stack — Granted plugins (toggle opt-out) + From the Store
                  (uninstall) sections
  - Admin nav: Marketplaces moved into Admin dropdown, renamed to
                "Curated Marketplaces"

Validation hardening: type-mismatch guards reject skill ZIP uploaded as
agent (or vice versa), and plugin ZIPs masquerading as skills/agents.
Human-readable error messages mapped client-side from machine codes.

Cross-source naming: Store entity-id-prefixed dirs (`plugins/store-<id>/`)
plus the bundle (`plugins/store-bundle/`) avoid collisions with admin
marketplaces (whose `store` slug is reserved by `is_valid_slug`).

Bundle composition is content-hashed at serve time — install/uninstall
or owner re-upload bumps the bundle's plugin.json `version`, so Claude
Code's auto-update toggle picks up changes.

Tests: 50+ new tests across naming, repositories, filter (admin ∪ store
∪ bundle), API (upload/install/uninstall/delete/preview/docs), end-to-end
marketplace.zip with bundle merging.
2026-05-05 02:53:49 +02:00

160 lines
7 KiB
Python

"""Repository tests for store_entities, user_store_installs, user_plugin_optouts."""
from __future__ import annotations
import uuid
import pytest
@pytest.fixture
def db_conn(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
from src.db import get_system_db
conn = get_system_db()
yield conn
conn.close()
def _make_user(conn, *, user_id: str, email: str) -> None:
from src.repositories.users import UserRepository
UserRepository(conn).create(id=user_id, email=email, name=email.split("@")[0])
def _create_entity(conn, *, owner_id: str, owner_username: str, name: str,
type_: str = "skill") -> str:
from src.repositories.store_entities import StoreEntitiesRepository
repo = StoreEntitiesRepository(conn)
eid = uuid.uuid4().hex
repo.create(
id=eid, owner_user_id=owner_id, owner_username=owner_username,
type=type_, name=name, description="desc", category=None,
version="abcd1234abcd1234", file_size=100,
)
return eid
class TestStoreEntities:
def test_create_and_get(self, db_conn):
from src.repositories.store_entities import StoreEntitiesRepository
_make_user(db_conn, user_id="u1", email="u1@x")
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="my-skill")
e = StoreEntitiesRepository(db_conn).get(eid)
assert e is not None
assert e["name"] == "my-skill"
assert e["owner_username"] == "u1"
assert e["install_count"] == 0
assert e["doc_paths"] == []
def test_unique_owner_name(self, db_conn):
_make_user(db_conn, user_id="u1", email="u1@x")
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="dup")
with pytest.raises(Exception):
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="dup")
def test_different_owners_same_name_ok(self, db_conn):
_make_user(db_conn, user_id="u1", email="u1@x")
_make_user(db_conn, user_id="u2", email="u2@x")
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="shared")
_create_entity(db_conn, owner_id="u2", owner_username="u2", name="shared")
def test_list_with_filters(self, db_conn):
from src.repositories.store_entities import StoreEntitiesRepository
_make_user(db_conn, user_id="u1", email="u1@x")
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="alpha", type_="skill")
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="beta", type_="agent")
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="gamma", type_="plugin")
repo = StoreEntitiesRepository(db_conn)
items, total = repo.list(skip=0, limit=10)
assert total == 3
assert len(items) == 3
items, total = repo.list(skip=0, limit=10, type="skill")
assert total == 1 and items[0]["name"] == "alpha"
items, total = repo.list(skip=0, limit=10, search="bet")
assert total == 1 and items[0]["name"] == "beta"
def test_bump_install_count(self, db_conn):
from src.repositories.store_entities import StoreEntitiesRepository
_make_user(db_conn, user_id="u1", email="u1@x")
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
repo = StoreEntitiesRepository(db_conn)
repo.bump_install_count(eid, 1)
repo.bump_install_count(eid, 1)
assert repo.get(eid)["install_count"] == 2
repo.bump_install_count(eid, -1)
assert repo.get(eid)["install_count"] == 1
# Floor at zero
repo.bump_install_count(eid, -10)
assert repo.get(eid)["install_count"] == 0
class TestUserStoreInstalls:
def test_install_idempotent(self, db_conn):
from src.repositories.user_store_installs import UserStoreInstallsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
_make_user(db_conn, user_id="u2", email="u2@x")
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
repo = UserStoreInstallsRepository(db_conn)
assert repo.install("u2", eid) is True
assert repo.install("u2", eid) is False
assert repo.is_installed("u2", eid) is True
assert repo.installer_count(eid) == 1
def test_uninstall(self, db_conn):
from src.repositories.user_store_installs import UserStoreInstallsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
_make_user(db_conn, user_id="u2", email="u2@x")
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
repo = UserStoreInstallsRepository(db_conn)
repo.install("u2", eid)
assert repo.uninstall("u2", eid) is True
assert repo.uninstall("u2", eid) is False # already gone
def test_list_for_user_joins_entity(self, db_conn):
from src.repositories.user_store_installs import UserStoreInstallsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
_make_user(db_conn, user_id="u2", email="u2@x")
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="zzz")
repo = UserStoreInstallsRepository(db_conn)
repo.install("u2", eid)
rows = repo.list_for_user("u2")
assert len(rows) == 1
assert rows[0]["name"] == "zzz"
assert rows[0]["owner_username"] == "u1"
class TestUserPluginOptouts:
def test_set_toggle(self, db_conn):
from src.repositories.user_plugin_optouts import UserPluginOptoutsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
repo = UserPluginOptoutsRepository(db_conn)
repo.set("u1", "mkt", "p1", opted_out=True)
assert repo.is_opted_out("u1", "mkt", "p1") is True
assert ("mkt", "p1") in repo.opted_out_set("u1")
repo.set("u1", "mkt", "p1", opted_out=False)
assert repo.is_opted_out("u1", "mkt", "p1") is False
assert repo.opted_out_set("u1") == set()
def test_set_idempotent(self, db_conn):
from src.repositories.user_plugin_optouts import UserPluginOptoutsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
repo = UserPluginOptoutsRepository(db_conn)
repo.set("u1", "mkt", "p1", opted_out=True)
repo.set("u1", "mkt", "p1", opted_out=True) # second call: no-op
assert len(repo.list_for_user("u1")) == 1
def test_delete_for_plugin_drops_all_users(self, db_conn):
from src.repositories.user_plugin_optouts import UserPluginOptoutsRepository
_make_user(db_conn, user_id="u1", email="u1@x")
_make_user(db_conn, user_id="u2", email="u2@x")
repo = UserPluginOptoutsRepository(db_conn)
repo.set("u1", "mkt", "p1", opted_out=True)
repo.set("u2", "mkt", "p1", opted_out=True)
repo.set("u1", "mkt", "p2", opted_out=True) # different plugin — survives
dropped = repo.delete_for_plugin("mkt", "p1")
assert dropped == 2
assert repo.opted_out_set("u1") == {("mkt", "p2")}
assert repo.opted_out_set("u2") == set()