Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
167 lines
6.6 KiB
TypeScript
167 lines
6.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import ApiTokensSection from '../ApiTokensSection';
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
|
}),
|
|
}));
|
|
|
|
// Mock the hooks
|
|
const mockTokens = [
|
|
{
|
|
id: '1',
|
|
name: 'Test Token',
|
|
key_prefix: 'abc123',
|
|
scopes: ['read:appointments', 'write:appointments'],
|
|
is_active: true,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
last_used_at: '2024-01-02T00:00:00Z',
|
|
expires_at: null,
|
|
created_by: { full_name: 'John Doe', username: 'john' },
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Revoked Token',
|
|
key_prefix: 'xyz789',
|
|
scopes: ['read:resources'],
|
|
is_active: false,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
last_used_at: null,
|
|
expires_at: null,
|
|
created_by: null,
|
|
},
|
|
];
|
|
|
|
const mockUseApiTokens = vi.fn();
|
|
const mockUseCreateApiToken = vi.fn();
|
|
const mockUseRevokeApiToken = vi.fn();
|
|
const mockUseUpdateApiToken = vi.fn();
|
|
|
|
vi.mock('../../hooks/useApiTokens', () => ({
|
|
useApiTokens: () => mockUseApiTokens(),
|
|
useCreateApiToken: () => mockUseCreateApiToken(),
|
|
useRevokeApiToken: () => mockUseRevokeApiToken(),
|
|
useUpdateApiToken: () => mockUseUpdateApiToken(),
|
|
API_SCOPES: [
|
|
{ value: 'read:appointments', label: 'Read Appointments', description: 'View appointments' },
|
|
{ value: 'write:appointments', label: 'Write Appointments', description: 'Create/edit appointments' },
|
|
{ value: 'read:resources', label: 'Read Resources', description: 'View resources' },
|
|
],
|
|
SCOPE_PRESETS: {
|
|
read_only: { label: 'Read Only', description: 'View data only', scopes: ['read:appointments', 'read:resources'] },
|
|
read_write: { label: 'Read & Write', description: 'Full access', scopes: ['read:appointments', 'write:appointments', 'read:resources'] },
|
|
custom: { label: 'Custom', description: 'Select individual permissions', scopes: [] },
|
|
},
|
|
}));
|
|
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('ApiTokensSection', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockUseCreateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
mockUseRevokeApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
mockUseUpdateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
|
});
|
|
|
|
it('renders loading state', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders error state', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: false, error: new Error('Failed') });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText(/Failed to load API tokens/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders empty state when no tokens', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('No API tokens yet')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders tokens list', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('Test Token')).toBeInTheDocument();
|
|
expect(screen.getByText('Revoked Token')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders section title', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders New Token button', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('New Token')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders API Docs link', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('API Docs')).toBeInTheDocument();
|
|
});
|
|
|
|
it('opens new token modal when button clicked', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
fireEvent.click(screen.getByText('New Token'));
|
|
// Modal title should appear
|
|
expect(screen.getByRole('heading', { name: 'Create API Token' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows active tokens count', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText(/Active Tokens \(1\)/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows revoked tokens count', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText(/Revoked Tokens \(1\)/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows token key prefix', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText(/abc123••••••••/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows revoked badge for inactive tokens', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('Revoked')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders description text', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText(/Create and manage API tokens/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders create button in empty state', () => {
|
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
|
expect(screen.getByText('Create API Token')).toBeInTheDocument();
|
|
});
|
|
});
|