nextjs-hackathon-stack 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -9
- package/dist/index.js +8 -2
- package/package.json +2 -2
- package/template/.cursor/rules/ai.mdc +4 -4
- package/template/.cursor/rules/general.mdc +2 -2
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +6 -7
- package/template/_env.example +7 -7
- package/template/package.json.tmpl +8 -2
- package/template/src/app/__tests__/auth-callback.test.ts +72 -0
- package/template/src/app/__tests__/error.test.tsx +71 -0
- package/template/src/app/__tests__/layout.test.tsx +22 -0
- package/template/src/app/__tests__/login-page.test.tsx +34 -0
- package/template/src/app/__tests__/protected-layout.test.tsx +43 -0
- package/template/src/app/__tests__/protected-page.test.tsx +41 -0
- package/template/src/app/api/chat/route.ts +3 -1
- package/template/src/features/auth/__tests__/login-form.test.tsx +53 -2
- package/template/src/features/auth/__tests__/login.action.test.ts +82 -0
- package/template/src/features/auth/__tests__/logout.action.test.ts +32 -0
- package/template/src/features/auth/actions/login.action.ts +1 -1
- package/template/src/features/auth/components/login-form.tsx +4 -3
- package/template/src/features/chat/__tests__/chat-ui.test.tsx +97 -7
- package/template/src/features/chat/__tests__/route.test.ts +66 -3
- package/template/src/features/chat/__tests__/use-chat.test.ts +15 -0
- package/template/src/features/chat/components/chat-ui.tsx +1 -1
- package/template/src/shared/__tests__/middleware.test.ts +34 -0
- package/template/src/shared/__tests__/schema.test.ts +16 -3
- package/template/src/shared/__tests__/supabase-middleware.test.ts +162 -0
- package/template/src/shared/__tests__/supabase-server.test.ts +76 -3
- package/template/src/shared/__tests__/ui-button.test.tsx +52 -0
- package/template/src/shared/__tests__/ui-card.test.tsx +78 -0
- package/template/src/shared/__tests__/utils.test.ts +30 -0
- package/template/src/shared/lib/ai.ts +2 -2
- package/template/vitest.config.ts +5 -0
- package/template/src/features/tts/__tests__/route.test.ts +0 -13
- package/template/src/features/tts/__tests__/tts-player.test.tsx +0 -21
- package/template/src/features/tts/api/route.ts +0 -14
- package/template/src/features/tts/components/tts-player.tsx +0 -59
- package/template/src/features/video/__tests__/route.test.ts +0 -13
- package/template/src/features/video/__tests__/video-generator.test.tsx +0 -21
- package/template/src/features/video/api/route.ts +0 -14
- package/template/src/features/video/components/video-generator.tsx +0 -56
- package/template/src/shared/__tests__/minimax-media.test.ts +0 -58
- package/template/src/shared/lib/minimax-media.ts +0 -63
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ npx nextjs-hackathon-stack my-app
|
|
|
20
20
|
| Forms | React Hook Form + Zod resolver |
|
|
21
21
|
| UI | shadcn/ui + Tailwind CSS v4 |
|
|
22
22
|
| AI Streaming | Vercel AI SDK + AI Gateway |
|
|
23
|
-
| LLM |
|
|
23
|
+
| LLM | Google Gemini 2.0 Flash (`google/gemini-2.0-flash`) |
|
|
24
24
|
| Testing | Vitest + React Testing Library + Playwright |
|
|
25
25
|
|
|
26
26
|
## Quick start
|
|
@@ -34,7 +34,6 @@ cd my-app
|
|
|
34
34
|
# NEXT_PUBLIC_SUPABASE_URL → supabase.com > Project Settings > API
|
|
35
35
|
# NEXT_PUBLIC_SUPABASE_ANON_KEY → supabase.com > Project Settings > API
|
|
36
36
|
# DATABASE_URL → supabase.com > Project Settings > Database
|
|
37
|
-
# MINIMAX_API_KEY → minimaxi.chat > API Keys
|
|
38
37
|
# AI_GATEWAY_URL → vercel.com > AI > Gateways
|
|
39
38
|
|
|
40
39
|
npm run dev
|
|
@@ -53,9 +52,7 @@ npx nextjs-hackathon-stack my-app --skip-install
|
|
|
53
52
|
## Features
|
|
54
53
|
|
|
55
54
|
- **Auth** — Email/password login with Supabase Auth, Server Actions, protected routes
|
|
56
|
-
- **AI Chat** — Streaming chat with
|
|
57
|
-
- **Video Generation** — MiniMax Video-01 via direct API
|
|
58
|
-
- **Text-to-Speech** — MiniMax Speech 2.6 via direct API
|
|
55
|
+
- **AI Chat** — Streaming chat with Gemini 2.0 Flash via Vercel AI Gateway (Edge runtime)
|
|
59
56
|
- **TDD-ready** — 100% coverage enforced, Vitest + Playwright preconfigured
|
|
60
57
|
- **Cursor AI** — Rules, agents, and skills preconfigured for the full stack
|
|
61
58
|
|
|
@@ -68,11 +65,9 @@ src/
|
|
|
68
65
|
├── app/ # Next.js routing + layouts
|
|
69
66
|
├── features/
|
|
70
67
|
│ ├── auth/ # Login form, server actions, session hook
|
|
71
|
-
│
|
|
72
|
-
│ ├── video/ # Video generation
|
|
73
|
-
│ └── tts/ # Text-to-speech
|
|
68
|
+
│ └── chat/ # AI chat (streaming)
|
|
74
69
|
├── shared/
|
|
75
|
-
│ ├── lib/ # Supabase clients, AI
|
|
70
|
+
│ ├── lib/ # Supabase clients, AI
|
|
76
71
|
│ ├── db/ # Drizzle schema + migrations
|
|
77
72
|
│ └── components/# Providers + shadcn/ui
|
|
78
73
|
└── e2e/ # Playwright e2e tests
|
package/dist/index.js
CHANGED
|
@@ -112,6 +112,13 @@ async function scaffold(projectName, skipInstall) {
|
|
|
112
112
|
p3.log.error(stderr || "pnpm install failed");
|
|
113
113
|
p3.log.info("You can install manually: cd " + projectName + " && pnpm install");
|
|
114
114
|
}
|
|
115
|
+
spinner2.start("Setting up husky pre-commit hooks");
|
|
116
|
+
try {
|
|
117
|
+
execSync("pnpm exec husky", { cwd: targetDir, stdio: "pipe" });
|
|
118
|
+
spinner2.stop("Husky pre-commit hooks set up");
|
|
119
|
+
} catch {
|
|
120
|
+
spinner2.stop("Husky setup failed \u2014 run manually: pnpm exec husky");
|
|
121
|
+
}
|
|
115
122
|
spinner2.start("Initializing shadcn/ui");
|
|
116
123
|
try {
|
|
117
124
|
execSync("npx shadcn@latest init --yes --defaults --force --silent", {
|
|
@@ -152,8 +159,7 @@ async function scaffold(projectName, skipInstall) {
|
|
|
152
159
|
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_URL")} \u2014 from supabase.com > Project Settings > API`);
|
|
153
160
|
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_ANON_KEY")} \u2014 from supabase.com > Project Settings > API`);
|
|
154
161
|
console.log(` ${pc2.dim("DATABASE_URL")} \u2014 from supabase.com > Project Settings > Database`);
|
|
155
|
-
console.log(` ${pc2.dim("AI_GATEWAY_URL")} \u2014
|
|
156
|
-
console.log(` ${pc2.dim("MINIMAX_API_KEY")} \u2014 from minimaxi.chat`);
|
|
162
|
+
console.log(` ${pc2.dim("AI_GATEWAY_URL")} \u2014 from vercel.com > AI > Gateways`);
|
|
157
163
|
console.log(` ${pc2.cyan("pnpm dev")}
|
|
158
164
|
`);
|
|
159
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextjs-hackathon-stack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Scaffold a full-stack Next.js hackathon starter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"drizzle",
|
|
23
23
|
"tanstack-query",
|
|
24
24
|
"shadcn",
|
|
25
|
-
"
|
|
25
|
+
"gemini",
|
|
26
26
|
"starter"
|
|
27
27
|
],
|
|
28
28
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: AI SDK + Edge runtime rules. RISK 3 (cold starts).
|
|
3
|
-
globs: ["src/features/chat/**", "src/
|
|
3
|
+
globs: ["src/features/chat/**", "src/shared/lib/ai*"]
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# AI Rules (Risk 3: Vercel Cold Starts)
|
|
@@ -30,12 +30,12 @@ If you need DB data in an AI route:
|
|
|
30
30
|
```typescript
|
|
31
31
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
32
32
|
|
|
33
|
-
const
|
|
33
|
+
const provider = createOpenAI({
|
|
34
34
|
baseURL: process.env.AI_GATEWAY_URL,
|
|
35
|
-
apiKey: process.env.
|
|
35
|
+
apiKey: process.env.AI_API_KEY,
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
export const aiModel =
|
|
38
|
+
export const aiModel = provider(process.env.AI_MODEL ?? "google/gemini-2.0-flash");
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Streaming Pattern
|
|
@@ -15,14 +15,14 @@ alwaysApply: true
|
|
|
15
15
|
- **Validation**: Zod (auto-generated via `drizzle-zod`)
|
|
16
16
|
- **Forms**: React Hook Form + Zod resolver
|
|
17
17
|
- **UI**: shadcn/ui + Tailwind CSS v4
|
|
18
|
-
- **AI**: Vercel AI SDK +
|
|
18
|
+
- **AI**: Vercel AI SDK + Google Gemini 2.0 Flash via AI Gateway
|
|
19
19
|
- **Testing**: Vitest + React Testing Library + Playwright
|
|
20
20
|
|
|
21
21
|
## Project Structure
|
|
22
22
|
```
|
|
23
23
|
src/
|
|
24
24
|
├── app/ # Next.js App Router pages
|
|
25
|
-
├── features/ # Feature modules (auth, chat
|
|
25
|
+
├── features/ # Feature modules (auth, chat)
|
|
26
26
|
│ └── <feature>/
|
|
27
27
|
│ ├── components/
|
|
28
28
|
│ ├── actions/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vitest run --coverage
|
package/template/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Full-stack Next.js 15 hackathon starter.
|
|
|
14
14
|
| State | TanStack Query v5 |
|
|
15
15
|
| Forms | React Hook Form + Zod |
|
|
16
16
|
| UI | shadcn/ui + Tailwind CSS v4 |
|
|
17
|
-
| AI | Vercel AI SDK +
|
|
17
|
+
| AI | Vercel AI SDK + Gemini 2.0 Flash |
|
|
18
18
|
| Testing | Vitest + Playwright |
|
|
19
19
|
|
|
20
20
|
## Getting Started
|
|
@@ -29,11 +29,10 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
|
|
|
29
29
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
|
30
30
|
DATABASE_URL=postgresql://postgres:[password]@db.your-project-id.supabase.co:5432/postgres
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# Vercel AI Gateway — https://vercel.com > AI > Gateways
|
|
32
|
+
# AI — Vercel AI Gateway (default: Google Gemini 2.0 Flash — free tier)
|
|
33
|
+
# Create gateway at: https://vercel.com > AI > Gateways
|
|
36
34
|
AI_GATEWAY_URL=https://gateway.ai.vercel.app/v1/your-team-id/your-gateway-id
|
|
35
|
+
AI_API_KEY=
|
|
37
36
|
```
|
|
38
37
|
|
|
39
38
|
### 2. Run the dev server
|
|
@@ -70,8 +69,8 @@ pnpm db:migrate # Apply migrations
|
|
|
70
69
|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase > Project Settings > API |
|
|
71
70
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase > Project Settings > API |
|
|
72
71
|
| `DATABASE_URL` | Supabase > Project Settings > Database > URI |
|
|
73
|
-
| `MINIMAX_API_KEY` | minimaxi.chat > API Keys |
|
|
74
72
|
| `AI_GATEWAY_URL` | Vercel > AI > Gateways |
|
|
73
|
+
| `AI_API_KEY` | Your AI provider API key |
|
|
75
74
|
| `NEXT_PUBLIC_APP_URL` | Your deployment URL (default: `http://localhost:3000`) |
|
|
76
75
|
|
|
77
76
|
See `.env.example` for all required variables with comments.
|
|
@@ -82,7 +81,7 @@ Feature-based structure:
|
|
|
82
81
|
```
|
|
83
82
|
src/
|
|
84
83
|
├── app/ # Next.js routing + layouts
|
|
85
|
-
├── features/ # auth | chat
|
|
84
|
+
├── features/ # auth | chat
|
|
86
85
|
├── shared/ # lib | db | components/ui
|
|
87
86
|
└── e2e/ # Playwright tests
|
|
88
87
|
```
|
package/template/_env.example
CHANGED
|
@@ -9,13 +9,13 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
|
|
9
9
|
# Supabase DB — https://supabase.com > Project Settings > Database > Connection string (URI)
|
|
10
10
|
DATABASE_URL=postgresql://postgres:[password]@db.your-project-id.supabase.co:5432/postgres
|
|
11
11
|
|
|
12
|
-
# AI
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# AI_MODEL=
|
|
12
|
+
# AI — Vercel AI Gateway (default: Google Gemini 2.0 Flash — free tier)
|
|
13
|
+
# Create gateway at: https://vercel.com > AI > Gateways
|
|
14
|
+
AI_GATEWAY_URL=https://gateway.ai.vercel.app/v1/your-team-id/your-gateway-id
|
|
15
|
+
AI_API_KEY=
|
|
16
|
+
# Optional: override model (default: google/gemini-2.0-flash)
|
|
17
|
+
# To use MiniMax: AI_MODEL=minimax/minimax-m2.7
|
|
18
|
+
# AI_MODEL=google/gemini-2.0-flash
|
|
19
19
|
|
|
20
20
|
# =============================================================================
|
|
21
21
|
# OPTIONAL
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"test:e2e": "playwright test",
|
|
17
17
|
"db:generate": "drizzle-kit generate",
|
|
18
18
|
"db:migrate": "drizzle-kit migrate",
|
|
19
|
-
"db:studio": "drizzle-kit studio"
|
|
19
|
+
"db:studio": "drizzle-kit studio",
|
|
20
|
+
"prepare": "husky"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"next": "^15",
|
|
@@ -64,6 +65,11 @@
|
|
|
64
65
|
"eslint-plugin-react-hooks": "^5",
|
|
65
66
|
"eslint-plugin-import-x": "^4",
|
|
66
67
|
"eslint-plugin-vitest": "^0.5",
|
|
67
|
-
"eslint-plugin-playwright": "^2"
|
|
68
|
+
"eslint-plugin-playwright": "^2",
|
|
69
|
+
"husky": "^9",
|
|
70
|
+
"lint-staged": "^15"
|
|
71
|
+
},
|
|
72
|
+
"lint-staged": {
|
|
73
|
+
"*.{ts,tsx}": ["vitest related --run"]
|
|
68
74
|
}
|
|
69
75
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockExchangeCodeForSession = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
6
|
+
createClient: vi.fn(() =>
|
|
7
|
+
Promise.resolve({
|
|
8
|
+
auth: { exchangeCodeForSession: mockExchangeCodeForSession },
|
|
9
|
+
})
|
|
10
|
+
),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("auth callback route", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("redirects to /login with error when code is missing", async () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
21
|
+
const req = new Request("http://localhost/api/auth/callback");
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const response = await GET(req);
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(response.status).toBe(307);
|
|
28
|
+
expect(response.headers.get("location")).toContain("/login?error=no_code");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("redirects to /login with error when exchange fails", async () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: { message: "bad code" } });
|
|
34
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
35
|
+
const req = new Request("http://localhost/api/auth/callback?code=bad-code");
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
const response = await GET(req);
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(response.status).toBe(307);
|
|
42
|
+
expect(response.headers.get("location")).toContain("/login?error=auth_error");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("redirects to / on successful exchange", async () => {
|
|
46
|
+
// Arrange
|
|
47
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
|
|
48
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
49
|
+
const req = new Request("http://localhost/api/auth/callback?code=valid-code");
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
const response = await GET(req);
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(response.status).toBe(307);
|
|
56
|
+
expect(response.headers.get("location")).toBe("http://localhost/");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("redirects to next param on successful exchange", async () => {
|
|
60
|
+
// Arrange
|
|
61
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
|
|
62
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
63
|
+
const req = new Request("http://localhost/api/auth/callback?code=valid-code&next=/dashboard");
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const response = await GET(req);
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(response.status).toBe(307);
|
|
70
|
+
expect(response.headers.get("location")).toContain("/dashboard");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import ErrorPage from "../error";
|
|
6
|
+
|
|
7
|
+
describe("ErrorPage", () => {
|
|
8
|
+
it("renders error heading", () => {
|
|
9
|
+
// Arrange
|
|
10
|
+
const error = new Error("Something failed");
|
|
11
|
+
const reset = vi.fn();
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders error message", () => {
|
|
21
|
+
// Arrange
|
|
22
|
+
const error = new Error("Specific error message");
|
|
23
|
+
const reset = vi.fn();
|
|
24
|
+
|
|
25
|
+
// Act
|
|
26
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(screen.getByText("Specific error message")).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders Try again button", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const error = new Error("test");
|
|
35
|
+
const reset = vi.fn();
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("calls reset when Try again is clicked", async () => {
|
|
45
|
+
// Arrange
|
|
46
|
+
const error = new Error("test");
|
|
47
|
+
const reset = vi.fn();
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
await user.click(screen.getByRole("button", { name: /try again/i }));
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(reset).toHaveBeenCalledOnce();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("logs error to console on mount", () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const error = new Error("logged error");
|
|
61
|
+
const reset = vi.fn();
|
|
62
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(consoleSpy).toHaveBeenCalledWith(error);
|
|
69
|
+
consoleSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import RootLayout from "../layout";
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/components/providers", () => ({
|
|
7
|
+
Providers: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("RootLayout", () => {
|
|
11
|
+
it("renders children inside body", () => {
|
|
12
|
+
// Arrange + Act
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<RootLayout>
|
|
15
|
+
<p data-testid="child-content">Hello</p>
|
|
16
|
+
</RootLayout>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(container.querySelector("[data-testid='child-content']")).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import LoginPage from "../(auth)/login/page";
|
|
5
|
+
|
|
6
|
+
vi.mock("@/features/auth/components/login-form", () => ({
|
|
7
|
+
LoginForm: () => <div data-testid="login-form" />,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("LoginPage", () => {
|
|
11
|
+
it("renders the welcome heading", () => {
|
|
12
|
+
// Arrange + Act
|
|
13
|
+
render(<LoginPage />);
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(screen.getByText(/welcome back/i)).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the LoginForm component", () => {
|
|
20
|
+
// Arrange + Act
|
|
21
|
+
render(<LoginPage />);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders the sign-in description", () => {
|
|
28
|
+
// Arrange + Act
|
|
29
|
+
render(<LoginPage />);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(screen.getByText(/sign in to your account/i)).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetUser = vi.fn();
|
|
4
|
+
const mockRedirect = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
7
|
+
createClient: vi.fn(() =>
|
|
8
|
+
Promise.resolve({
|
|
9
|
+
auth: { getUser: mockGetUser },
|
|
10
|
+
})
|
|
11
|
+
),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
redirect: (url: string) => mockRedirect(url),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("ProtectedLayout", () => {
|
|
19
|
+
it("renders children when user is authenticated", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
mockGetUser.mockResolvedValue({ data: { user: { email: "user@example.com" } } });
|
|
22
|
+
const { default: ProtectedLayout } = await import("../(protected)/layout");
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const result = await ProtectedLayout({ children: <div data-testid="child" /> });
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(result).toBeTruthy();
|
|
29
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("redirects to /login when user is not authenticated", async () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
35
|
+
const { default: ProtectedLayout } = await import("../(protected)/layout");
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
await ProtectedLayout({ children: <div /> });
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetUser = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
6
|
+
createClient: vi.fn(() =>
|
|
7
|
+
Promise.resolve({
|
|
8
|
+
auth: { getUser: mockGetUser },
|
|
9
|
+
})
|
|
10
|
+
),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@/features/chat/components/chat-ui", () => ({
|
|
14
|
+
ChatUi: () => <div data-testid="chat-ui" />,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe("HomePage (protected)", () => {
|
|
18
|
+
it("renders welcome message with user email", async () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
mockGetUser.mockResolvedValue({ data: { user: { email: "user@example.com" } } });
|
|
21
|
+
const { default: HomePage } = await import("../(protected)/page");
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const result = await HomePage();
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(result).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders with null user gracefully", async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
33
|
+
const { default: HomePage } = await import("../(protected)/page");
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const result = await HomePage();
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(result).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -16,7 +16,9 @@ export async function POST(req: Request) {
|
|
|
16
16
|
return result.toDataStreamResponse({
|
|
17
17
|
getErrorMessage: (error) => {
|
|
18
18
|
console.error("[chat] stream error:", error);
|
|
19
|
-
|
|
19
|
+
if (error instanceof Error) return error.message;
|
|
20
|
+
if (typeof error === "object" && error !== null) return JSON.stringify(error);
|
|
21
|
+
return String(error);
|
|
20
22
|
},
|
|
21
23
|
});
|
|
22
24
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { LoginForm } from "../components/login-form";
|
|
6
6
|
|
|
7
|
+
const mockLoginAction = vi.fn().mockResolvedValue({ status: "idle" });
|
|
8
|
+
|
|
7
9
|
vi.mock("../actions/login.action", () => ({
|
|
8
|
-
loginAction:
|
|
10
|
+
loginAction: (...args: unknown[]) => mockLoginAction(...args),
|
|
9
11
|
}));
|
|
10
12
|
|
|
11
13
|
describe("LoginForm", () => {
|
|
12
14
|
beforeEach(() => {
|
|
13
15
|
vi.clearAllMocks();
|
|
16
|
+
mockLoginAction.mockResolvedValue({ status: "idle" });
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
it("renders email and password fields", () => {
|
|
@@ -41,4 +44,52 @@ describe("LoginForm", () => {
|
|
|
41
44
|
// Assert
|
|
42
45
|
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
|
|
43
46
|
});
|
|
47
|
+
|
|
48
|
+
it("shows validation error for short password", async () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
render(<LoginForm />);
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
55
|
+
await user.type(screen.getByLabelText(/password/i), "short");
|
|
56
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("submits with valid credentials and calls loginAction", async () => {
|
|
63
|
+
// Arrange
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(<LoginForm />);
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
69
|
+
await user.type(screen.getByLabelText(/password/i), "password123");
|
|
70
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(mockLoginAction).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("displays server-side error message when action returns error", async () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
mockLoginAction.mockResolvedValue({ status: "error", message: "Invalid credentials" });
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
render(<LoginForm />);
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
86
|
+
await user.type(screen.getByLabelText(/password/i), "password123");
|
|
87
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
44
95
|
});
|