kaboot/tests/components/PaymentResult.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

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);
});
});
});