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.
Files changed (44) hide show
  1. package/README.md +4 -9
  2. package/dist/index.js +8 -2
  3. package/package.json +2 -2
  4. package/template/.cursor/rules/ai.mdc +4 -4
  5. package/template/.cursor/rules/general.mdc +2 -2
  6. package/template/.husky/pre-commit +1 -0
  7. package/template/README.md +6 -7
  8. package/template/_env.example +7 -7
  9. package/template/package.json.tmpl +8 -2
  10. package/template/src/app/__tests__/auth-callback.test.ts +72 -0
  11. package/template/src/app/__tests__/error.test.tsx +71 -0
  12. package/template/src/app/__tests__/layout.test.tsx +22 -0
  13. package/template/src/app/__tests__/login-page.test.tsx +34 -0
  14. package/template/src/app/__tests__/protected-layout.test.tsx +43 -0
  15. package/template/src/app/__tests__/protected-page.test.tsx +41 -0
  16. package/template/src/app/api/chat/route.ts +3 -1
  17. package/template/src/features/auth/__tests__/login-form.test.tsx +53 -2
  18. package/template/src/features/auth/__tests__/login.action.test.ts +82 -0
  19. package/template/src/features/auth/__tests__/logout.action.test.ts +32 -0
  20. package/template/src/features/auth/actions/login.action.ts +1 -1
  21. package/template/src/features/auth/components/login-form.tsx +4 -3
  22. package/template/src/features/chat/__tests__/chat-ui.test.tsx +97 -7
  23. package/template/src/features/chat/__tests__/route.test.ts +66 -3
  24. package/template/src/features/chat/__tests__/use-chat.test.ts +15 -0
  25. package/template/src/features/chat/components/chat-ui.tsx +1 -1
  26. package/template/src/shared/__tests__/middleware.test.ts +34 -0
  27. package/template/src/shared/__tests__/schema.test.ts +16 -3
  28. package/template/src/shared/__tests__/supabase-middleware.test.ts +162 -0
  29. package/template/src/shared/__tests__/supabase-server.test.ts +76 -3
  30. package/template/src/shared/__tests__/ui-button.test.tsx +52 -0
  31. package/template/src/shared/__tests__/ui-card.test.tsx +78 -0
  32. package/template/src/shared/__tests__/utils.test.ts +30 -0
  33. package/template/src/shared/lib/ai.ts +2 -2
  34. package/template/vitest.config.ts +5 -0
  35. package/template/src/features/tts/__tests__/route.test.ts +0 -13
  36. package/template/src/features/tts/__tests__/tts-player.test.tsx +0 -21
  37. package/template/src/features/tts/api/route.ts +0 -14
  38. package/template/src/features/tts/components/tts-player.tsx +0 -59
  39. package/template/src/features/video/__tests__/route.test.ts +0 -13
  40. package/template/src/features/video/__tests__/video-generator.test.tsx +0 -21
  41. package/template/src/features/video/api/route.ts +0 -14
  42. package/template/src/features/video/components/video-generator.tsx +0 -56
  43. package/template/src/shared/__tests__/minimax-media.test.ts +0 -58
  44. 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 | MiniMax M2.7 (`minimax/minimax-m2.7`) |
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 MiniMax M2.7 via Vercel AI Gateway (Edge runtime)
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
- ├── chat/ # AI chat (streaming)
72
- │ ├── video/ # Video generation
73
- │ └── tts/ # Text-to-speech
68
+ └── chat/ # AI chat (streaming)
74
69
  ├── shared/
75
- │ ├── lib/ # Supabase clients, AI, MiniMax
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 Vercel AI Gateway URL`);
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.11",
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
- "minimax",
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/features/video/**", "src/features/tts/**", "src/shared/lib/ai*"]
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 gateway = createOpenAI({
33
+ const provider = createOpenAI({
34
34
  baseURL: process.env.AI_GATEWAY_URL,
35
- apiKey: process.env.MINIMAX_API_KEY,
35
+ apiKey: process.env.AI_API_KEY,
36
36
  });
37
37
 
38
- export const aiModel = gateway("MiniMax-Text-01");
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 + MiniMax M2.7 via AI Gateway
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, video, tts)
25
+ ├── features/ # Feature modules (auth, chat)
26
26
  │ └── <feature>/
27
27
  │ ├── components/
28
28
  │ ├── actions/
@@ -0,0 +1 @@
1
+ vitest run --coverage
@@ -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 + MiniMax |
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
- # MiniMaxhttps://www.minimaxi.chat > API Keys
33
- MINIMAX_API_KEY=your-minimax-api-key
34
-
35
- # Vercel AI Gateway — https://vercel.com > AI > Gateways
32
+ # AIVercel 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 | video | tts
84
+ ├── features/ # auth | chat
86
85
  ├── shared/ # lib | db | components/ui
87
86
  └── e2e/ # Playwright tests
88
87
  ```
@@ -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 Provider defaults to MiniMax direct API
13
- AI_API_KEY=your-api-key
14
- # Optional: override base URL (default: https://api.minimaxi.chat/v1)
15
- # For Vercel Gateway: https://ai-gateway.vercel.sh/v1
16
- # AI_BASE_URL=https://api.minimaxi.chat/v1
17
- # Optional: override model (default: MiniMax-M1)
18
- # AI_MODEL=MiniMax-M1
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
- return error instanceof Error ? error.message : "AI request failed";
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: vi.fn().mockResolvedValue({ status: "idle" }),
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
  });