Skip to content

Commit be55633

Browse files
committed
Tests
1 parent 529169a commit be55633

File tree

11 files changed

+590
-37
lines changed

11 files changed

+590
-37
lines changed

README.md

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,130 @@
1-
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1+
# ChatGPT Clone
2+
3+
A production-grade ChatGPT-like clone built with Next.js, Supabase, Together.ai, Tailwind CSS, and modern best practices. Real-time chat, file attachments, model selection, user settings, and more.
4+
5+
---
6+
7+
## Features
8+
9+
- **Authentication**: Email/password signup & login, protected routes, session management
10+
- **Chat Interface**: Real-time streaming, markdown/code, reactions, editing, mobile-responsive
11+
- **Conversation Management**: Create, rename, delete, search, filter, optimistic UI
12+
- **File Attachments**: Drag & drop, preview, validation, secure storage
13+
- **Model Selection**: Switch Together.ai models, per-conversation, token/cost tracking
14+
15+
---
16+
17+
## Tech Stack
18+
19+
- **Frontend**: Next.js 14 (App Router, RSC), TypeScript, Tailwind CSS, shadcn/ui
20+
- **Backend**: Next.js API Routes (Edge), Supabase (Postgres, Auth, Storage)
21+
- **State**: Zustand, TanStack Query, React Hook Form, Zod
22+
- **LLM**: Together.ai API
23+
- **Testing**: Vitest
24+
25+
---
226

327
## Getting Started
428

5-
First, run the development server:
29+
### 1. Clone & Install
30+
31+
```bash
32+
git clone https://github.com/your-org/chatgpt-clone.git
33+
cd chatgpt-clone
34+
npm install # or yarn or pnpm
35+
```
36+
37+
### 2. Environment Setup
38+
39+
Copy `.env.example` to `.env.local` and fill in:
40+
41+
```env
42+
NEXT_PUBLIC_SUPABASE_URL=...
43+
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
44+
SUPABASE_SERVICE_ROLE_KEY=...
45+
TOGETHER_API_KEY=...
46+
```
47+
48+
- [Create a Supabase project](https://app.supabase.com/)
49+
- [Get a Together.ai API key](https://platform.together.ai/)
50+
51+
### 3. Database Setup
52+
53+
- Run the SQL in `knowledge_base/context.md` to create tables: `users`, `conversations`, `messages`, `attachments`.
54+
- Set up Supabase Storage bucket for file uploads.
55+
56+
### 4. Run Locally
657

758
```bash
859
npm run dev
9-
# or
10-
yarn dev
11-
# or
12-
pnpm dev
13-
# or
14-
bun dev
1560
```
61+
Visit [http://localhost:3000](http://localhost:3000)
62+
63+
---
64+
65+
## Scripts
66+
67+
- `npm run dev` — Start dev server
68+
- `npm run build` — Build for production
69+
- `npm start` — Start production server
70+
- `npm run lint` — Lint code
71+
- `npm test` — Run all tests (Jest/Vitest)
72+
73+
---
74+
75+
## Testing
76+
77+
- **Unit/Integration**: `npm test` (Jest/Vitest)
78+
- **E2E**: See `integration/e2e-chat-auth-flow.test.tsx`
79+
- Tests cover auth, chat, file upload, and more
80+
81+
---
82+
83+
## Production Environment
84+
85+
- Set `NODE_ENV=production` in your deployment platform (Vercel does this automatically)
86+
- Use Node.js 18+ runtime
87+
- Set all required environment variables (see `.env.example`)
88+
- Enable HTTPS (Vercel/most hosts do this by default)
89+
- Configure Supabase CORS for your domain
90+
- Set up Supabase Storage bucket for file uploads
91+
- Set JWT expiration and security settings in Supabase Auth
92+
- Review rate limits and security headers in `middleware.ts` and API routes
93+
94+
## Deployment
95+
96+
### Deploy to Vercel (Recommended)
97+
98+
1. Push your code to GitHub
99+
2. [Import your repo to Vercel](https://vercel.com/import)
100+
3. Set all environment variables in Vercel dashboard
101+
4. Click Deploy
102+
103+
### Manual/Other Node Hosts
16104

17-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
105+
- Build: `npm run build`
106+
- Start: `npm start`
107+
- Set all env vars from `.env.example`
18108

19-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
109+
---
20110

21-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
111+
## Security & Best Practices
22112

23-
## Learn More
113+
- Input validation (Zod, server-side checks)
114+
- XSS/CSRF/CORS protection (Next.js, Supabase)
115+
- Rate limiting on API routes
116+
- Use HTTPS in production
117+
- Store Together.ai API keys securely (never commit to repo)
24118

25-
To learn more about Next.js, take a look at the following resources:
119+
---
26120

27-
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
121+
## Contributing
29122

30-
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
123+
- PRs welcome! Follow conventional commits and run lint/tests before submitting.
124+
- See `knowledge_base/tasks.md` for roadmap and features.
31125

32-
## Deploy on Vercel
126+
---
33127

34-
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
128+
## License
35129

36-
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
130+
MIT. Not affiliated with OpenAI or ChatGPT. For educational/research use.

app/api/chat/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ const together = new Together();
55

66
type Message = CompletionCreateParams.Message;
77

8+
// Simple in-memory rate limiter (per IP, per minute)
9+
const rateLimitMap = new Map<string, { count: number, last: number }>()
10+
const RATE_LIMIT = 30 // requests
11+
const RATE_WINDOW = 60 * 1000 // 1 minute
12+
813
async function createChatCompletionWithFallback({ model, messages }: { model: string, messages: Message[] }) {
914
try {
1015
return await together.chat.completions.create({
@@ -31,6 +36,20 @@ async function createChatCompletionWithFallback({ model, messages }: { model: st
3136
}
3237

3338
export async function POST(request: Request) {
39+
// Rate limiting
40+
const ip = request.headers.get('x-forwarded-for') || 'unknown'
41+
const now = Date.now()
42+
const entry = rateLimitMap.get(ip) || { count: 0, last: now }
43+
if (now - entry.last > RATE_WINDOW) {
44+
entry.count = 0
45+
entry.last = now
46+
}
47+
entry.count++
48+
rateLimitMap.set(ip, entry)
49+
if (entry.count > RATE_LIMIT) {
50+
return new Response(JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Content-Type': 'application/json' } })
51+
}
52+
3453
try {
3554
const { messages, model = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", attachments } = await request.json();
3655

app/api/generate-image/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { createClient } from '@supabase/supabase-js'
33

4+
// Simple in-memory rate limiter (per IP, per minute)
5+
const rateLimitMap = new Map<string, { count: number, last: number }>()
6+
const RATE_LIMIT = 10 // requests
7+
const RATE_WINDOW = 60 * 1000 // 1 minute
8+
49
export async function POST(req: NextRequest) {
10+
// Rate limiting
11+
const ip = req.headers.get('x-forwarded-for') || 'unknown'
12+
const now = Date.now()
13+
const entry = rateLimitMap.get(ip) || { count: 0, last: now }
14+
if (now - entry.last > RATE_WINDOW) {
15+
entry.count = 0
16+
entry.last = now
17+
}
18+
entry.count++
19+
rateLimitMap.set(ip, entry)
20+
if (entry.count > RATE_LIMIT) {
21+
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
22+
}
23+
524
const { prompt } = await req.json()
625
if (!prompt) return NextResponse.json({ error: 'Missing prompt' }, { status: 400 })
726

app/api/models/route.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,32 @@ let cachedModels: { name: string; description: string; price_per_million: number
22
let cacheTimestamp = 0;
33
const CACHE_TTL = 60 * 60 * 1000; // 60 minutes
44

5-
export async function GET() {
5+
// Simple in-memory rate limiter (per IP, per minute)
6+
const rateLimitMap = new Map<string, { count: number, last: number }>()
7+
const RATE_LIMIT = 30 // requests
8+
const RATE_WINDOW = 60 * 1000 // 1 minute
9+
10+
export async function GET(request: Request) {
611
const apiKey = process.env.TOGETHER_API_KEY;
712

813
if (!apiKey) {
914
return new Response(JSON.stringify({ error: 'Missing Together.AI API key' }), { status: 500 });
1015
}
1116

12-
const now = Date.now();
17+
// Rate limiting
18+
const ip = request.headers.get('x-forwarded-for') || 'unknown'
19+
const now = Date.now()
20+
const entry = rateLimitMap.get(ip) || { count: 0, last: now }
21+
if (now - entry.last > RATE_WINDOW) {
22+
entry.count = 0
23+
entry.last = now
24+
}
25+
entry.count++
26+
rateLimitMap.set(ip, entry)
27+
if (entry.count > RATE_LIMIT) {
28+
return new Response(JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Content-Type': 'application/json' } })
29+
}
30+
1331
if (cachedModels && now - cacheTimestamp < CACHE_TTL) {
1432
return Response.json(cachedModels);
1533
}

app/api/user-settings/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,30 @@ async function getUserFromAuthHeader(req: NextRequest) {
2626
return user
2727
}
2828

29+
// Simple in-memory rate limiter (per IP, per minute)
30+
const rateLimitMap = new Map<string, { count: number, last: number }>()
31+
const RATE_LIMIT = 30 // requests
32+
const RATE_WINDOW = 60 * 1000 // 1 minute
33+
34+
function rateLimit(req: NextRequest) {
35+
const ip = req.headers.get('x-forwarded-for') || 'unknown'
36+
const now = Date.now()
37+
const entry = rateLimitMap.get(ip) || { count: 0, last: now }
38+
if (now - entry.last > RATE_WINDOW) {
39+
entry.count = 0
40+
entry.last = now
41+
}
42+
entry.count++
43+
rateLimitMap.set(ip, entry)
44+
if (entry.count > RATE_LIMIT) {
45+
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
46+
}
47+
return null
48+
}
49+
2950
export async function GET(req: NextRequest) {
51+
const limit = rateLimit(req)
52+
if (limit) return limit
3053
const user = await getUserFromAuthHeader(req)
3154
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3255
const supabase = getSupabaseAdmin()
@@ -44,6 +67,8 @@ export async function GET(req: NextRequest) {
4467
}
4568

4669
export async function POST(req: NextRequest) {
70+
const limit = rateLimit(req)
71+
if (limit) return limit
4772
const user = await getUserFromAuthHeader(req)
4873
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
4974
const body = await req.json()
@@ -62,6 +87,8 @@ export async function POST(req: NextRequest) {
6287
}
6388

6489
export async function PATCH(req: NextRequest) {
90+
const limit = rateLimit(req)
91+
if (limit) return limit
6592
const user = await getUserFromAuthHeader(req)
6693
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
6794
const body = await req.json()

integration/auth-flow.int.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react'
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3+
import { describe, it, expect, vi, beforeEach } from 'vitest'
4+
import '@testing-library/jest-dom'
5+
6+
// Mock next/navigation useRouter
7+
vi.mock('next/navigation', () => ({
8+
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() })
9+
}))
10+
11+
// Import the sign-in form/component
12+
import { SignInForm } from '@/app/(auth)/sign-in/sign-in-form'
13+
14+
// Integration test: auth flow
15+
16+
vi.mock('@/lib/supabase/client', () => {
17+
const signInWithPassword = vi.fn(async () => ({ data: { user: { id: 'user1', email: '[email protected]' } }, error: null }))
18+
// Attach to global for test access
19+
globalThis.__signInWithPassword = signInWithPassword
20+
return {
21+
createSupabaseClient: () => ({
22+
auth: { signInWithPassword },
23+
}),
24+
supabase: {
25+
auth: { signInWithPassword },
26+
},
27+
__esModule: true,
28+
}
29+
})
30+
31+
declare global {
32+
// eslint-disable-next-line no-var
33+
var __signInWithPassword: ReturnType<typeof vi.fn>
34+
}
35+
36+
describe('Auth Integration Flow', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
})
40+
41+
it('should sign in with email and password and show authenticated UI', async () => {
42+
render(<SignInForm />)
43+
const emailInput = screen.getByLabelText(/email/i)
44+
const passwordInput = screen.getByLabelText(/password/i)
45+
const submitBtn = screen.getByRole('button', { name: /sign in/i })
46+
47+
fireEvent.change(emailInput, { target: { value: '[email protected]' } })
48+
fireEvent.change(passwordInput, { target: { value: 'hunter2' } })
49+
fireEvent.click(submitBtn)
50+
51+
await waitFor(() => expect(globalThis.__signInWithPassword).toHaveBeenCalledWith({
52+
53+
password: 'hunter2',
54+
}))
55+
56+
// Should show some authenticated UI state (e.g., success message, redirect, etc.)
57+
// Adjust this assertion to match your actual UI
58+
expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument()
59+
})
60+
})

0 commit comments

Comments
 (0)