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.
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
const mockAuthFetch = vi.fn();
|
|
const mockAuth = {
|
|
isAuthenticated: true,
|
|
};
|
|
|
|
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
|
useAuthenticatedFetch: () => ({
|
|
authFetch: mockAuthFetch,
|
|
isAuthenticated: mockAuth.isAuthenticated,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('useUserPreferences - subscription data', () => {
|
|
let useUserPreferences: typeof import('../../hooks/useUserPreferences').useUserPreferences;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
|
|
mockAuth.isAuthenticated = true;
|
|
|
|
const module = await import('../../hooks/useUserPreferences');
|
|
useUserPreferences = module.useUserPreferences;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('subscription info fetching', () => {
|
|
it('fetches subscription status along with preferences', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'subscription',
|
|
generationCount: 10,
|
|
generationLimit: 250,
|
|
generationsRemaining: 240,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription).toEqual({
|
|
hasAccess: true,
|
|
accessType: 'subscription',
|
|
generationCount: 10,
|
|
generationLimit: 250,
|
|
generationsRemaining: 240,
|
|
});
|
|
});
|
|
|
|
it('sets subscription info for group access users', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'group',
|
|
generationCount: null,
|
|
generationLimit: null,
|
|
generationsRemaining: null,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription).toEqual({
|
|
hasAccess: true,
|
|
accessType: 'group',
|
|
generationCount: null,
|
|
generationLimit: null,
|
|
generationsRemaining: null,
|
|
});
|
|
});
|
|
|
|
it('sets subscription info for users without access', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: false,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: false,
|
|
accessType: 'none',
|
|
generationCount: 0,
|
|
generationLimit: 250,
|
|
generationsRemaining: 0,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription).toEqual({
|
|
hasAccess: false,
|
|
accessType: 'none',
|
|
generationCount: 0,
|
|
generationLimit: 250,
|
|
generationsRemaining: 0,
|
|
});
|
|
});
|
|
|
|
it('handles subscription status fetch failure gracefully', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'purple',
|
|
aiProvider: 'openrouter',
|
|
hasAIAccess: false,
|
|
}),
|
|
})
|
|
.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription).toBeNull();
|
|
expect(result.current.preferences.colorScheme).toBe('purple');
|
|
});
|
|
|
|
it('handles subscription status non-ok response gracefully', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 503,
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('hasAIAccess from preferences', () => {
|
|
it('sets hasAIAccess true when user has AI access', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'group',
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.hasAIAccess).toBe(true);
|
|
});
|
|
|
|
it('sets hasAIAccess false when user does not have AI access', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: false,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: false,
|
|
accessType: 'none',
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.hasAIAccess).toBe(false);
|
|
});
|
|
|
|
it('defaults hasAIAccess to false when not present', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: false,
|
|
accessType: 'none',
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.hasAIAccess).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('when not authenticated', () => {
|
|
beforeEach(() => {
|
|
mockAuth.isAuthenticated = false;
|
|
});
|
|
|
|
it('does not fetch subscription status', async () => {
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(mockAuthFetch).not.toHaveBeenCalled();
|
|
expect(result.current.subscription).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('refetching subscription', () => {
|
|
it('can refetch subscription info via fetchPreferences', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: false,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: false,
|
|
accessType: 'none',
|
|
generationCount: 0,
|
|
generationLimit: 250,
|
|
generationsRemaining: 0,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription?.hasAccess).toBe(false);
|
|
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'subscription',
|
|
generationCount: 0,
|
|
generationLimit: 250,
|
|
generationsRemaining: 250,
|
|
}),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.fetchPreferences();
|
|
});
|
|
|
|
expect(result.current.subscription?.hasAccess).toBe(true);
|
|
expect(result.current.subscription?.accessType).toBe('subscription');
|
|
});
|
|
});
|
|
|
|
describe('generation tracking', () => {
|
|
it('tracks generation count correctly', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'subscription',
|
|
generationCount: 50,
|
|
generationLimit: 250,
|
|
generationsRemaining: 200,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription?.generationCount).toBe(50);
|
|
expect(result.current.subscription?.generationsRemaining).toBe(200);
|
|
});
|
|
|
|
it('shows zero remaining when limit reached', async () => {
|
|
mockAuthFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
hasAIAccess: true,
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
hasAccess: true,
|
|
accessType: 'subscription',
|
|
generationCount: 250,
|
|
generationLimit: 250,
|
|
generationsRemaining: 0,
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useUserPreferences());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.subscription?.generationsRemaining).toBe(0);
|
|
});
|
|
});
|
|
});
|