Add TenantCustomTier system and fix BusinessEditModal feature loading
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>
This commit is contained in:
@@ -508,4 +508,230 @@ describe('TrialBanner', () => {
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Edge Cases', () => {
|
||||
it('should handle negative days left gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: -5,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should still render (backend shouldn't send this, but defensive coding)
|
||||
expect(screen.getByText(/-5 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle fractional days by rounding', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5.7 as number,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should display with the value received
|
||||
expect(screen.getByText(/5.7 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should transition from urgent to non-urgent styling on update', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 3,
|
||||
});
|
||||
|
||||
const { container, rerender } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Initially urgent
|
||||
expect(container.querySelector('.from-red-500')).toBeInTheDocument();
|
||||
|
||||
// Update to non-urgent
|
||||
const updatedBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<TrialBanner business={updatedBusiness} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should now be non-urgent
|
||||
expect(container.querySelector('.from-blue-600')).toBeInTheDocument();
|
||||
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle business without name gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
name: '',
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should still render the banner
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle switching from active to inactive trial', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5,
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
|
||||
// Update to inactive
|
||||
const updatedBusiness = createMockBusiness({
|
||||
isTrialActive: false,
|
||||
daysLeftInTrial: 5,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<TrialBanner business={updatedBusiness} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should no longer render
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Interactions', () => {
|
||||
it('should prevent multiple rapid clicks on upgrade button', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(upgradeButton);
|
||||
fireEvent.click(upgradeButton);
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
// Navigate should still only be called once per click (no debouncing in component)
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not interfere with other buttons after dismiss', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Banner is gone
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
|
||||
// Upgrade button should also be gone
|
||||
expect(screen.queryByRole('button', { name: /upgrade now/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should have shadow and proper background for visibility', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const banner = container.querySelector('.shadow-md');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have gradient background for visual appeal', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const gradient = container.querySelector('.bg-gradient-to-r');
|
||||
expect(gradient).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show hover states on interactive elements', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
expect(upgradeButton).toHaveClass('hover:bg-blue-50');
|
||||
});
|
||||
|
||||
it('should have appropriate spacing and padding', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Check for padding classes
|
||||
const contentContainer = container.querySelector('.py-3');
|
||||
expect(contentContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render icons with proper size', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Icons should have consistent size classes
|
||||
const iconContainer = container.querySelector('.rounded-full');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show different icons for urgent vs non-urgent states', () => {
|
||||
const nonUrgentBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container: container1, unmount } = renderWithRouter(
|
||||
<TrialBanner business={nonUrgentBusiness} />
|
||||
);
|
||||
|
||||
// Non-urgent should not have pulse animation
|
||||
expect(container1.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
const urgentBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 2,
|
||||
});
|
||||
|
||||
const { container: container2 } = renderWithRouter(
|
||||
<TrialBanner business={urgentBusiness} />
|
||||
);
|
||||
|
||||
// Urgent should have pulse animation
|
||||
expect(container2.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user