Add max_public_pages feature and site builder access control
- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10) - Gate site builder access based on max_public_pages entitlement - Auto-create Site with default booking page for new tenants - Update PageEditor to use useEntitlements hook for permission checks - Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor - Add force update functionality for superusers in PlanEditorWizard - Add comprehensive filters to all safe scripting get_* methods - Update plugin documentation with full filter reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
Star,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Modal, Alert } from '../../components/ui';
|
||||
import { FeaturePicker } from './FeaturePicker';
|
||||
@@ -29,8 +30,11 @@ import {
|
||||
useCreatePlanVersion,
|
||||
useUpdatePlan,
|
||||
useUpdatePlanVersion,
|
||||
useForceUpdatePlanVersion,
|
||||
isForceUpdateConfirmRequired,
|
||||
type PlanFeatureWrite,
|
||||
} from '../../hooks/useBillingAdmin';
|
||||
import { useCurrentUser } from '../../hooks/useAuth';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -110,13 +114,20 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
}) => {
|
||||
const { data: features, isLoading: featuresLoading } = useFeatures();
|
||||
const { data: addons } = useAddOnProducts();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const createPlanMutation = useCreatePlan();
|
||||
const createVersionMutation = useCreatePlanVersion();
|
||||
const updatePlanMutation = useUpdatePlan();
|
||||
const updateVersionMutation = useUpdatePlanVersion();
|
||||
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||||
|
||||
const isNewPlan = mode === 'create';
|
||||
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
|
||||
const isSuperuser = currentUser?.role === 'superuser';
|
||||
|
||||
// Force update state (for updating without creating new version)
|
||||
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
|
||||
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
|
||||
const [newMarketingFeature, setNewMarketingFeature] = useState('');
|
||||
@@ -313,11 +324,49 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Force update handler (updates existing version without creating new one)
|
||||
const handleForceUpdate = async () => {
|
||||
if (!initialData?.version?.id) return;
|
||||
|
||||
try {
|
||||
setForceUpdateError(null);
|
||||
|
||||
// First call without confirm to get affected subscriber count
|
||||
const response = await forceUpdateMutation.mutateAsync({
|
||||
id: initialData.version.id,
|
||||
name: formData.version_name,
|
||||
is_public: formData.is_public,
|
||||
price_monthly_cents: formData.price_monthly_cents,
|
||||
price_yearly_cents: formData.price_yearly_cents,
|
||||
transaction_fee_percent: formData.transaction_fee_percent,
|
||||
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||
trial_days: formData.trial_days,
|
||||
is_most_popular: formData.is_most_popular,
|
||||
show_price: formData.show_price,
|
||||
marketing_features: formData.marketing_features,
|
||||
stripe_product_id: formData.stripe_product_id,
|
||||
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||
features: formData.selectedFeatures,
|
||||
confirm: true, // Confirm immediately since user already acknowledged
|
||||
});
|
||||
|
||||
// If successful, close the modal
|
||||
if (!isForceUpdateConfirmRequired(response)) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to force update plan:', error);
|
||||
setForceUpdateError('Failed to update plan. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
createPlanMutation.isPending ||
|
||||
createVersionMutation.isPending ||
|
||||
updatePlanMutation.isPending ||
|
||||
updateVersionMutation.isPending;
|
||||
updateVersionMutation.isPending ||
|
||||
forceUpdateMutation.isPending;
|
||||
|
||||
// Derived values for display
|
||||
const monthlyEquivalent = formData.price_yearly_cents > 0
|
||||
@@ -345,7 +394,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
size="4xl"
|
||||
>
|
||||
{/* Grandfathering Warning */}
|
||||
{hasSubscribers && (
|
||||
{hasSubscribers && !showForceUpdateConfirm && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mb-4"
|
||||
@@ -359,6 +408,53 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Force Update Confirmation Dialog */}
|
||||
{showForceUpdateConfirm && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
Warning: This will affect existing customers
|
||||
</h4>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
|
||||
You are about to update this plan version <strong>in place</strong>. This will immediately
|
||||
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
|
||||
subscriber(s). This action cannot be undone.
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||
Only use this for correcting errors or minor adjustments. For significant changes,
|
||||
use the standard save which creates a new version and grandfathers existing subscribers.
|
||||
</p>
|
||||
{forceUpdateError && (
|
||||
<Alert variant="error" message={forceUpdateError} className="mb-3" />
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForceUpdateConfirm(false);
|
||||
setForceUpdateError(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForceUpdate}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Yes, Update All Subscribers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
{steps.map((step, index) => {
|
||||
@@ -834,7 +930,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
{!isFirstStep && (
|
||||
{!isFirstStep && !showForceUpdateConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
@@ -845,35 +941,51 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{!isLastStep ? (
|
||||
{!showForceUpdateConfirm && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Next
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !canProceed}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isLastStep ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Force Update button - only for superusers editing plans with subscribers */}
|
||||
{hasSubscribers && isSuperuser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForceUpdateConfirm(true)}
|
||||
disabled={isLoading || !canProceed}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Update Without Versioning
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !canProceed}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,23 @@ vi.mock('../../../hooks/useBillingAdmin', () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useForceUpdatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
|
||||
isPending: false,
|
||||
}),
|
||||
isForceUpdateConfirmRequired: (response: unknown) =>
|
||||
response !== null &&
|
||||
typeof response === 'object' &&
|
||||
'requires_confirm' in response &&
|
||||
(response as { requires_confirm: boolean }).requires_confirm === true,
|
||||
}));
|
||||
|
||||
// Mock useCurrentUser from useAuth
|
||||
vi.mock('../../../hooks/useAuth', () => ({
|
||||
useCurrentUser: () => ({
|
||||
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PlanEditorWizard', () => {
|
||||
@@ -409,4 +426,135 @@ describe('PlanEditorWizard', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Force Update (Superuser)', () => {
|
||||
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Should show "Update Without Versioning" button
|
||||
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Click the force update button
|
||||
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||
|
||||
// Should show confirmation dialog with warning
|
||||
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
|
||||
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can cancel force update confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Click the force update button
|
||||
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||
|
||||
// Click Cancel
|
||||
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
|
||||
|
||||
// Confirmation dialog should be hidden, back to normal footer
|
||||
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 0, // No subscribers
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Should NOT show "Update Without Versioning" button
|
||||
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,7 @@ export const FEATURE_CODES = {
|
||||
MAX_RESOURCES: 'max_resources',
|
||||
MAX_EVENT_TYPES: 'max_event_types',
|
||||
MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
|
||||
MAX_PUBLIC_PAGES: 'max_public_pages',
|
||||
} as const;
|
||||
|
||||
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];
|
||||
|
||||
@@ -5,11 +5,11 @@ import { config } from "../puckConfig";
|
||||
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
|
||||
import { Loader2, Plus, Trash2, FileText } from "lucide-react";
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
|
||||
|
||||
export const PageEditor: React.FC = () => {
|
||||
const { data: pages, isLoading } = usePages();
|
||||
const { user } = useAuth();
|
||||
const { getLimit, isLoading: entitlementsLoading } = useEntitlements();
|
||||
const updatePage = useUpdatePage();
|
||||
const createPage = useCreatePage();
|
||||
const deletePage = useDeletePage();
|
||||
@@ -18,6 +18,13 @@ export const PageEditor: React.FC = () => {
|
||||
const [showNewPageModal, setShowNewPageModal] = useState(false);
|
||||
const [newPageTitle, setNewPageTitle] = useState('');
|
||||
|
||||
// Get max_public_pages from billing entitlements
|
||||
// null = unlimited, 0 = no access, >0 = limited pages
|
||||
const maxPagesLimit = getLimit(FEATURE_CODES.MAX_PUBLIC_PAGES);
|
||||
const canCustomize = maxPagesLimit === null || maxPagesLimit > 0;
|
||||
const pageCount = pages?.length || 0;
|
||||
const canCreateMore = canCustomize && (maxPagesLimit === null || pageCount < maxPagesLimit);
|
||||
|
||||
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,9 +42,8 @@ export const PageEditor: React.FC = () => {
|
||||
const handlePublish = async (newData: any) => {
|
||||
if (!currentPage) return;
|
||||
|
||||
// Check if user has permission to customize
|
||||
const hasPermission = (user as any)?.tenant?.can_customize_booking_page || false;
|
||||
if (!hasPermission) {
|
||||
// Check if user has permission to customize (based on max_public_pages entitlement)
|
||||
if (!canCustomize) {
|
||||
toast.error("Your plan does not include site customization. Please upgrade to edit pages.");
|
||||
return;
|
||||
}
|
||||
@@ -84,7 +90,7 @@ export const PageEditor: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || entitlementsLoading) {
|
||||
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
|
||||
}
|
||||
|
||||
@@ -94,10 +100,8 @@ export const PageEditor: React.FC = () => {
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const maxPages = (user as any)?.tenant?.max_pages || 1;
|
||||
const pageCount = pages?.length || 0;
|
||||
const canCustomize = (user as any)?.tenant?.can_customize_booking_page || false;
|
||||
const canCreateMore = canCustomize && (maxPages === -1 || pageCount < maxPages);
|
||||
// Display max pages as string for UI (null = unlimited shown as ∞)
|
||||
const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
@@ -136,7 +140,7 @@ export const PageEditor: React.FC = () => {
|
||||
onClick={() => setShowNewPageModal(true)}
|
||||
disabled={!canCreateMore}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPages})`}
|
||||
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPagesDisplay})`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Page
|
||||
@@ -156,7 +160,7 @@ export const PageEditor: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{pageCount} / {maxPages === -1 ? '∞' : maxPages} pages
|
||||
{pageCount} / {maxPagesDisplay} pages
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -422,64 +422,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Configuration */}
|
||||
{/* Limits & Quotas - Dynamic from billing system */}
|
||||
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Limits Configuration
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Use -1 for unlimited. These limits control what this business can create.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Users
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={featureValues.max_users ?? 5}
|
||||
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Resources
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={featureValues.max_resources ?? 10}
|
||||
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_resources: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Pages
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={featureValues.max_pages ?? 1}
|
||||
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_pages: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Locations
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={featureValues.max_locations ?? 1}
|
||||
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_locations: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DynamicFeaturesEditor
|
||||
values={featureValues}
|
||||
onChange={(fieldName, value) => {
|
||||
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
featureType="integer"
|
||||
headerTitle="Limits & Quotas"
|
||||
showDescriptions
|
||||
columns={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Site Builder Access */}
|
||||
@@ -488,7 +442,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
Site Builder
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Access the public-facing website builder for this business. Current limit: {featureValues.max_pages === -1 ? 'unlimited' : (featureValues.max_pages ?? 1)} page{(featureValues.max_pages ?? 1) !== 1 ? 's' : ''}.
|
||||
Access the public-facing website builder for this business. Current limit: {(featureValues.max_public_pages === null || featureValues.max_public_pages === -1) ? 'unlimited' : (featureValues.max_public_pages ?? 0)} page{(featureValues.max_public_pages ?? 0) !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
<a
|
||||
href={`http://${business.subdomain}.lvh.me:5173/site-editor`}
|
||||
@@ -510,14 +464,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
onChange={(fieldName, value) => {
|
||||
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
// Show only boolean features (limits are in the grid above)
|
||||
featureType="boolean"
|
||||
// Skip limits category since they're shown in the dedicated grid
|
||||
excludeCodes={[
|
||||
'max_users', 'max_resources', 'max_locations', 'max_services',
|
||||
'max_customers', 'max_appointments_per_month', 'max_sms_per_month',
|
||||
'max_email_per_month', 'max_storage_mb', 'max_api_calls_per_day',
|
||||
]}
|
||||
headerTitle="Features & Permissions"
|
||||
showDescriptions
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,7 @@ FEATURES = [
|
||||
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
|
||||
{"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
|
||||
{"code": "white_label", "name": "White Label", "description": "Remove all SmoothSchedule branding completely", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 50},
|
||||
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
|
||||
|
||||
# --- Plugins & Automation ---
|
||||
{"code": "can_use_plugins", "name": "Use Plugins", "description": "Install and use marketplace plugins", "feature_type": "boolean", "category": "plugins", "tenant_field_name": "can_use_plugins", "display_order": 10},
|
||||
@@ -134,6 +135,7 @@ PLANS = [
|
||||
"max_storage_mb": 250,
|
||||
"max_api_calls_per_day": 0,
|
||||
"max_sms_per_month": 0,
|
||||
"max_public_pages": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -171,6 +173,7 @@ PLANS = [
|
||||
"max_storage_mb": 2000,
|
||||
"max_api_calls_per_day": 0,
|
||||
"max_sms_per_month": 0,
|
||||
"max_public_pages": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -216,6 +219,7 @@ PLANS = [
|
||||
"max_sms_per_month": 2000,
|
||||
"max_storage_mb": 10000,
|
||||
"max_api_calls_per_day": 10000,
|
||||
"max_public_pages": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -270,6 +274,7 @@ PLANS = [
|
||||
"max_sms_per_month": 10000,
|
||||
"max_storage_mb": 50000,
|
||||
"max_api_calls_per_day": 50000,
|
||||
"max_public_pages": 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -332,6 +337,7 @@ PLANS = [
|
||||
"max_sms_per_month": None,
|
||||
"max_storage_mb": None,
|
||||
"max_api_calls_per_day": None,
|
||||
"max_public_pages": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -100,3 +100,14 @@ def create_default_page_for_site(sender, instance, created, **kwargs):
|
||||
"""Automatically create a default home page when a new Site is created."""
|
||||
if created:
|
||||
instance.create_default_page()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Tenant)
|
||||
def create_site_for_tenant(sender, instance, created, **kwargs):
|
||||
"""Automatically create a Site with default page for new tenants.
|
||||
|
||||
This ensures ALL tenants (including Free tier) get a default booking page.
|
||||
The Site post_save signal will then create the default home page.
|
||||
"""
|
||||
if created:
|
||||
Site.objects.get_or_create(tenant=instance)
|
||||
|
||||
@@ -8,6 +8,7 @@ from .models import Site, Page, Domain
|
||||
from .serializers import SiteSerializer, PageSerializer, DomainSerializer, PublicPageSerializer, PublicServiceSerializer
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from smoothschedule.scheduling.schedule.models import Service
|
||||
from smoothschedule.billing.services.entitlements import EntitlementService
|
||||
|
||||
class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
|
||||
serializer_class = SiteSerializer
|
||||
@@ -53,17 +54,20 @@ class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.Upd
|
||||
return Response(serializer.data)
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Check if tenant has permission to customize booking page
|
||||
if not tenant.can_customize_booking_page:
|
||||
# Check max_public_pages from billing system
|
||||
max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
|
||||
# None means unlimited, 0 means no access
|
||||
can_customize = max_pages is None or max_pages > 0
|
||||
|
||||
if not can_customize:
|
||||
return Response({
|
||||
"error": "Your plan does not include site customization. Please upgrade to a paid plan to create and edit pages."
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Check page limit
|
||||
# Check page limit (None = unlimited)
|
||||
current_page_count = Page.objects.filter(site=site).count()
|
||||
max_pages = tenant.max_pages
|
||||
|
||||
if max_pages != -1 and current_page_count >= max_pages:
|
||||
if max_pages is not None and current_page_count >= max_pages:
|
||||
return Response({
|
||||
"error": f"Page limit reached. Your plan allows {max_pages} page(s). Please upgrade to create more pages."
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
@@ -81,25 +85,31 @@ class PageViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return Page.objects.filter(site__tenant=self.request.tenant)
|
||||
|
||||
def _check_site_permission(self, tenant):
|
||||
"""Check if tenant can edit site based on max_public_pages entitlement."""
|
||||
max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
|
||||
# None means unlimited, 0 means no access
|
||||
return max_pages is None or max_pages > 0
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Check if tenant has permission to customize booking page
|
||||
if not request.tenant.can_customize_booking_page:
|
||||
# Check max_public_pages from billing system
|
||||
if not self._check_site_permission(request.tenant):
|
||||
return Response({
|
||||
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Check if tenant has permission to customize booking page
|
||||
if not request.tenant.can_customize_booking_page:
|
||||
# Check max_public_pages from billing system
|
||||
if not self._check_site_permission(request.tenant):
|
||||
return Response({
|
||||
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Check if tenant has permission to customize booking page
|
||||
if not request.tenant.can_customize_booking_page:
|
||||
# Check max_public_pages from billing system
|
||||
if not self._check_site_permission(request.tenant):
|
||||
return Response({
|
||||
"error": "Your plan does not include site customization. Please upgrade to a paid plan to delete pages."
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -847,3 +847,872 @@ class TestValidatePluginWhitelistValidation:
|
||||
|
||||
assert len(result['warnings']) > 0
|
||||
assert any('dynamic URL' in w for w in result['warnings'])
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TESTS FOR NEW API METHODS
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestSafeScriptAPIFeatureCheck:
|
||||
"""Tests for SafeScriptAPI._check_feature method."""
|
||||
|
||||
def test_has_check_feature_method(self):
|
||||
"""Should have _check_feature method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, '_check_feature')
|
||||
assert callable(api._check_feature)
|
||||
|
||||
def test_raises_when_feature_not_available(self):
|
||||
"""Should raise ScriptExecutionError when feature not available."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api._check_feature('sms_enabled', 'SMS messaging')
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
def test_passes_when_feature_available(self):
|
||||
"""Should not raise when feature is available."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=True)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
# Should not raise
|
||||
api._check_feature('sms_enabled', 'SMS messaging')
|
||||
mock_business.has_feature.assert_called_with('sms_enabled')
|
||||
|
||||
|
||||
class TestSafeScriptAPISendSMS:
|
||||
"""Tests for SafeScriptAPI.send_sms method."""
|
||||
|
||||
def test_has_send_sms_method(self):
|
||||
"""Should have send_sms method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'send_sms')
|
||||
assert callable(api.send_sms)
|
||||
|
||||
def test_validates_phone_number(self):
|
||||
"""Should validate phone number."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=True)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.send_sms('123', 'Test message')
|
||||
|
||||
assert 'Invalid phone number' in str(exc_info.value)
|
||||
|
||||
def test_validates_message_length(self):
|
||||
"""Should validate message length."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=True)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.send_sms('+15551234567', 'x' * 2000)
|
||||
|
||||
assert 'too long' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetSMSBalance:
|
||||
"""Tests for SafeScriptAPI.get_sms_balance method."""
|
||||
|
||||
def test_has_get_sms_balance_method(self):
|
||||
"""Should have get_sms_balance method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_sms_balance')
|
||||
assert callable(api.get_sms_balance)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetResources:
|
||||
"""Tests for SafeScriptAPI.get_resources method."""
|
||||
|
||||
def test_has_get_resources_method(self):
|
||||
"""Should have get_resources method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_resources')
|
||||
assert callable(api.get_resources)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Resource')
|
||||
def test_returns_list(self, mock_resource_class):
|
||||
"""Should return a list of resources."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_resource = Mock()
|
||||
mock_resource.id = 1
|
||||
mock_resource.name = 'Test Resource'
|
||||
mock_resource.type = 'STAFF'
|
||||
mock_resource.resource_type = None
|
||||
mock_resource.description = 'Description'
|
||||
mock_resource.is_active = True
|
||||
mock_resource.max_concurrent_events = 1
|
||||
mock_resource.location_id = None
|
||||
mock_resource.location = None
|
||||
mock_resource.is_mobile = False
|
||||
mock_resource.user_id = None
|
||||
mock_resource.user = None
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[mock_resource])
|
||||
|
||||
mock_resource_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
result = api.get_resources()
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetResourceAvailability:
|
||||
"""Tests for SafeScriptAPI.get_resource_availability method."""
|
||||
|
||||
def test_has_get_resource_availability_method(self):
|
||||
"""Should have get_resource_availability method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_resource_availability')
|
||||
assert callable(api.get_resource_availability)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetServices:
|
||||
"""Tests for SafeScriptAPI.get_services method."""
|
||||
|
||||
def test_has_get_services_method(self):
|
||||
"""Should have get_services method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_services')
|
||||
assert callable(api.get_services)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetServiceStats:
|
||||
"""Tests for SafeScriptAPI.get_service_stats method."""
|
||||
|
||||
def test_has_get_service_stats_method(self):
|
||||
"""Should have get_service_stats method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_service_stats')
|
||||
assert callable(api.get_service_stats)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetPayments:
|
||||
"""Tests for SafeScriptAPI.get_payments method."""
|
||||
|
||||
def test_has_get_payments_method(self):
|
||||
"""Should have get_payments method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_payments')
|
||||
assert callable(api.get_payments)
|
||||
|
||||
def test_requires_payment_processing_feature(self):
|
||||
"""Should require payment_processing feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_payments()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetInvoices:
|
||||
"""Tests for SafeScriptAPI.get_invoices method."""
|
||||
|
||||
def test_has_get_invoices_method(self):
|
||||
"""Should have get_invoices method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_invoices')
|
||||
assert callable(api.get_invoices)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetRevenueStats:
|
||||
"""Tests for SafeScriptAPI.get_revenue_stats method."""
|
||||
|
||||
def test_has_get_revenue_stats_method(self):
|
||||
"""Should have get_revenue_stats method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_revenue_stats')
|
||||
assert callable(api.get_revenue_stats)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetContracts:
|
||||
"""Tests for SafeScriptAPI.get_contracts method."""
|
||||
|
||||
def test_has_get_contracts_method(self):
|
||||
"""Should have get_contracts method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_contracts')
|
||||
assert callable(api.get_contracts)
|
||||
|
||||
def test_requires_contracts_feature(self):
|
||||
"""Should require can_use_contracts feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_contracts()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetExpiringContracts:
|
||||
"""Tests for SafeScriptAPI.get_expiring_contracts method."""
|
||||
|
||||
def test_has_get_expiring_contracts_method(self):
|
||||
"""Should have get_expiring_contracts method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_expiring_contracts')
|
||||
assert callable(api.get_expiring_contracts)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetLocations:
|
||||
"""Tests for SafeScriptAPI.get_locations method."""
|
||||
|
||||
def test_has_get_locations_method(self):
|
||||
"""Should have get_locations method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_locations')
|
||||
assert callable(api.get_locations)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetLocationStats:
|
||||
"""Tests for SafeScriptAPI.get_location_stats method."""
|
||||
|
||||
def test_has_get_location_stats_method(self):
|
||||
"""Should have get_location_stats method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_location_stats')
|
||||
assert callable(api.get_location_stats)
|
||||
|
||||
def test_requires_multi_location_feature(self):
|
||||
"""Should require multi_location feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_location_stats(1)
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetStaff:
|
||||
"""Tests for SafeScriptAPI.get_staff method."""
|
||||
|
||||
def test_has_get_staff_method(self):
|
||||
"""Should have get_staff method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_staff')
|
||||
assert callable(api.get_staff)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetStaffPerformance:
|
||||
"""Tests for SafeScriptAPI.get_staff_performance method."""
|
||||
|
||||
def test_has_get_staff_performance_method(self):
|
||||
"""Should have get_staff_performance method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_staff_performance')
|
||||
assert callable(api.get_staff_performance)
|
||||
|
||||
|
||||
class TestSafeScriptAPICreateVideoMeeting:
|
||||
"""Tests for SafeScriptAPI.create_video_meeting method."""
|
||||
|
||||
def test_has_create_video_meeting_method(self):
|
||||
"""Should have create_video_meeting method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'create_video_meeting')
|
||||
assert callable(api.create_video_meeting)
|
||||
|
||||
def test_requires_video_conferencing_feature(self):
|
||||
"""Should require can_add_video_conferencing feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.create_video_meeting()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
def test_validates_provider(self):
|
||||
"""Should validate video provider."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=True)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.create_video_meeting(provider='invalid_provider')
|
||||
|
||||
assert 'Invalid video provider' in str(exc_info.value)
|
||||
|
||||
def test_returns_meeting_data(self):
|
||||
"""Should return meeting data."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=True)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
result = api.create_video_meeting(provider='zoom', title='Test Meeting')
|
||||
|
||||
assert 'meeting_id' in result
|
||||
assert 'join_url' in result
|
||||
assert 'host_url' in result
|
||||
assert result['provider'] == 'zoom'
|
||||
assert result['title'] == 'Test Meeting'
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetEmailTemplates:
|
||||
"""Tests for SafeScriptAPI.get_email_templates method."""
|
||||
|
||||
def test_has_get_email_templates_method(self):
|
||||
"""Should have get_email_templates method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_email_templates')
|
||||
assert callable(api.get_email_templates)
|
||||
|
||||
def test_requires_email_templates_feature(self):
|
||||
"""Should require can_use_email_templates feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_email_templates()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPISendTemplateEmail:
|
||||
"""Tests for SafeScriptAPI.send_template_email method."""
|
||||
|
||||
def test_has_send_template_email_method(self):
|
||||
"""Should have send_template_email method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'send_template_email')
|
||||
assert callable(api.send_template_email)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetAnalytics:
|
||||
"""Tests for SafeScriptAPI.get_analytics method."""
|
||||
|
||||
def test_has_get_analytics_method(self):
|
||||
"""Should have get_analytics method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_analytics')
|
||||
assert callable(api.get_analytics)
|
||||
|
||||
def test_requires_advanced_reporting_feature(self):
|
||||
"""Should require advanced_reporting feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_analytics()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetBookingTrends:
|
||||
"""Tests for SafeScriptAPI.get_booking_trends method."""
|
||||
|
||||
def test_has_get_booking_trends_method(self):
|
||||
"""Should have get_booking_trends method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_booking_trends')
|
||||
assert callable(api.get_booking_trends)
|
||||
|
||||
|
||||
class TestSafeScriptAPIUpdateAppointment:
|
||||
"""Tests for SafeScriptAPI.update_appointment method."""
|
||||
|
||||
def test_has_update_appointment_method(self):
|
||||
"""Should have update_appointment method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'update_appointment')
|
||||
assert callable(api.update_appointment)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetRecurringAppointments:
|
||||
"""Tests for SafeScriptAPI.get_recurring_appointments method."""
|
||||
|
||||
def test_has_get_recurring_appointments_method(self):
|
||||
"""Should have get_recurring_appointments method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_recurring_appointments')
|
||||
assert callable(api.get_recurring_appointments)
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetAppointmentsFilters:
|
||||
"""Tests for comprehensive filtering in get_appointments method."""
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_start_time_gt(self, mock_event_class):
|
||||
"""Should filter by start_time greater than."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(start_time__gt='2024-01-01T10:00:00')
|
||||
|
||||
# Should have called filter with start_time__gt
|
||||
filter_calls = [str(c) for c in mock_queryset.filter.call_args_list]
|
||||
assert any('start_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_start_time_lt(self, mock_event_class):
|
||||
"""Should filter by start_time less than."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(start_time__lt='2024-01-01T10:00:00')
|
||||
|
||||
assert any('start_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_start_time_gte(self, mock_event_class):
|
||||
"""Should filter by start_time greater than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(start_time__gte='2024-01-01T10:00:00')
|
||||
|
||||
assert any('start_time__gte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_start_time_lte(self, mock_event_class):
|
||||
"""Should filter by start_time less than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(start_time__lte='2024-01-01T10:00:00')
|
||||
|
||||
assert any('start_time__lte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_end_time_gt(self, mock_event_class):
|
||||
"""Should filter by end_time greater than."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(end_time__gt='2024-01-01T10:00:00')
|
||||
|
||||
assert any('end_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_end_time_lt(self, mock_event_class):
|
||||
"""Should filter by end_time less than."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(end_time__lt='2024-01-01T10:00:00')
|
||||
|
||||
assert any('end_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_created_at_gte(self, mock_event_class):
|
||||
"""Should filter by created_at greater than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(created_at__gte='2024-01-01T10:00:00')
|
||||
|
||||
assert any('created_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_updated_at_gte(self, mock_event_class):
|
||||
"""Should filter by updated_at greater than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(updated_at__gte='2024-01-01T10:00:00')
|
||||
|
||||
assert any('updated_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_service_id(self, mock_event_class):
|
||||
"""Should filter by service_id."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(service_id=123)
|
||||
|
||||
assert any('service_id' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_location_id(self, mock_event_class):
|
||||
"""Should filter by location_id."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(location_id=456)
|
||||
|
||||
assert any('location_id' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_title_contains(self, mock_event_class):
|
||||
"""Should filter by title contains (case-insensitive)."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(title__icontains='meeting')
|
||||
|
||||
assert any('title__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_notes_contains(self, mock_event_class):
|
||||
"""Should filter by notes contains (case-insensitive)."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(notes__icontains='important')
|
||||
|
||||
assert any('notes__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_customer_id(self, mock_event_class):
|
||||
"""Should filter by customer_id (via participants)."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.distinct.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(customer_id=789)
|
||||
|
||||
assert any('participants__user_id' in str(c) or 'customer' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_resource_id(self, mock_event_class):
|
||||
"""Should filter by resource_id (via participants)."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.distinct.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(resource_id=321)
|
||||
|
||||
assert any('participants__resource_id' in str(c) or 'resource' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_deposit_amount_gte(self, mock_event_class):
|
||||
"""Should filter by deposit_amount greater than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(deposit_amount__gte=50.00)
|
||||
|
||||
assert any('deposit_amount__gte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_final_price_lte(self, mock_event_class):
|
||||
"""Should filter by final_price less than or equal."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(final_price__lte=100.00)
|
||||
|
||||
assert any('final_price__lte' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_has_deposit(self, mock_event_class):
|
||||
"""Should filter appointments that have a deposit."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.exclude.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(has_deposit=True)
|
||||
|
||||
# Should use exclude(deposit_amount__isnull=True) or filter(deposit_amount__isnull=False)
|
||||
call_args_str = str(mock_queryset.filter.call_args_list) + str(mock_queryset.exclude.call_args_list)
|
||||
assert 'deposit_amount' in call_args_str
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_by_multiple_statuses(self, mock_event_class):
|
||||
"""Should filter by multiple statuses using __in."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(status__in=['SCHEDULED', 'COMPLETED'])
|
||||
|
||||
assert any('status__in' in str(c) for c in mock_queryset.filter.call_args_list)
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_filter_combination(self, mock_event_class):
|
||||
"""Should support combining multiple filters."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
api.get_appointments(
|
||||
status='SCHEDULED',
|
||||
start_time__gte='2024-01-01T00:00:00',
|
||||
start_time__lt='2024-02-01T00:00:00',
|
||||
service_id=123
|
||||
)
|
||||
|
||||
# Multiple filter calls should have been made
|
||||
assert mock_queryset.filter.call_count >= 1
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.models.Event')
|
||||
def test_returns_comprehensive_data(self, mock_event_class):
|
||||
"""Should return comprehensive appointment data including related objects."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
from datetime import datetime
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.id = 1
|
||||
mock_service.name = 'Haircut'
|
||||
|
||||
mock_location = Mock()
|
||||
mock_location.id = 2
|
||||
mock_location.name = 'Main Office'
|
||||
|
||||
mock_event = Mock()
|
||||
mock_event.id = 100
|
||||
mock_event.title = 'Test Appointment'
|
||||
mock_event.start_time = datetime(2024, 1, 15, 10, 0, 0)
|
||||
mock_event.end_time = datetime(2024, 1, 15, 11, 0, 0)
|
||||
mock_event.status = 'SCHEDULED'
|
||||
mock_event.notes = 'Some notes'
|
||||
mock_event.created_at = datetime(2024, 1, 1, 9, 0, 0)
|
||||
mock_event.updated_at = datetime(2024, 1, 1, 9, 0, 0)
|
||||
mock_event.service = mock_service
|
||||
mock_event.service_id = 1
|
||||
mock_event.location = mock_location
|
||||
mock_event.location_id = 2
|
||||
mock_event.deposit_amount = 25.00
|
||||
mock_event.final_price = None
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.filter.return_value = mock_queryset
|
||||
mock_queryset.select_related.return_value = mock_queryset
|
||||
mock_queryset.__getitem__ = Mock(return_value=[mock_event])
|
||||
mock_event_class.objects.all.return_value = mock_queryset
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
result = api.get_appointments()
|
||||
|
||||
assert len(result) == 1
|
||||
appt = result[0]
|
||||
assert appt['id'] == 100
|
||||
assert appt['title'] == 'Test Appointment'
|
||||
assert 'service_id' in appt
|
||||
assert 'service_name' in appt
|
||||
assert 'location_id' in appt
|
||||
assert 'location_name' in appt
|
||||
assert 'created_at' in appt
|
||||
assert 'updated_at' in appt
|
||||
assert 'deposit_amount' in appt
|
||||
assert 'final_price' in appt
|
||||
|
||||
def test_requires_recurring_appointments_feature(self):
|
||||
"""Should require recurring_appointments feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_recurring_appointments()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
|
||||
Reference in New Issue
Block a user