feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

ERSTES Modul ueber die Phase-Q-Plugin-Architektur: Registrierung komplett
ueber das Plugin-Manifest (page_routes + menu_items) — routes/index.tsx und
Sidebar.tsx wurden NICHT angefasst. Der Komponenten-Map-Generator wired den
lazy import automatisch (38 Komponenten).

Backend existierte vollstaendig (listings mit search/tags/pagination,
listing-detail, install mit Ed25519-Signatur-Verify, verify, categories;
marketplace:read/admin + require_admin fuer Install), Frontend hatte 0%
Abdeckung.

- api/marketplace.ts: TanStack-Hooks (useMarketplaceListings mit
  search/tags/pagination, useMarketplaceListing, useMarketplaceCategories,
  useInstallFromMarketplace, useVerifyMarketplacePlugin)
- pages/Marketplace.tsx: Suche, Tag-Filter-Chips, Listing-Karten (Name,
  Version, Author, Beschreibung, Tags, Download-Counter, Verified-Badge,
  Preis/Kostenlos), Install mit Confirm (Admin-only via is_system_admin),
  Signatur-Verify, Ergebnis-Banner, Pagination — No-Access-Card ohne
  marketplace:read
- Manifest: page_route /marketplace + menu_item (Store-Icon, order 85,
  permission marketplace:read)
- i18n marketplace.* + nav.marketplace de/en

Verifikation: Vitest 10/10 (Rendering, No-Access, Empty/Error, Karten,
  Search+Tags, Admin-Gating, Install-Flow mit+ohne Confirm, Verify-Flow) ·
  tsc exit 0 · production build exit 0 · Backend-Regressionen
  (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check:
  page_route + menu_item korrekt.
This commit is contained in:
Agent Zero
2026-09-13 09:44:28 +02:00
parent a3201ae221
commit 289dfc8230
7 changed files with 741 additions and 5 deletions
@@ -0,0 +1,196 @@
/**
* Marketplace page tests — browse, search, install plugins (module 5/16).
*
* Covers: rendering, search input, tag filter buttons, listing cards with
* verified badge, install flow (admin only) with confirmation, verify
* signature flow, permission gating, empty and error states.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MarketplacePage } from '@/pages/Marketplace';
import type { MarketplaceListing } from '@/api/marketplace';
const installMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({
success: true, name: 'demo-plugin', display_name: 'Demo Plugin',
version: '1.0.0', installed: true, activated: true,
message: 'ok', error: null,
});
return Promise.resolve({});
});
const verifyMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({
name: 'demo-plugin', version: '1.0.0',
signature_valid: true, message: 'Signature valid',
});
return Promise.resolve({});
});
const makeListing = (overrides: Partial<MarketplaceListing> = {}): MarketplaceListing => ({
id: 'mk-1',
name: 'demo-plugin',
display_name: 'Demo Plugin',
description: 'A demo plugin for testing',
version: '1.0.0',
author: 'LeoCRM',
homepage: '',
download_url: 'https://example.com/demo.zip',
icon: '',
screenshots: [],
tags: ['crm', 'demo'],
price: 0,
is_verified: true,
download_count: 42,
min_app_version: '1.0.0',
license: 'MIT',
created_at: null,
updated_at: null,
...overrides,
});
let mockListings: MarketplaceListing[] = [];
let mockCategories: string[] = [];
let mockCanBrowse = true;
let mockIsAdmin = true;
let mockError = false;
vi.mock('@/api/marketplace', () => ({
useMarketplaceListings: () => ({
data: { listings: mockListings, total: mockListings.length, page: 1, page_size: 12 },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useMarketplaceCategories: () => ({
data: { categories: mockCategories, total: mockCategories.length },
isLoading: false,
}),
useInstallFromMarketplace: () => ({
mutate: installMut,
isPending: false,
}),
useVerifyMarketplacePlugin: () => ({
mutate: verifyMut,
isPending: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanBrowse || perm !== 'marketplace:read',
}),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (s: { user: { id: string; is_system_admin: boolean } }) => unknown) =>
selector({ user: { id: 'u-1', is_system_admin: mockIsAdmin } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<MarketplacePage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockListings = [];
mockCategories = [];
mockCanBrowse = true;
mockIsAdmin = true;
mockError = false;
});
describe('MarketplacePage', () => {
it('renders the page with title', () => {
renderPage();
expect(screen.getByTestId('marketplace-page')).toBeInTheDocument();
});
it('shows no-access card without marketplace:read', () => {
mockCanBrowse = false;
renderPage();
expect(screen.getByTestId('marketplace-no-access')).toBeInTheDocument();
});
it('shows empty state when no listings exist', () => {
renderPage();
expect(screen.getByTestId('marketplace-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
expect(screen.getByTestId('marketplace-error')).toBeInTheDocument();
});
it('renders listing cards with name, version, verified badge and tags', () => {
mockListings = [makeListing()];
renderPage();
expect(screen.getByTestId('marketplace-card-demo-plugin')).toBeInTheDocument();
expect(screen.getByText('Demo Plugin')).toBeInTheDocument();
expect(screen.getByText(/v1\.0\.0/)).toBeInTheDocument();
expect(screen.getByTestId('marketplace-verified-demo-plugin')).toBeInTheDocument();
expect(screen.getByText('crm')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('renders the search input and tag filter buttons', () => {
mockCategories = ['crm', 'mail'];
renderPage();
expect(screen.getByTestId('marketplace-search')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-all')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-crm')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-mail')).toBeInTheDocument();
});
it('hides the install button for non-admin users', () => {
mockListings = [makeListing()];
mockIsAdmin = false;
renderPage();
expect(screen.queryByTestId('marketplace-install-demo-plugin')).not.toBeInTheDocument();
// verify button is still there (marketplace:read)
expect(screen.getByTestId('marketplace-verify-demo-plugin')).toBeInTheDocument();
});
it('installs a plugin after confirmation', async () => {
mockListings = [makeListing()];
window.confirm = vi.fn(() => true);
renderPage();
fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin'));
await waitFor(() => {
expect(installMut).toHaveBeenCalledWith(
{ name: 'demo-plugin', activate: true },
expect.anything(),
);
});
// success result banner appears
expect(await screen.findByTestId('marketplace-result')).toBeInTheDocument();
});
it('does not install when the confirmation is rejected', () => {
mockListings = [makeListing()];
window.confirm = vi.fn(() => false);
renderPage();
fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin'));
expect(installMut).not.toHaveBeenCalled();
});
it('verifies a plugin signature and shows the result', async () => {
mockListings = [makeListing()];
renderPage();
fireEvent.click(screen.getByTestId('marketplace-verify-demo-plugin'));
await waitFor(() => {
expect(verifyMut).toHaveBeenCalledWith(
{ name: 'demo-plugin' },
expect.anything(),
);
});
expect(await screen.findByTestId('marketplace-result')).toHaveTextContent(/valid/i);
});
});