kaboot/tests/components/UpgradePage.test.tsx
Joey Yakimowich-Payne 2e12edc249
Add Stripe payment integration for AI subscriptions
Implement subscription-based AI access with 250 generations/month at $5/month or $50/year.

Changes:
- Backend: Stripe service, payment routes, webhook handlers, generation tracking
- Frontend: Upgrade page with pricing, payment success/cancel pages, UI prompts
- Database: Add subscription fields to users, payments table, migrations
- Config: Stripe env vars to .env.example, docker-compose.prod.yml, PRODUCTION.md
- Tests: Payment route tests, component tests, subscription hook tests

Users without AI access see upgrade prompts; subscribers see remaining generation count.
2026-01-21 16:11:03 -07:00

436 lines
12 KiB
TypeScript

import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockSigninRedirect = vi.fn();
const mockAuth = {
user: {
access_token: 'valid-token',
},
isAuthenticated: true,
signinRedirect: mockSigninRedirect,
};
vi.mock('react-oidc-context', () => ({
useAuth: () => mockAuth,
}));
vi.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
header: ({ children, ...props }: any) => <header {...props}>{children}</header>,
button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
},
AnimatePresence: ({ children }: any) => children,
}));
const originalFetch = global.fetch;
describe('UpgradePage', () => {
let UpgradePage: typeof import('../../components/UpgradePage').UpgradePage;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
mockAuth.user = { access_token: 'valid-token' };
mockAuth.isAuthenticated = true;
global.fetch = vi.fn();
const module = await import('../../components/UpgradePage');
UpgradePage = module.UpgradePage;
});
afterEach(() => {
global.fetch = originalFetch;
vi.resetAllMocks();
});
describe('loading state', () => {
it('shows loader while checking status', async () => {
let resolveStatus: (value: any) => void;
const statusPromise = new Promise((resolve) => {
resolveStatus = resolve;
});
(global.fetch as ReturnType<typeof vi.fn>).mockReturnValue(statusPromise);
render(<UpgradePage />);
// The loader has animate-spin class
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
await act(async () => {
resolveStatus!({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
});
});
describe('user without access', () => {
beforeEach(() => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
it('renders upgrade page with pricing', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Unlock/i)).toBeInTheDocument();
});
expect(screen.getByText('Monthly')).toBeInTheDocument();
expect(screen.getByText('Yearly')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Pro Gamer')).toBeInTheDocument();
});
it('shows $0 for Starter plan', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('$0')).toBeInTheDocument();
});
});
it('defaults to yearly billing cycle', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('$4.17')).toBeInTheDocument();
});
expect(screen.getByText(/Billed \$50 yearly/i)).toBeInTheDocument();
});
it('switches to monthly pricing when monthly is clicked', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('$4.17')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Monthly'));
await waitFor(() => {
expect(screen.getByText('$5')).toBeInTheDocument();
});
expect(screen.queryByText(/Billed \$50 yearly/i)).not.toBeInTheDocument();
});
it('shows feature comparison', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('5 AI generations per month')).toBeInTheDocument();
});
expect(screen.getByText('250 AI generations per month')).toBeInTheDocument();
expect(screen.getByText('Basic quiz topics')).toBeInTheDocument();
expect(screen.getByText('Host up to 10 players')).toBeInTheDocument();
expect(screen.getByText('Host up to 100 players')).toBeInTheDocument();
});
it('shows back button when onBack is provided', async () => {
const mockOnBack = vi.fn();
render(<UpgradePage onBack={mockOnBack} />);
await waitFor(() => {
expect(screen.getByText(/Unlock/i)).toBeInTheDocument();
});
const backButton = document.querySelector('button[class*="absolute"]');
expect(backButton).toBeInTheDocument();
});
it('calls onBack when back button is clicked', async () => {
const mockOnBack = vi.fn();
render(<UpgradePage onBack={mockOnBack} />);
await waitFor(() => {
expect(screen.getByText(/Unlock/i)).toBeInTheDocument();
});
const backButton = document.querySelector('button[class*="absolute"]');
if (backButton) {
fireEvent.click(backButton);
expect(mockOnBack).toHaveBeenCalled();
}
});
});
describe('user with access', () => {
beforeEach(() => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: true }),
});
});
it('shows "You\'re a Pro!" message', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText("You're a Pro!")).toBeInTheDocument();
});
});
it('shows "Back to Game" button', async () => {
const mockOnBack = vi.fn();
render(<UpgradePage onBack={mockOnBack} />);
await waitFor(() => {
expect(screen.getByText('Back to Game')).toBeInTheDocument();
});
});
it('calls onBack when "Back to Game" is clicked', async () => {
const mockOnBack = vi.fn();
render(<UpgradePage onBack={mockOnBack} />);
await waitFor(() => {
expect(screen.getByText('Back to Game')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Back to Game'));
expect(mockOnBack).toHaveBeenCalled();
});
});
describe('checkout flow', () => {
beforeEach(() => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
it('calls checkout endpoint when "Upgrade Now" is clicked', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://checkout.stripe.com/test' }),
});
const originalHref = window.location.href;
Object.defineProperty(window, 'location', {
value: { href: '', origin: 'http://localhost' },
writable: true,
});
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Upgrade Now/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upgrade Now/i));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/payments/checkout'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: 'Bearer valid-token',
}),
})
);
});
Object.defineProperty(window, 'location', {
value: { href: originalHref },
writable: true,
});
});
it('redirects to sign in if no token', async () => {
mockAuth.user = null;
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Upgrade Now/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upgrade Now/i));
await waitFor(() => {
expect(mockSigninRedirect).toHaveBeenCalled();
});
});
it('shows error message on checkout failure', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
});
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Upgrade Now/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upgrade Now/i));
await waitFor(() => {
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
});
it('passes correct planType for yearly billing', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://checkout.stripe.com/test' }),
});
const originalHref = window.location.href;
Object.defineProperty(window, 'location', {
value: { href: '', origin: 'http://localhost' },
writable: true,
});
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Upgrade Now/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upgrade Now/i));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"planType":"yearly"'),
})
);
});
Object.defineProperty(window, 'location', {
value: { href: originalHref },
writable: true,
});
});
it('passes correct planType for monthly billing', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://checkout.stripe.com/test' }),
});
const originalHref = window.location.href;
Object.defineProperty(window, 'location', {
value: { href: '', origin: 'http://localhost' },
writable: true,
});
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Upgrade Now/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Monthly'));
await waitFor(() => {
expect(screen.getByText('$5')).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Upgrade Now/i));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"planType":"monthly"'),
})
);
});
Object.defineProperty(window, 'location', {
value: { href: originalHref },
writable: true,
});
});
});
describe('status check error handling', () => {
it('handles status check failure gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Unlock/i)).toBeInTheDocument();
});
expect(consoleSpy).toHaveBeenCalledWith('Failed to check status:', expect.any(Error));
consoleSpy.mockRestore();
});
it('handles missing token during status check', async () => {
mockAuth.user = null;
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Unlock/i)).toBeInTheDocument();
});
});
});
describe('UI elements', () => {
beforeEach(() => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
it('shows discount badge for yearly billing', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('-17%')).toBeInTheDocument();
});
});
it('shows "Most Popular" badge on Pro plan', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('Most Popular')).toBeInTheDocument();
});
});
it('shows "Current Plan" button disabled for Starter', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText('Current Plan')).toBeInTheDocument();
});
const currentPlanButton = screen.getByText('Current Plan');
expect(currentPlanButton).toBeDisabled();
});
it('shows security and guarantee info', async () => {
render(<UpgradePage />);
await waitFor(() => {
expect(screen.getByText(/Secure payment via Stripe/i)).toBeInTheDocument();
});
expect(screen.getByText(/Cancel anytime/i)).toBeInTheDocument();
expect(screen.getByText(/7-day money-back guarantee/i)).toBeInTheDocument();
});
});
});