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.
436 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|