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.
This commit is contained in:
parent
3c54a0f4d9
commit
2e12edc249
22 changed files with 2866 additions and 21 deletions
436
tests/components/UpgradePage.test.tsx
Normal file
436
tests/components/UpgradePage.test.tsx
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue