agnes-the-ai-analyst/tests/test_store_naming.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

83 lines
2.6 KiB
Python

"""Unit tests for src.store_naming."""
from __future__ import annotations
from pathlib import Path
import pytest
from src.store_naming import (
compute_entity_version,
sanitize_username,
suffixed_name,
)
class TestSanitizeUsername:
@pytest.mark.parametrize("email,expected", [
("c_marustamyan@groupon.com", "c-marustamyan"),
("john.doe+claude@acme.com", "john-doe-claude"),
("USER@example.com", "user"),
("a.b.c@x.y", "a-b-c"),
("plain@example.com", "plain"),
("UPPER_CASE-name@example.com", "upper-case-name"),
("name123@example.com", "name123"),
("dots..multiple..@example.com", "dots-multiple"),
])
def test_known_inputs(self, email, expected):
assert sanitize_username(email) == expected
def test_empty_after_sanitize_raises(self):
with pytest.raises(ValueError):
sanitize_username("---@example.com")
with pytest.raises(ValueError):
sanitize_username("@example.com")
def test_strips_leading_trailing_dashes(self):
assert sanitize_username("-foo-@example.com") == "foo"
class TestSuffixedName:
def test_basic(self):
assert suffixed_name("code-review", "honza") == "code-review-by-honza"
def test_preserves_original_chars(self):
assert suffixed_name("a-b-c", "u-v") == "a-b-c-by-u-v"
class TestComputeEntityVersion:
def test_deterministic_same_content(self, tmp_path: Path):
a = tmp_path / "a"; a.mkdir()
(a / "x.txt").write_text("hello")
(a / "y.md").write_text("---\nname: x\n---")
v1 = compute_entity_version(a)
b = tmp_path / "b"; b.mkdir()
(b / "x.txt").write_text("hello")
(b / "y.md").write_text("---\nname: x\n---")
v2 = compute_entity_version(b)
assert v1 == v2
assert len(v1) == 16
def test_changes_on_content_change(self, tmp_path: Path):
d = tmp_path / "d"; d.mkdir()
(d / "x.txt").write_text("hello")
v1 = compute_entity_version(d)
(d / "x.txt").write_text("hello!")
v2 = compute_entity_version(d)
assert v1 != v2
def test_changes_on_filename_change(self, tmp_path: Path):
d = tmp_path / "d"; d.mkdir()
(d / "x.txt").write_text("same content")
v1 = compute_entity_version(d)
(d / "x.txt").rename(d / "y.txt")
v2 = compute_entity_version(d)
assert v1 != v2
def test_empty_dir_returns_stable_hash(self, tmp_path: Path):
d = tmp_path / "d"; d.mkdir()
v = compute_entity_version(d)
assert isinstance(v, str)
assert len(v) == 16