- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1025 lines
31 KiB
TypeScript
1025 lines
31 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 {
|
|
usePlatformSettings,
|
|
useUpdateGeneralSettings,
|
|
useUpdateStripeKeys,
|
|
useValidateStripeKeys,
|
|
useSubscriptionPlans,
|
|
useCreateSubscriptionPlan,
|
|
useUpdateSubscriptionPlan,
|
|
useDeleteSubscriptionPlan,
|
|
useSyncPlansWithStripe,
|
|
useSyncPlanToTenants,
|
|
} from '../usePlatformSettings';
|
|
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('usePlatformSettings hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('usePlatformSettings', () => {
|
|
it('fetches platform settings successfully', async () => {
|
|
const mockSettings = {
|
|
stripe_secret_key_masked: 'sk_test_***',
|
|
stripe_publishable_key_masked: 'pk_test_***',
|
|
stripe_webhook_secret_masked: 'whsec_***',
|
|
stripe_account_id: 'acct_123',
|
|
stripe_account_name: 'Test Account',
|
|
stripe_keys_validated_at: '2025-12-07T10:00:00Z',
|
|
stripe_validation_error: '',
|
|
has_stripe_keys: true,
|
|
stripe_keys_from_env: false,
|
|
email_check_interval_minutes: 5,
|
|
updated_at: '2025-12-07T10:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
|
|
|
|
const { result } = renderHook(() => usePlatformSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/platform/settings/');
|
|
expect(result.current.data).toEqual(mockSettings);
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => usePlatformSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
});
|
|
|
|
it('uses staleTime of 5 minutes', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
renderHook(() => usePlatformSettings(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalled();
|
|
});
|
|
|
|
// Query should be cached with 5 minute stale time
|
|
const cachedQuery = queryClient.getQueryState(['platformSettings']);
|
|
expect(cachedQuery).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('useUpdateGeneralSettings', () => {
|
|
it('updates general settings successfully', async () => {
|
|
const updatedSettings = {
|
|
email_check_interval_minutes: 10,
|
|
updated_at: '2025-12-07T11:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: updatedSettings });
|
|
|
|
const { result } = renderHook(() => useUpdateGeneralSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
email_check_interval_minutes: 10,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/settings/general/', {
|
|
email_check_interval_minutes: 10,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('updates query cache on success', async () => {
|
|
const updatedSettings = {
|
|
stripe_secret_key_masked: 'sk_test_***',
|
|
stripe_publishable_key_masked: 'pk_test_***',
|
|
stripe_webhook_secret_masked: 'whsec_***',
|
|
stripe_account_id: 'acct_123',
|
|
stripe_account_name: 'Test Account',
|
|
stripe_keys_validated_at: null,
|
|
stripe_validation_error: '',
|
|
has_stripe_keys: true,
|
|
stripe_keys_from_env: false,
|
|
email_check_interval_minutes: 15,
|
|
updated_at: '2025-12-07T11:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: updatedSettings });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateGeneralSettings(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
email_check_interval_minutes: 15,
|
|
});
|
|
});
|
|
|
|
const cachedData = queryClient.getQueryData(['platformSettings']);
|
|
expect(cachedData).toEqual(updatedSettings);
|
|
});
|
|
|
|
it('handles update error', async () => {
|
|
vi.mocked(apiClient.post).mockRejectedValue(new Error('Update failed'));
|
|
|
|
const { result } = renderHook(() => useUpdateGeneralSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({ email_check_interval_minutes: 5 });
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useUpdateStripeKeys', () => {
|
|
it('updates Stripe keys successfully', async () => {
|
|
const updatedSettings = {
|
|
stripe_secret_key_masked: 'sk_live_***',
|
|
stripe_publishable_key_masked: 'pk_live_***',
|
|
stripe_webhook_secret_masked: 'whsec_***',
|
|
has_stripe_keys: true,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: updatedSettings });
|
|
|
|
const { result } = renderHook(() => useUpdateStripeKeys(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
stripe_secret_key: 'sk_live_newkey',
|
|
stripe_publishable_key: 'pk_live_newkey',
|
|
stripe_webhook_secret: 'whsec_newsecret',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/settings/stripe/keys/', {
|
|
stripe_secret_key: 'sk_live_newkey',
|
|
stripe_publishable_key: 'pk_live_newkey',
|
|
stripe_webhook_secret: 'whsec_newsecret',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('updates partial Stripe keys', async () => {
|
|
const updatedSettings = {
|
|
stripe_secret_key_masked: 'sk_live_***',
|
|
has_stripe_keys: true,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: updatedSettings });
|
|
|
|
const { result } = renderHook(() => useUpdateStripeKeys(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
stripe_secret_key: 'sk_live_newkey',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/settings/stripe/keys/', {
|
|
stripe_secret_key: 'sk_live_newkey',
|
|
});
|
|
});
|
|
|
|
it('updates query cache on success', async () => {
|
|
const updatedSettings = {
|
|
stripe_secret_key_masked: 'sk_live_***',
|
|
has_stripe_keys: true,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: updatedSettings });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateStripeKeys(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
stripe_secret_key: 'sk_live_newkey',
|
|
});
|
|
});
|
|
|
|
const cachedData = queryClient.getQueryData(['platformSettings']);
|
|
expect(cachedData).toEqual(updatedSettings);
|
|
});
|
|
});
|
|
|
|
describe('useValidateStripeKeys', () => {
|
|
it('validates Stripe keys successfully', async () => {
|
|
const responseData = {
|
|
success: true,
|
|
message: 'Stripe keys validated successfully',
|
|
settings: {
|
|
stripe_keys_validated_at: '2025-12-07T12:00:00Z',
|
|
stripe_validation_error: '',
|
|
stripe_account_id: 'acct_123',
|
|
stripe_account_name: 'Test Account',
|
|
},
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: responseData });
|
|
|
|
const { result } = renderHook(() => useValidateStripeKeys(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/settings/stripe/validate/');
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data).toEqual(responseData);
|
|
});
|
|
});
|
|
|
|
it('updates query cache with settings on success', async () => {
|
|
const updatedSettings = {
|
|
stripe_keys_validated_at: '2025-12-07T12:00:00Z',
|
|
stripe_validation_error: '',
|
|
stripe_account_id: 'acct_123',
|
|
stripe_account_name: 'Test Account',
|
|
};
|
|
const responseData = {
|
|
success: true,
|
|
message: 'Validated',
|
|
settings: updatedSettings,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: responseData });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useValidateStripeKeys(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
const cachedData = queryClient.getQueryData(['platformSettings']);
|
|
expect(cachedData).toEqual(updatedSettings);
|
|
});
|
|
|
|
it('does not update cache when settings is not in response', async () => {
|
|
const responseData = {
|
|
success: false,
|
|
message: 'Invalid keys',
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: responseData });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useValidateStripeKeys(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
const cachedData = queryClient.getQueryData(['platformSettings']);
|
|
expect(cachedData).toBeUndefined();
|
|
});
|
|
|
|
it('handles validation error', async () => {
|
|
vi.mocked(apiClient.post).mockRejectedValue(new Error('Validation failed'));
|
|
|
|
const { result } = renderHook(() => useValidateStripeKeys(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync();
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useSubscriptionPlans', () => {
|
|
it('fetches subscription plans successfully', async () => {
|
|
const mockPlans = [
|
|
{
|
|
id: 1,
|
|
name: 'Basic',
|
|
description: 'Basic plan',
|
|
plan_type: 'base' as const,
|
|
stripe_product_id: 'prod_123',
|
|
stripe_price_id: 'price_123',
|
|
price_monthly: '29.99',
|
|
price_yearly: '299.99',
|
|
business_tier: 'basic',
|
|
features: ['feature1', 'feature2'],
|
|
limits: { max_users: 10 },
|
|
permissions: { can_use_plugins: false },
|
|
transaction_fee_percent: '2.5',
|
|
transaction_fee_fixed: '0.30',
|
|
sms_enabled: true,
|
|
sms_price_per_message_cents: 5,
|
|
masked_calling_enabled: false,
|
|
masked_calling_price_per_minute_cents: 0,
|
|
proxy_number_enabled: false,
|
|
proxy_number_monthly_fee_cents: 0,
|
|
contracts_enabled: false,
|
|
default_auto_reload_enabled: true,
|
|
default_auto_reload_threshold_cents: 500,
|
|
default_auto_reload_amount_cents: 2000,
|
|
is_active: true,
|
|
is_public: true,
|
|
is_most_popular: false,
|
|
show_price: true,
|
|
created_at: '2025-12-01T10:00:00Z',
|
|
updated_at: '2025-12-07T10:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Pro',
|
|
description: 'Pro plan',
|
|
plan_type: 'base' as const,
|
|
stripe_product_id: 'prod_456',
|
|
stripe_price_id: 'price_456',
|
|
price_monthly: '99.99',
|
|
price_yearly: null,
|
|
business_tier: 'pro',
|
|
features: ['feature1', 'feature2', 'feature3'],
|
|
limits: { max_users: 50 },
|
|
permissions: { can_use_plugins: true },
|
|
transaction_fee_percent: '2.0',
|
|
transaction_fee_fixed: '0.30',
|
|
sms_enabled: true,
|
|
sms_price_per_message_cents: 4,
|
|
masked_calling_enabled: true,
|
|
masked_calling_price_per_minute_cents: 10,
|
|
proxy_number_enabled: true,
|
|
proxy_number_monthly_fee_cents: 1500,
|
|
contracts_enabled: true,
|
|
default_auto_reload_enabled: false,
|
|
default_auto_reload_threshold_cents: 0,
|
|
default_auto_reload_amount_cents: 0,
|
|
is_active: true,
|
|
is_public: true,
|
|
is_most_popular: true,
|
|
show_price: true,
|
|
created_at: '2025-12-01T10:00:00Z',
|
|
updated_at: '2025-12-07T10:00:00Z',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlans });
|
|
|
|
const { result } = renderHook(() => useSubscriptionPlans(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/platform/subscription-plans/');
|
|
expect(result.current.data).toEqual(mockPlans);
|
|
expect(result.current.data).toHaveLength(2);
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => useSubscriptionPlans(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
});
|
|
|
|
it('uses staleTime of 5 minutes', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
renderHook(() => useSubscriptionPlans(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalled();
|
|
});
|
|
|
|
const cachedQuery = queryClient.getQueryState(['subscriptionPlans']);
|
|
expect(cachedQuery).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('useCreateSubscriptionPlan', () => {
|
|
it('creates subscription plan successfully', async () => {
|
|
const newPlan = {
|
|
name: 'Enterprise',
|
|
description: 'Enterprise plan',
|
|
plan_type: 'base' as const,
|
|
price_monthly: 199.99,
|
|
price_yearly: 1999.99,
|
|
business_tier: 'enterprise',
|
|
features: ['all_features'],
|
|
limits: { max_users: 100 },
|
|
permissions: { can_use_plugins: true },
|
|
transaction_fee_percent: 1.5,
|
|
transaction_fee_fixed: 0.30,
|
|
create_stripe_product: true,
|
|
};
|
|
|
|
const createdPlan = {
|
|
id: 3,
|
|
...newPlan,
|
|
price_monthly: '199.99',
|
|
price_yearly: '1999.99',
|
|
transaction_fee_percent: '1.5',
|
|
transaction_fee_fixed: '0.30',
|
|
stripe_product_id: 'prod_789',
|
|
stripe_price_id: 'price_789',
|
|
sms_enabled: false,
|
|
sms_price_per_message_cents: 0,
|
|
masked_calling_enabled: false,
|
|
masked_calling_price_per_minute_cents: 0,
|
|
proxy_number_enabled: false,
|
|
proxy_number_monthly_fee_cents: 0,
|
|
contracts_enabled: false,
|
|
default_auto_reload_enabled: false,
|
|
default_auto_reload_threshold_cents: 0,
|
|
default_auto_reload_amount_cents: 0,
|
|
is_active: true,
|
|
is_public: true,
|
|
is_most_popular: false,
|
|
show_price: true,
|
|
created_at: '2025-12-07T12:00:00Z',
|
|
updated_at: '2025-12-07T12:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: createdPlan });
|
|
|
|
const { result } = renderHook(() => useCreateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(newPlan);
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/subscription-plans/', newPlan);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
expect(result.current.data).toEqual(createdPlan);
|
|
});
|
|
});
|
|
|
|
it('creates plan with minimal data', async () => {
|
|
const minimalPlan = {
|
|
name: 'Minimal Plan',
|
|
};
|
|
|
|
const createdPlan = {
|
|
id: 4,
|
|
name: 'Minimal Plan',
|
|
description: '',
|
|
plan_type: 'base' as const,
|
|
stripe_product_id: '',
|
|
stripe_price_id: '',
|
|
price_monthly: null,
|
|
price_yearly: null,
|
|
business_tier: '',
|
|
features: [],
|
|
limits: {},
|
|
permissions: {},
|
|
transaction_fee_percent: '0',
|
|
transaction_fee_fixed: '0',
|
|
sms_enabled: false,
|
|
sms_price_per_message_cents: 0,
|
|
masked_calling_enabled: false,
|
|
masked_calling_price_per_minute_cents: 0,
|
|
proxy_number_enabled: false,
|
|
proxy_number_monthly_fee_cents: 0,
|
|
contracts_enabled: false,
|
|
default_auto_reload_enabled: false,
|
|
default_auto_reload_threshold_cents: 0,
|
|
default_auto_reload_amount_cents: 0,
|
|
is_active: true,
|
|
is_public: false,
|
|
is_most_popular: false,
|
|
show_price: false,
|
|
created_at: '2025-12-07T12:00:00Z',
|
|
updated_at: '2025-12-07T12:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: createdPlan });
|
|
|
|
const { result } = renderHook(() => useCreateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(minimalPlan);
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/subscription-plans/', minimalPlan);
|
|
});
|
|
|
|
it('invalidates subscription plans query on success', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 5 } });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
// Spy on invalidateQueries
|
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useCreateSubscriptionPlan(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ name: 'Test Plan' });
|
|
});
|
|
|
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['subscriptionPlans'] });
|
|
});
|
|
|
|
it('handles create error', async () => {
|
|
vi.mocked(apiClient.post).mockRejectedValue(new Error('Create failed'));
|
|
|
|
const { result } = renderHook(() => useCreateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({ name: 'Test' });
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useUpdateSubscriptionPlan', () => {
|
|
it('updates subscription plan successfully', async () => {
|
|
const updates = {
|
|
id: 1,
|
|
name: 'Updated Basic',
|
|
price_monthly: 39.99,
|
|
is_most_popular: true,
|
|
};
|
|
|
|
const updatedPlan = {
|
|
id: 1,
|
|
name: 'Updated Basic',
|
|
price_monthly: '39.99',
|
|
is_most_popular: true,
|
|
description: 'Basic plan',
|
|
plan_type: 'base' as const,
|
|
stripe_product_id: 'prod_123',
|
|
stripe_price_id: 'price_123',
|
|
price_yearly: '299.99',
|
|
business_tier: 'basic',
|
|
features: ['feature1', 'feature2'],
|
|
limits: { max_users: 10 },
|
|
permissions: { can_use_plugins: false },
|
|
transaction_fee_percent: '2.5',
|
|
transaction_fee_fixed: '0.30',
|
|
sms_enabled: true,
|
|
sms_price_per_message_cents: 5,
|
|
masked_calling_enabled: false,
|
|
masked_calling_price_per_minute_cents: 0,
|
|
proxy_number_enabled: false,
|
|
proxy_number_monthly_fee_cents: 0,
|
|
contracts_enabled: false,
|
|
default_auto_reload_enabled: true,
|
|
default_auto_reload_threshold_cents: 500,
|
|
default_auto_reload_amount_cents: 2000,
|
|
is_active: true,
|
|
is_public: true,
|
|
show_price: true,
|
|
created_at: '2025-12-01T10:00:00Z',
|
|
updated_at: '2025-12-07T13:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedPlan });
|
|
|
|
const { result } = renderHook(() => useUpdateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(updates);
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/platform/subscription-plans/1/', {
|
|
name: 'Updated Basic',
|
|
price_monthly: 39.99,
|
|
is_most_popular: true,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data).toEqual(updatedPlan);
|
|
});
|
|
});
|
|
|
|
it('updates only specified fields', async () => {
|
|
const updates = {
|
|
id: 2,
|
|
is_active: false,
|
|
};
|
|
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 2, is_active: false } });
|
|
|
|
const { result } = renderHook(() => useUpdateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(updates);
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/platform/subscription-plans/2/', {
|
|
is_active: false,
|
|
});
|
|
});
|
|
|
|
it('invalidates subscription plans query on success', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateSubscriptionPlan(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ id: 1, name: 'Updated' });
|
|
});
|
|
|
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['subscriptionPlans'] });
|
|
});
|
|
|
|
it('handles update error', async () => {
|
|
vi.mocked(apiClient.patch).mockRejectedValue(new Error('Update failed'));
|
|
|
|
const { result } = renderHook(() => useUpdateSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({ id: 1, name: 'Fail' });
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useDeleteSubscriptionPlan', () => {
|
|
it('deletes subscription plan successfully', async () => {
|
|
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
|
|
|
|
const { result } = renderHook(() => useDeleteSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(3);
|
|
});
|
|
|
|
expect(apiClient.delete).toHaveBeenCalledWith('/platform/subscription-plans/3/');
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('invalidates subscription plans query on success', async () => {
|
|
vi.mocked(apiClient.delete).mockResolvedValue({});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useDeleteSubscriptionPlan(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(5);
|
|
});
|
|
|
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['subscriptionPlans'] });
|
|
});
|
|
|
|
it('handles delete error', async () => {
|
|
vi.mocked(apiClient.delete).mockRejectedValue(new Error('Delete failed'));
|
|
|
|
const { result } = renderHook(() => useDeleteSubscriptionPlan(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(1);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useSyncPlansWithStripe', () => {
|
|
it('syncs plans with Stripe successfully', async () => {
|
|
const syncResponse = {
|
|
success: true,
|
|
message: 'Plans synced successfully',
|
|
synced_count: 3,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: syncResponse });
|
|
|
|
const { result } = renderHook(() => useSyncPlansWithStripe(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/subscription-plans/sync_with_stripe/');
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data).toEqual(syncResponse);
|
|
});
|
|
});
|
|
|
|
it('invalidates subscription plans query on success', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useSyncPlansWithStripe(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['subscriptionPlans'] });
|
|
});
|
|
|
|
it('handles sync error', async () => {
|
|
vi.mocked(apiClient.post).mockRejectedValue(new Error('Sync failed'));
|
|
|
|
const { result } = renderHook(() => useSyncPlansWithStripe(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync();
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useSyncPlanToTenants', () => {
|
|
it('syncs plan to tenants successfully', async () => {
|
|
const syncResponse = {
|
|
message: 'Plan synced to 15 tenants',
|
|
tenant_count: 15,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: syncResponse });
|
|
|
|
const { result } = renderHook(() => useSyncPlanToTenants(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(1);
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/platform/subscription-plans/1/sync_tenants/');
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data).toEqual(syncResponse);
|
|
});
|
|
});
|
|
|
|
it('handles zero tenants', async () => {
|
|
const syncResponse = {
|
|
message: 'No tenants on this plan',
|
|
tenant_count: 0,
|
|
};
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: syncResponse });
|
|
|
|
const { result } = renderHook(() => useSyncPlanToTenants(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(5);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data?.tenant_count).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('does not invalidate queries (no onSuccess callback)', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'Done', tenant_count: 5 } });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useSyncPlanToTenants(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(2);
|
|
});
|
|
|
|
// This mutation does not have an onSuccess callback, so it shouldn't invalidate
|
|
expect(invalidateQueriesSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles sync error', async () => {
|
|
vi.mocked(apiClient.post).mockRejectedValue(new Error('Sync to tenants failed'));
|
|
|
|
const { result } = renderHook(() => useSyncPlanToTenants(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(1);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
});
|