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
416
tests/hooks/useUserPreferences.subscription.test.tsx
Normal file
416
tests/hooks/useUserPreferences.subscription.test.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue