484 lines
14 KiB
TypeScript
484 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
|
|
const navigateMock = vi.fn();
|
|
|
|
const tMock = (
|
|
_key: string,
|
|
fallback?: string | Record<string, unknown>,
|
|
options?: Record<string, unknown>,
|
|
) => {
|
|
let value = typeof fallback === 'string' ? fallback : _key;
|
|
if (options) {
|
|
Object.entries(options).forEach(([key, val]) => {
|
|
value = value.replaceAll(`{{${key}}}`, String(val));
|
|
});
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }));
|
|
const triggerDownloadMock = vi.fn();
|
|
const eventContext = {
|
|
activeEvent: null as any,
|
|
};
|
|
|
|
vi.mock('react-router-dom', () => ({
|
|
useNavigate: () => navigateMock,
|
|
useLocation: () => ({ pathname: '/mobile/billing', search: '', hash: '' }),
|
|
}));
|
|
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({ t: tMock }),
|
|
initReactI18next: {
|
|
type: '3rdParty',
|
|
init: () => undefined,
|
|
},
|
|
}));
|
|
|
|
vi.mock('lucide-react', () => ({
|
|
ChevronDown: () => <span />,
|
|
ChevronUp: () => <span />,
|
|
Package: () => <span />,
|
|
Receipt: () => <span />,
|
|
RefreshCcw: () => <span />,
|
|
Sparkles: () => <span />,
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => {
|
|
const toast = Object.assign(vi.fn(), {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
});
|
|
return { default: toast };
|
|
});
|
|
|
|
vi.mock('../hooks/useBackNavigation', () => ({
|
|
useBackNavigation: () => vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../theme', () => ({
|
|
useAdminTheme: () => ({
|
|
textStrong: '#111827',
|
|
text: '#111827',
|
|
muted: '#6b7280',
|
|
subtle: '#9ca3af',
|
|
danger: '#b91c1c',
|
|
border: '#e5e7eb',
|
|
primary: '#2563eb',
|
|
accentSoft: '#eef2ff',
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../components/MobileShell', () => ({
|
|
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock('../components/Primitives', () => ({
|
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
|
<button type="button" onClick={onPress}>
|
|
{label}
|
|
</button>
|
|
),
|
|
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
|
}));
|
|
|
|
vi.mock('../components/ContextHelpLink', () => ({
|
|
ContextHelpLink: () => <div />,
|
|
}));
|
|
|
|
vi.mock('@tamagui/stacks', () => ({
|
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock('@tamagui/text', () => ({
|
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
|
}));
|
|
|
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
|
Pressable: ({
|
|
children,
|
|
onPress,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onPress?: () => void;
|
|
}) => (
|
|
<button type="button" onClick={onPress}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../lib/apiError', () => ({
|
|
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
|
}));
|
|
|
|
vi.mock('../../constants', () => ({
|
|
ADMIN_EVENT_VIEW_PATH: (slug: string) => `/mobile/events/${slug}`,
|
|
adminPath: (path: string) => path,
|
|
}));
|
|
|
|
vi.mock('../../context/EventContext', () => ({
|
|
useEventContext: () => eventContext,
|
|
}));
|
|
|
|
vi.mock('../billingUsage', () => ({
|
|
buildPackageUsageMetrics: () => [],
|
|
formatPackageEventAllowance: () => '—',
|
|
getUsageState: () => 'ok',
|
|
usagePercent: () => 0,
|
|
}));
|
|
|
|
vi.mock('../lib/packageSummary', () => ({
|
|
collectPackageFeatures: () => [],
|
|
formatEventUsage: () => '',
|
|
getPackageFeatureLabel: () => '',
|
|
getPackageLimitEntries: () => [],
|
|
resolveTenantWatermarkFeatureKey: () => '',
|
|
}));
|
|
|
|
vi.mock('../lib/billingCheckout', () => ({
|
|
loadPendingCheckout: () => null,
|
|
shouldClearPendingCheckout: () => false,
|
|
storePendingCheckout: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../invite-layout/export-utils', () => ({
|
|
triggerDownloadFromBlob: (...args: unknown[]) => triggerDownloadMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../api', () => ({
|
|
getEvent: vi.fn(),
|
|
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
|
|
getTenantBillingTransactions: vi.fn().mockResolvedValue({
|
|
data: [
|
|
{
|
|
id: 1,
|
|
status: 'completed',
|
|
amount: 49,
|
|
currency: 'EUR',
|
|
provider: 'paypal',
|
|
provider_id: 'ORDER-1',
|
|
package_name: 'Starter',
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
receipt_url: '/api/v1/billing/transactions/1/receipt',
|
|
},
|
|
],
|
|
}),
|
|
getTenantAddonHistory: vi.fn().mockResolvedValue({ data: [] }),
|
|
getTenantPackageCheckoutStatus: vi.fn(),
|
|
downloadTenantBillingReceipt: (...args: unknown[]) => downloadReceiptMock(...args),
|
|
}));
|
|
|
|
import MobileBillingPage from '../BillingPage';
|
|
import * as api from '../../api';
|
|
|
|
describe('MobileBillingPage', () => {
|
|
beforeEach(() => {
|
|
eventContext.activeEvent = null;
|
|
vi.clearAllMocks();
|
|
vi.mocked(api.getTenantPackagesOverview).mockResolvedValue({ packages: [], activePackage: null });
|
|
vi.mocked(api.getTenantBillingTransactions).mockResolvedValue({
|
|
data: [
|
|
{
|
|
id: 1,
|
|
status: 'completed',
|
|
amount: 49,
|
|
currency: 'EUR',
|
|
provider: 'paypal',
|
|
provider_id: 'ORDER-1',
|
|
package_name: 'Starter',
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
receipt_url: '/api/v1/billing/transactions/1/receipt',
|
|
},
|
|
],
|
|
} as any);
|
|
vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any);
|
|
vi.mocked(api.getEvent).mockResolvedValue(null as any);
|
|
});
|
|
|
|
it('shows current event scoped entitlements separately from tenant history', async () => {
|
|
eventContext.activeEvent = {
|
|
id: 99,
|
|
slug: 'fruehlingsfest',
|
|
name: { de: 'Frühlingsfest' },
|
|
};
|
|
|
|
vi.mocked(api.getEvent).mockResolvedValueOnce({
|
|
id: 99,
|
|
slug: 'fruehlingsfest',
|
|
name: { de: 'Frühlingsfest' },
|
|
event_date: null,
|
|
event_type_id: null,
|
|
event_type: null,
|
|
status: 'published',
|
|
settings: {},
|
|
package: {
|
|
id: 201,
|
|
name: 'Event Paket',
|
|
price: 19,
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
expires_at: '2024-02-01T00:00:00Z',
|
|
branding_allowed: true,
|
|
watermark_allowed: true,
|
|
features: [],
|
|
},
|
|
capabilities: {
|
|
ai_styling: false,
|
|
ai_styling_granted_by: null,
|
|
ai_styling_required_feature: null,
|
|
ai_styling_addon_keys: [],
|
|
ai_styling_event_enabled: true,
|
|
ai_styling_allow_custom_prompt: true,
|
|
ai_styling_allowed_style_keys: [],
|
|
ai_styling_policy_message: null,
|
|
},
|
|
addons: [],
|
|
limits: null,
|
|
member_permissions: [],
|
|
} as any);
|
|
|
|
vi.mocked(api.getTenantAddonHistory)
|
|
.mockResolvedValueOnce({ data: [] })
|
|
.mockResolvedValueOnce({
|
|
data: [
|
|
{
|
|
id: 302,
|
|
addon_key: 'extra_photos_100',
|
|
label: '+100 photos',
|
|
event: { id: 99, slug: 'fruehlingsfest', name: { de: 'Frühlingsfest' } },
|
|
amount: 9,
|
|
currency: 'EUR',
|
|
status: 'completed',
|
|
purchased_at: '2024-01-20T00:00:00Z',
|
|
extra_photos: 100,
|
|
extra_guests: 0,
|
|
extra_gallery_days: 0,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
render(<MobileBillingPage />);
|
|
|
|
await screen.findByText('Current event');
|
|
expect(screen.getByText('Frühlingsfest')).toBeInTheDocument();
|
|
expect(screen.getByText('Active for this event')).toBeInTheDocument();
|
|
expect(screen.getAllByText('+100 photos').length).toBeGreaterThan(0);
|
|
expect(api.getTenantAddonHistory).toHaveBeenCalledWith({ eventId: 99, perPage: 6, page: 1 });
|
|
});
|
|
|
|
it('marks add-ons purchased for another event in global history', async () => {
|
|
eventContext.activeEvent = {
|
|
id: 99,
|
|
slug: 'fruehlingsfest',
|
|
name: { de: 'Frühlingsfest' },
|
|
};
|
|
|
|
vi.mocked(api.getEvent).mockResolvedValueOnce({
|
|
id: 99,
|
|
slug: 'fruehlingsfest',
|
|
name: { de: 'Frühlingsfest' },
|
|
event_date: null,
|
|
event_type_id: null,
|
|
event_type: null,
|
|
status: 'published',
|
|
settings: {},
|
|
package: null,
|
|
addons: [],
|
|
limits: null,
|
|
member_permissions: [],
|
|
} as any);
|
|
|
|
vi.mocked(api.getTenantAddonHistory)
|
|
.mockResolvedValueOnce({
|
|
data: [
|
|
{
|
|
id: 400,
|
|
addon_key: 'ai_styling_unlock',
|
|
label: 'AI Magic Edits',
|
|
event: { id: 77, slug: 'sommerfest', name: { de: 'Sommerfest' } },
|
|
amount: 12,
|
|
currency: 'EUR',
|
|
status: 'completed',
|
|
purchased_at: '2024-01-03T00:00:00Z',
|
|
extra_photos: 0,
|
|
extra_guests: 0,
|
|
extra_gallery_days: 0,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
} as any)
|
|
.mockResolvedValueOnce({ data: [] } as any);
|
|
|
|
render(<MobileBillingPage />);
|
|
|
|
await screen.findByText('Purchased for another event');
|
|
});
|
|
|
|
it('shows linked event information for a package', async () => {
|
|
eventContext.activeEvent = null;
|
|
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
|
|
activePackage: {
|
|
id: 11,
|
|
package_id: 501,
|
|
package_name: 'Pro Paket',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'pro',
|
|
active: true,
|
|
used_events: 2,
|
|
remaining_events: 1,
|
|
price: 49,
|
|
currency: 'EUR',
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
linked_events_count: 2,
|
|
current_event: {
|
|
id: 77,
|
|
slug: 'sommerfest',
|
|
name: { de: 'Sommerfest' },
|
|
status: 'published',
|
|
event_date: '2024-08-15T00:00:00Z',
|
|
linked_at: '2024-08-01T00:00:00Z',
|
|
},
|
|
},
|
|
packages: [],
|
|
});
|
|
|
|
render(<MobileBillingPage />);
|
|
|
|
await screen.findByText('Aktuell genutzt für');
|
|
expect(screen.getByText('Sommerfest')).toBeInTheDocument();
|
|
expect(screen.getByText('2 verknüpfte Events')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows only recent package history and can expand the rest', async () => {
|
|
eventContext.activeEvent = null;
|
|
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
|
|
activePackage: {
|
|
id: 1,
|
|
package_id: 100,
|
|
package_name: 'Aktives Paket',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: true,
|
|
used_events: 0,
|
|
remaining_events: 1,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
packages: [
|
|
{
|
|
id: 1,
|
|
package_id: 100,
|
|
package_name: 'Aktives Paket',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: true,
|
|
used_events: 0,
|
|
remaining_events: 1,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
{
|
|
id: 2,
|
|
package_id: 101,
|
|
package_name: 'Historie 1',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: false,
|
|
used_events: 1,
|
|
remaining_events: 0,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2023-12-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
{
|
|
id: 3,
|
|
package_id: 102,
|
|
package_name: 'Historie 2',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: false,
|
|
used_events: 1,
|
|
remaining_events: 0,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2023-11-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
{
|
|
id: 4,
|
|
package_id: 103,
|
|
package_name: 'Historie 3',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: false,
|
|
used_events: 1,
|
|
remaining_events: 0,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2023-10-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
{
|
|
id: 5,
|
|
package_id: 104,
|
|
package_name: 'Historie 4',
|
|
package_type: 'reseller',
|
|
included_package_slug: 'standard',
|
|
active: false,
|
|
used_events: 1,
|
|
remaining_events: 0,
|
|
price: 29,
|
|
currency: 'EUR',
|
|
purchased_at: '2023-09-01T00:00:00Z',
|
|
expires_at: null,
|
|
package_limits: {},
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<MobileBillingPage />);
|
|
|
|
await screen.findByText('Aktives Paket');
|
|
expect(screen.getByText('Historie 1')).toBeInTheDocument();
|
|
expect(screen.getByText('Historie 2')).toBeInTheDocument();
|
|
expect(screen.getByText('Historie 3')).toBeInTheDocument();
|
|
expect(screen.queryByText('Historie 4')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('Verlauf anzeigen (1)'));
|
|
|
|
expect(await screen.findByText('Historie 4')).toBeInTheDocument();
|
|
expect(screen.getByText('Verlauf ausblenden')).toBeInTheDocument();
|
|
});
|
|
|
|
it('downloads receipts via the API helper', async () => {
|
|
eventContext.activeEvent = null;
|
|
render(<MobileBillingPage />);
|
|
|
|
const receiptLink = await screen.findByText('Beleg');
|
|
fireEvent.click(receiptLink);
|
|
|
|
await waitFor(() => {
|
|
expect(downloadReceiptMock).toHaveBeenCalledWith('/api/v1/billing/transactions/1/receipt');
|
|
expect(triggerDownloadMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|