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.
208 lines
6.1 KiB
TypeScript
208 lines
6.1 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
vi.mock('framer-motion', () => ({
|
|
motion: {
|
|
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
},
|
|
}));
|
|
|
|
vi.mock('canvas-confetti', () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
|
|
describe('PaymentResult', () => {
|
|
let PaymentResult: typeof import('../../components/PaymentResult').PaymentResult;
|
|
let mockConfetti: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
|
|
const confettiModule = await import('canvas-confetti');
|
|
mockConfetti = confettiModule.default as ReturnType<typeof vi.fn>;
|
|
|
|
const module = await import('../../components/PaymentResult');
|
|
PaymentResult = module.PaymentResult;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('loading state', () => {
|
|
it('shows loading spinner', () => {
|
|
render(<PaymentResult status="loading" />);
|
|
|
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
|
expect(screen.getByText(/Processing your payment/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not trigger confetti in loading state', () => {
|
|
render(<PaymentResult status="loading" />);
|
|
|
|
expect(mockConfetti).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('success state', () => {
|
|
it('shows success message', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
expect(screen.getByText('Welcome to Pro!')).toBeInTheDocument();
|
|
expect(screen.getByText(/AI powers are now unlocked/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows generation count badge', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
expect(screen.getByText('250 AI generations ready to use')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows "Start Creating" button', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
expect(screen.getByText('Start Creating')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onBack when "Start Creating" is clicked', () => {
|
|
const mockOnBack = vi.fn();
|
|
render(<PaymentResult status="success" onBack={mockOnBack} />);
|
|
|
|
fireEvent.click(screen.getByText('Start Creating'));
|
|
|
|
expect(mockOnBack).toHaveBeenCalled();
|
|
});
|
|
|
|
it('triggers confetti animation on mount', async () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(mockConfetti).toHaveBeenCalled();
|
|
});
|
|
|
|
it('confetti uses correct colors', async () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(mockConfetti).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
colors: ['#8B5CF6', '#6366F1', '#EC4899', '#F59E0B'],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('only triggers confetti once', async () => {
|
|
const { rerender } = render(<PaymentResult status="success" />);
|
|
|
|
vi.advanceTimersByTime(3100);
|
|
|
|
const callCountAfterFirst = mockConfetti.mock.calls.length;
|
|
|
|
rerender(<PaymentResult status="success" />);
|
|
vi.advanceTimersByTime(3100);
|
|
|
|
expect(mockConfetti.mock.calls.length).toBe(callCountAfterFirst);
|
|
});
|
|
});
|
|
|
|
describe('cancel state', () => {
|
|
it('shows cancel message', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
expect(screen.getByText('Payment Cancelled')).toBeInTheDocument();
|
|
expect(screen.getByText(/No worries/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows "Go Back" button', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
expect(screen.getByText('Go Back')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onBack when "Go Back" is clicked', () => {
|
|
const mockOnBack = vi.fn();
|
|
render(<PaymentResult status="cancel" onBack={mockOnBack} />);
|
|
|
|
fireEvent.click(screen.getByText('Go Back'));
|
|
|
|
expect(mockOnBack).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show generation count badge', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
expect(screen.queryByText(/250 AI generations/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not trigger confetti', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
vi.advanceTimersByTime(3100);
|
|
|
|
expect(mockConfetti).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('UI elements', () => {
|
|
it('renders PartyPopper icon for success', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
const successCard = screen.getByText('Welcome to Pro!').closest('div');
|
|
expect(successCard).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders XCircle icon for cancel', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
const cancelCard = screen.getByText('Payment Cancelled').closest('div');
|
|
expect(cancelCard).toBeInTheDocument();
|
|
});
|
|
|
|
it('has green gradient background for success', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
const gradientBg = document.querySelector('.from-green-50');
|
|
expect(gradientBg).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not have green gradient for cancel', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
const gradientBg = document.querySelector('.from-green-50');
|
|
expect(gradientBg).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('button styling', () => {
|
|
it('success button has dark styling', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
const button = screen.getByText('Start Creating').closest('button');
|
|
expect(button?.className).toContain('bg-gray-900');
|
|
});
|
|
|
|
it('cancel button has theme primary styling', () => {
|
|
render(<PaymentResult status="cancel" />);
|
|
|
|
const button = screen.getByText('Go Back').closest('button');
|
|
expect(button?.className).toContain('bg-theme-primary');
|
|
});
|
|
});
|
|
|
|
describe('without onBack callback', () => {
|
|
it('renders buttons without errors when onBack is undefined', () => {
|
|
render(<PaymentResult status="success" />);
|
|
|
|
const button = screen.getByText('Start Creating');
|
|
expect(button).toBeInTheDocument();
|
|
|
|
fireEvent.click(button);
|
|
});
|
|
});
|
|
});
|