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>
245 lines
6.4 KiB
TypeScript
245 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import React from 'react';
|
|
|
|
// Mock apiClient
|
|
vi.mock('../../api/client', () => ({
|
|
default: {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import {
|
|
useResources,
|
|
useResource,
|
|
useCreateResource,
|
|
useUpdateResource,
|
|
useDeleteResource,
|
|
} from '../useResources';
|
|
import apiClient from '../../api/client';
|
|
|
|
// Create wrapper
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
};
|
|
};
|
|
|
|
describe('useResources hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('useResources', () => {
|
|
it('fetches resources and transforms data', async () => {
|
|
const mockResources = [
|
|
{ id: 1, name: 'Room 1', type: 'ROOM', max_concurrent_events: 2 },
|
|
{ id: 2, name: 'Staff 1', type: 'STAFF', user_id: 10 },
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
|
|
|
|
const { result } = renderHook(() => useResources(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/resources/?');
|
|
expect(result.current.data).toHaveLength(2);
|
|
expect(result.current.data?.[0]).toEqual({
|
|
id: '1',
|
|
name: 'Room 1',
|
|
type: 'ROOM',
|
|
userId: undefined,
|
|
maxConcurrentEvents: 2,
|
|
savedLaneCount: undefined,
|
|
userCanEditSchedule: false,
|
|
locationId: null,
|
|
locationName: null,
|
|
isMobile: false,
|
|
});
|
|
});
|
|
|
|
it('applies type filter', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
renderHook(() => useResources({ type: 'STAFF' }), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalledWith('/resources/?type=STAFF');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useResource', () => {
|
|
it('fetches single resource by id', async () => {
|
|
const mockResource = {
|
|
id: 1,
|
|
name: 'Room 1',
|
|
type: 'ROOM',
|
|
max_concurrent_events: 1,
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResource });
|
|
|
|
const { result } = renderHook(() => useResource('1'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/resources/1/');
|
|
expect(result.current.data?.name).toBe('Room 1');
|
|
});
|
|
|
|
it('does not fetch when id is empty', async () => {
|
|
const { result } = renderHook(() => useResource(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(apiClient.get).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useCreateResource', () => {
|
|
it('creates resource with backend field mapping', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'New Room',
|
|
type: 'ROOM',
|
|
maxConcurrentEvents: 3,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
|
|
name: 'New Room',
|
|
type: 'ROOM',
|
|
user_id: null,
|
|
max_concurrent_events: 3,
|
|
});
|
|
});
|
|
|
|
it('converts userId to user_id integer', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'Staff',
|
|
type: 'STAFF',
|
|
userId: '42',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
|
|
user_id: 42,
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe('useUpdateResource', () => {
|
|
it('updates resource with mapped fields', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useUpdateResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
id: '1',
|
|
updates: { name: 'Updated Room', maxConcurrentEvents: 5 },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
|
|
name: 'Updated Room',
|
|
max_concurrent_events: 5,
|
|
});
|
|
});
|
|
|
|
it('handles userId update', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
id: '1',
|
|
updates: { userId: '10' },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
|
|
user: 10,
|
|
});
|
|
});
|
|
|
|
it('sets user to null when userId is empty', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
id: '1',
|
|
updates: { userId: '' },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
|
|
user: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useDeleteResource', () => {
|
|
it('deletes resource by id', async () => {
|
|
vi.mocked(apiClient.delete).mockResolvedValue({});
|
|
|
|
const { result } = renderHook(() => useDeleteResource(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('5');
|
|
});
|
|
|
|
expect(apiClient.delete).toHaveBeenCalledWith('/resources/5/');
|
|
});
|
|
});
|
|
});
|