feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user