nextjs-hackathon-stack 0.1.15 → 0.1.16
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/dist/index.js +7 -8
- package/package.json +1 -1
- package/template/_env.example +0 -7
- package/template/package.json.tmpl +0 -3
- package/template/src/app/(protected)/page.tsx +16 -7
- package/template/src/app/__tests__/protected-page.test.tsx +23 -6
- package/template/src/e2e/todos.spec.ts +14 -0
- package/template/src/features/auth/__tests__/login-form.test.tsx +15 -15
- package/template/src/features/auth/components/login-form.tsx +7 -7
- package/template/src/features/todos/__tests__/add-todo-form.test.tsx +69 -0
- package/template/src/features/todos/__tests__/todo-list.test.tsx +85 -0
- package/template/src/features/todos/__tests__/todos.action.test.ts +145 -0
- package/template/src/features/todos/actions/todos.action.ts +34 -0
- package/template/src/features/todos/components/add-todo-form.tsx +40 -0
- package/template/src/features/todos/components/todo-list.tsx +58 -0
- package/template/src/shared/db/schema.ts +15 -1
- package/template/.cursor/rules/ai.mdc +0 -53
- package/template/src/app/api/chat/route.ts +0 -24
- package/template/src/e2e/chat.spec.ts +0 -8
- package/template/src/features/chat/__tests__/chat-ui.test.tsx +0 -121
- package/template/src/features/chat/__tests__/route.test.ts +0 -82
- package/template/src/features/chat/__tests__/use-chat.test.ts +0 -15
- package/template/src/features/chat/components/chat-ui.tsx +0 -48
- package/template/src/features/chat/hooks/use-chat.ts +0 -1
- package/template/src/shared/__tests__/ai.test.ts +0 -9
- package/template/src/shared/lib/ai.ts +0 -8
package/dist/index.js
CHANGED
|
@@ -104,7 +104,7 @@ async function scaffold(projectName, skipInstall) {
|
|
|
104
104
|
if (!skipInstall) {
|
|
105
105
|
spinner2.start("Installing dependencies (this may take a minute)");
|
|
106
106
|
try {
|
|
107
|
-
execSync("pnpm install", { cwd: targetDir, stdio: "pipe" });
|
|
107
|
+
execSync("pnpm install", { cwd: targetDir, stdio: "pipe", env: { ...process.env, HUSKY: "0" } });
|
|
108
108
|
spinner2.stop("Dependencies installed");
|
|
109
109
|
} catch (err) {
|
|
110
110
|
spinner2.stop("Failed to install dependencies");
|
|
@@ -151,15 +151,14 @@ async function scaffold(projectName, skipInstall) {
|
|
|
151
151
|
} catch {
|
|
152
152
|
spinner2.stop("Husky setup failed \u2014 run manually: pnpm exec husky");
|
|
153
153
|
}
|
|
154
|
-
success(pc2.bold(
|
|
154
|
+
success(pc2.bold(`\xA1Proyecto "${projectName}" creado!`));
|
|
155
155
|
console.log(`
|
|
156
|
-
${pc2.dim("
|
|
156
|
+
${pc2.dim("Siguientes pasos:")}`);
|
|
157
157
|
console.log(` ${pc2.cyan(`cd ${projectName}`)}`);
|
|
158
|
-
console.log(` ${pc2.yellow("
|
|
159
|
-
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_URL")} \u2014
|
|
160
|
-
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_ANON_KEY")} \u2014
|
|
161
|
-
console.log(` ${pc2.dim("DATABASE_URL")} \u2014
|
|
162
|
-
console.log(` ${pc2.dim("AI_GATEWAY_URL")} \u2014 from vercel.com > AI > Gateways`);
|
|
158
|
+
console.log(` ${pc2.yellow("Edita .env.local y completa tus credenciales:")}`);
|
|
159
|
+
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_URL")} \u2014 supabase.com > Project Settings > API`);
|
|
160
|
+
console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_ANON_KEY")} \u2014 supabase.com > Project Settings > API`);
|
|
161
|
+
console.log(` ${pc2.dim("DATABASE_URL")} \u2014 supabase.com > Project Settings > Database`);
|
|
163
162
|
console.log(` ${pc2.cyan("pnpm dev")}
|
|
164
163
|
`);
|
|
165
164
|
}
|
package/package.json
CHANGED
package/template/_env.example
CHANGED
|
@@ -9,13 +9,6 @@ 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 — Vercel AI Gateway (default: MiniMax M2.1)
|
|
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: minimax/minimax-m2.1)
|
|
17
|
-
# AI_MODEL=minimax/minimax-m2.1
|
|
18
|
-
|
|
19
12
|
# =============================================================================
|
|
20
13
|
# OPTIONAL
|
|
21
14
|
# =============================================================================
|
|
@@ -26,15 +26,12 @@
|
|
|
26
26
|
"drizzle-orm": "^0.44",
|
|
27
27
|
"drizzle-zod": "^0.7",
|
|
28
28
|
"@tanstack/react-query": "^5",
|
|
29
|
-
"ai": "^4.3",
|
|
30
|
-
"@ai-sdk/react": "^1",
|
|
31
29
|
"react-hook-form": "^7",
|
|
32
30
|
"@hookform/resolvers": "^3",
|
|
33
31
|
"zod": "^3",
|
|
34
32
|
"react": "^19",
|
|
35
33
|
"react-dom": "^19",
|
|
36
34
|
"postgres": "^3",
|
|
37
|
-
"@ai-sdk/openai": "^1",
|
|
38
35
|
"clsx": "^2",
|
|
39
36
|
"tailwind-merge": "^2",
|
|
40
37
|
"class-variance-authority": "^0.7",
|
|
@@ -1,24 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TodoList } from "@/features/todos/components/todo-list";
|
|
2
|
+
import { AddTodoForm } from "@/features/todos/components/add-todo-form";
|
|
2
3
|
import { logoutAction } from "@/features/auth/actions/logout.action";
|
|
3
4
|
import { createClient } from "@/shared/lib/supabase/server";
|
|
5
|
+
import { db } from "@/shared/db";
|
|
6
|
+
import { todos } from "@/shared/db/schema";
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
4
8
|
|
|
5
9
|
export default async function HomePage() {
|
|
6
10
|
const supabase = await createClient();
|
|
7
11
|
const {
|
|
8
12
|
data: { user },
|
|
9
13
|
} = await supabase.auth.getUser();
|
|
14
|
+
const items = await db.select().from(todos).where(eq(todos.userId, user!.id));
|
|
10
15
|
|
|
11
16
|
return (
|
|
12
|
-
<main className="container mx-auto p-8">
|
|
13
|
-
<div className="mb-
|
|
14
|
-
<h1 className="text-2xl font-bold">
|
|
17
|
+
<main className="container mx-auto max-w-2xl p-8">
|
|
18
|
+
<div className="mb-6 flex items-center justify-between">
|
|
19
|
+
<h1 className="text-2xl font-bold">Mis tareas</h1>
|
|
15
20
|
<form action={logoutAction}>
|
|
16
|
-
<button
|
|
17
|
-
|
|
21
|
+
<button
|
|
22
|
+
type="submit"
|
|
23
|
+
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
|
|
24
|
+
>
|
|
25
|
+
Cerrar sesión
|
|
18
26
|
</button>
|
|
19
27
|
</form>
|
|
20
28
|
</div>
|
|
21
|
-
<
|
|
29
|
+
<AddTodoForm />
|
|
30
|
+
<TodoList items={items} />
|
|
22
31
|
</main>
|
|
23
32
|
);
|
|
24
33
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mockGetUser = vi.fn();
|
|
4
|
+
const mockSelect = vi.fn();
|
|
4
5
|
|
|
5
6
|
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
6
7
|
createClient: vi.fn(() =>
|
|
@@ -10,14 +11,29 @@ vi.mock("@/shared/lib/supabase/server", () => ({
|
|
|
10
11
|
),
|
|
11
12
|
}));
|
|
12
13
|
|
|
13
|
-
vi.mock("@/
|
|
14
|
-
|
|
14
|
+
vi.mock("@/shared/db", () => ({
|
|
15
|
+
db: {
|
|
16
|
+
select: vi.fn(() => ({
|
|
17
|
+
from: vi.fn(() => ({
|
|
18
|
+
where: mockSelect,
|
|
19
|
+
})),
|
|
20
|
+
})),
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@/features/todos/components/todo-list", () => ({
|
|
25
|
+
TodoList: () => <div data-testid="todo-list" />,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("@/features/todos/components/add-todo-form", () => ({
|
|
29
|
+
AddTodoForm: () => <div data-testid="add-todo-form" />,
|
|
15
30
|
}));
|
|
16
31
|
|
|
17
32
|
describe("HomePage (protected)", () => {
|
|
18
|
-
it("
|
|
33
|
+
it("renderiza la página con las tareas del usuario", async () => {
|
|
19
34
|
// Arrange
|
|
20
|
-
mockGetUser.mockResolvedValue({ data: { user: { email: "user@example.com" } } });
|
|
35
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1", email: "user@example.com" } } });
|
|
36
|
+
mockSelect.mockResolvedValue([]);
|
|
21
37
|
const { default: HomePage } = await import("../(protected)/page");
|
|
22
38
|
|
|
23
39
|
// Act
|
|
@@ -27,9 +43,10 @@ describe("HomePage (protected)", () => {
|
|
|
27
43
|
expect(result).toBeTruthy();
|
|
28
44
|
});
|
|
29
45
|
|
|
30
|
-
it("
|
|
46
|
+
it("renderiza correctamente con lista de tareas vacía", async () => {
|
|
31
47
|
// Arrange
|
|
32
|
-
mockGetUser.mockResolvedValue({ data: { user:
|
|
48
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1", email: "user@example.com" } } });
|
|
49
|
+
mockSelect.mockResolvedValue([]);
|
|
33
50
|
const { default: HomePage } = await import("../(protected)/page");
|
|
34
51
|
|
|
35
52
|
// Act
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test.describe("Página de tareas", () => {
|
|
4
|
+
test("muestra el título Mis tareas", async ({ page }) => {
|
|
5
|
+
await page.goto("/");
|
|
6
|
+
await expect(page.getByRole("heading", { name: "Mis tareas" })).toBeVisible();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("muestra el formulario para agregar tareas", async ({ page }) => {
|
|
10
|
+
await page.goto("/");
|
|
11
|
+
await expect(page.getByRole("textbox", { name: /nueva tarea/i })).toBeVisible();
|
|
12
|
+
await expect(page.getByRole("button", { name: /agregar/i })).toBeVisible();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -21,8 +21,8 @@ describe("LoginForm", () => {
|
|
|
21
21
|
render(<LoginForm />);
|
|
22
22
|
|
|
23
23
|
// Assert
|
|
24
|
-
expect(screen.getByLabelText(/
|
|
25
|
-
expect(screen.getByLabelText(/
|
|
24
|
+
expect(screen.getByLabelText(/correo/i)).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByLabelText(/contraseña/i)).toBeInTheDocument();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it("renders submit button", () => {
|
|
@@ -30,7 +30,7 @@ describe("LoginForm", () => {
|
|
|
30
30
|
render(<LoginForm />);
|
|
31
31
|
|
|
32
32
|
// Assert
|
|
33
|
-
expect(screen.getByRole("button", { name: /
|
|
33
|
+
expect(screen.getByRole("button", { name: /iniciar sesión/i })).toBeInTheDocument();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
it("shows validation error when submitting with empty email", async () => {
|
|
@@ -39,10 +39,10 @@ describe("LoginForm", () => {
|
|
|
39
39
|
render(<LoginForm />);
|
|
40
40
|
|
|
41
41
|
// Act
|
|
42
|
-
await user.click(screen.getByRole("button", { name: /
|
|
42
|
+
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
|
|
43
43
|
|
|
44
44
|
// Assert
|
|
45
|
-
expect(await screen.findByText(/
|
|
45
|
+
expect(await screen.findByText(/correo electrónico inválido/i)).toBeInTheDocument();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
it("shows validation error for short password", async () => {
|
|
@@ -51,12 +51,12 @@ describe("LoginForm", () => {
|
|
|
51
51
|
render(<LoginForm />);
|
|
52
52
|
|
|
53
53
|
// Act
|
|
54
|
-
await user.type(screen.getByLabelText(/
|
|
55
|
-
await user.type(screen.getByLabelText(/
|
|
56
|
-
await user.click(screen.getByRole("button", { name: /
|
|
54
|
+
await user.type(screen.getByLabelText(/correo/i), "user@example.com");
|
|
55
|
+
await user.type(screen.getByLabelText(/contraseña/i), "short");
|
|
56
|
+
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
|
|
57
57
|
|
|
58
58
|
// Assert
|
|
59
|
-
expect(await screen.findByText(/
|
|
59
|
+
expect(await screen.findByText(/al menos 8 caracteres/i)).toBeInTheDocument();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("submits with valid credentials and calls loginAction", async () => {
|
|
@@ -65,9 +65,9 @@ describe("LoginForm", () => {
|
|
|
65
65
|
render(<LoginForm />);
|
|
66
66
|
|
|
67
67
|
// Act
|
|
68
|
-
await user.type(screen.getByLabelText(/
|
|
69
|
-
await user.type(screen.getByLabelText(/
|
|
70
|
-
await user.click(screen.getByRole("button", { name: /
|
|
68
|
+
await user.type(screen.getByLabelText(/correo/i), "user@example.com");
|
|
69
|
+
await user.type(screen.getByLabelText(/contraseña/i), "password123");
|
|
70
|
+
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
|
|
71
71
|
|
|
72
72
|
// Assert
|
|
73
73
|
await waitFor(() => {
|
|
@@ -82,9 +82,9 @@ describe("LoginForm", () => {
|
|
|
82
82
|
render(<LoginForm />);
|
|
83
83
|
|
|
84
84
|
// Act
|
|
85
|
-
await user.type(screen.getByLabelText(/
|
|
86
|
-
await user.type(screen.getByLabelText(/
|
|
87
|
-
await user.click(screen.getByRole("button", { name: /
|
|
85
|
+
await user.type(screen.getByLabelText(/correo/i), "user@example.com");
|
|
86
|
+
await user.type(screen.getByLabelText(/contraseña/i), "password123");
|
|
87
|
+
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
|
|
88
88
|
|
|
89
89
|
// Assert
|
|
90
90
|
await waitFor(() => {
|
|
@@ -13,8 +13,8 @@ import { Input } from "@/shared/components/ui/input";
|
|
|
13
13
|
import { Label } from "@/shared/components/ui/label";
|
|
14
14
|
|
|
15
15
|
const schema = z.object({
|
|
16
|
-
email: z.string().email("
|
|
17
|
-
password: z.string().min(8, "
|
|
16
|
+
email: z.string().email("Correo electrónico inválido"),
|
|
17
|
+
password: z.string().min(8, "La contraseña debe tener al menos 8 caracteres"),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
type FormValues = z.infer<typeof schema>;
|
|
@@ -41,8 +41,8 @@ export function LoginForm() {
|
|
|
41
41
|
return (
|
|
42
42
|
<Card>
|
|
43
43
|
<CardHeader>
|
|
44
|
-
<CardTitle>
|
|
45
|
-
<CardDescription>
|
|
44
|
+
<CardTitle>Iniciar sesión</CardTitle>
|
|
45
|
+
<CardDescription>Ingresa tu correo y contraseña</CardDescription>
|
|
46
46
|
</CardHeader>
|
|
47
47
|
<CardContent>
|
|
48
48
|
<form onSubmit={(e) => { void onSubmit(e); }} data-testid="login-form" className="space-y-4">
|
|
@@ -53,7 +53,7 @@ export function LoginForm() {
|
|
|
53
53
|
)}
|
|
54
54
|
|
|
55
55
|
<div className="space-y-1.5">
|
|
56
|
-
<Label htmlFor="email">
|
|
56
|
+
<Label htmlFor="email">Correo electrónico</Label>
|
|
57
57
|
<Input
|
|
58
58
|
id="email"
|
|
59
59
|
type="email"
|
|
@@ -67,7 +67,7 @@ export function LoginForm() {
|
|
|
67
67
|
</div>
|
|
68
68
|
|
|
69
69
|
<div className="space-y-1.5">
|
|
70
|
-
<Label htmlFor="password">
|
|
70
|
+
<Label htmlFor="password">Contraseña</Label>
|
|
71
71
|
<Input
|
|
72
72
|
id="password"
|
|
73
73
|
type="password"
|
|
@@ -81,7 +81,7 @@ export function LoginForm() {
|
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
83
|
<Button type="submit" className="w-full" disabled={isPending}>
|
|
84
|
-
{isPending ? "
|
|
84
|
+
{isPending ? "Iniciando sesión…" : "Iniciar sesión"}
|
|
85
85
|
</Button>
|
|
86
86
|
</form>
|
|
87
87
|
</CardContent>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { AddTodoForm } from "../components/add-todo-form";
|
|
6
|
+
|
|
7
|
+
const mockAdd = vi.fn().mockResolvedValue(undefined);
|
|
8
|
+
|
|
9
|
+
vi.mock("../actions/todos.action", () => ({
|
|
10
|
+
addTodoAction: (...args: unknown[]) => mockAdd(...args),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("AddTodoForm", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renderiza el input y el botón Agregar", () => {
|
|
19
|
+
// Arrange + Act
|
|
20
|
+
render(<AddTodoForm />);
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(screen.getByRole("textbox", { name: /nueva tarea/i })).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByRole("button", { name: /agregar/i })).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("llama a addTodoAction con el título al enviar el formulario", async () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
render(<AddTodoForm />);
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
await user.type(screen.getByRole("textbox", { name: /nueva tarea/i }), "Mi tarea");
|
|
34
|
+
await user.click(screen.getByRole("button", { name: /agregar/i }));
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(mockAdd).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("limpia el input después de enviar", async () => {
|
|
43
|
+
// Arrange
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
render(<AddTodoForm />);
|
|
46
|
+
const input = screen.getByRole("textbox", { name: /nueva tarea/i });
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
await user.type(input, "Tarea temporal");
|
|
50
|
+
await user.click(screen.getByRole("button", { name: /agregar/i }));
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(input).toHaveValue("");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("no envía el formulario si el input está vacío (required)", async () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(<AddTodoForm />);
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
await user.click(screen.getByRole("button", { name: /agregar/i }));
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(mockAdd).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { TodoList } from "../components/todo-list";
|
|
6
|
+
|
|
7
|
+
const mockToggle = vi.fn().mockResolvedValue(undefined);
|
|
8
|
+
const mockDelete = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
|
|
10
|
+
vi.mock("../actions/todos.action", () => ({
|
|
11
|
+
toggleTodoAction: (...args: unknown[]) => mockToggle(...args),
|
|
12
|
+
deleteTodoAction: (...args: unknown[]) => mockDelete(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const baseTodo = {
|
|
16
|
+
id: "1",
|
|
17
|
+
userId: "u1",
|
|
18
|
+
title: "Comprar leche",
|
|
19
|
+
completed: false,
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("TodoList", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("muestra mensaje vacío cuando no hay tareas", () => {
|
|
29
|
+
// Arrange + Act
|
|
30
|
+
render(<TodoList items={[]} />);
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(screen.getByText(/no tienes tareas/i)).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renderiza cada tarea en la lista", () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const items = [
|
|
39
|
+
{ ...baseTodo, id: "1", title: "Tarea uno" },
|
|
40
|
+
{ ...baseTodo, id: "2", title: "Tarea dos" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
render(<TodoList items={items} />);
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(screen.getByText("Tarea uno")).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText("Tarea dos")).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("aplica line-through a tareas completadas", () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
const items = [{ ...baseTodo, completed: true }];
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
render(<TodoList items={items} />);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(screen.getByText("Comprar leche")).toHaveClass("line-through");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("llama a toggleTodoAction al cambiar el checkbox", async () => {
|
|
63
|
+
// Arrange
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(<TodoList items={[baseTodo]} />);
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
await user.click(screen.getByRole("checkbox"));
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(mockToggle).toHaveBeenCalledWith("1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("llama a deleteTodoAction al pulsar Eliminar", async () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(<TodoList items={[baseTodo]} />);
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
await user.click(screen.getByRole("button", { name: /eliminar/i }));
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(mockDelete).toHaveBeenCalledWith("1");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockInsert = vi.fn();
|
|
4
|
+
const mockUpdate = vi.fn();
|
|
5
|
+
const mockDelete = vi.fn();
|
|
6
|
+
const mockSelectWhere = vi.fn();
|
|
7
|
+
const mockRevalidatePath = vi.fn();
|
|
8
|
+
const mockGetUser = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("next/cache", () => ({
|
|
11
|
+
revalidatePath: (path: string) => mockRevalidatePath(path),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
15
|
+
createClient: vi.fn(() =>
|
|
16
|
+
Promise.resolve({
|
|
17
|
+
auth: { getUser: mockGetUser },
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@/shared/db", () => ({
|
|
23
|
+
db: {
|
|
24
|
+
insert: vi.fn(() => ({ values: mockInsert })),
|
|
25
|
+
update: vi.fn(() => ({ set: vi.fn(() => ({ where: mockUpdate })) })),
|
|
26
|
+
delete: vi.fn(() => ({ where: mockDelete })),
|
|
27
|
+
select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockSelectWhere })) })),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@/shared/db/schema", () => ({
|
|
32
|
+
todos: {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe("addTodoAction", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("no hace nada si el título está vacío", async () => {
|
|
41
|
+
// Arrange
|
|
42
|
+
const { addTodoAction } = await import("../actions/todos.action");
|
|
43
|
+
const fd = new FormData();
|
|
44
|
+
fd.set("title", " ");
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
await addTodoAction(fd);
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("no hace nada si no hay usuario autenticado", async () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
56
|
+
const { addTodoAction } = await import("../actions/todos.action");
|
|
57
|
+
const fd = new FormData();
|
|
58
|
+
fd.set("title", "Mi tarea");
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
await addTodoAction(fd);
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("inserta la tarea y llama a revalidatePath cuando el usuario existe", async () => {
|
|
68
|
+
// Arrange
|
|
69
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
70
|
+
mockInsert.mockResolvedValue(undefined);
|
|
71
|
+
const { addTodoAction } = await import("../actions/todos.action");
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.set("title", "Nueva tarea");
|
|
74
|
+
|
|
75
|
+
// Act
|
|
76
|
+
await addTodoAction(fd);
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(mockInsert).toHaveBeenCalledWith({ userId: "u1", title: "Nueva tarea" });
|
|
80
|
+
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("no hace nada si el campo title no está presente", async () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
const { addTodoAction } = await import("../actions/todos.action");
|
|
86
|
+
const fd = new FormData();
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
await addTodoAction(fd);
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("toggleTodoAction", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.clearAllMocks();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("no hace nada si la tarea no existe", async () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
mockSelectWhere.mockResolvedValue([]);
|
|
104
|
+
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
await toggleTodoAction("non-existent-id");
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(mockUpdate).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("invierte completed y llama a revalidatePath", async () => {
|
|
114
|
+
// Arrange
|
|
115
|
+
mockSelectWhere.mockResolvedValue([{ id: "1", completed: false }]);
|
|
116
|
+
mockUpdate.mockResolvedValue(undefined);
|
|
117
|
+
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
await toggleTodoAction("1");
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(mockUpdate).toHaveBeenCalled();
|
|
124
|
+
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("deleteTodoAction", () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
vi.clearAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("elimina la tarea y llama a revalidatePath", async () => {
|
|
134
|
+
// Arrange
|
|
135
|
+
mockDelete.mockResolvedValue(undefined);
|
|
136
|
+
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
137
|
+
|
|
138
|
+
// Act
|
|
139
|
+
await deleteTodoAction("1");
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
expect(mockDelete).toHaveBeenCalled();
|
|
143
|
+
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { db } from "@/shared/db";
|
|
6
|
+
import { todos } from "@/shared/db/schema";
|
|
7
|
+
import { createClient } from "@/shared/lib/supabase/server";
|
|
8
|
+
|
|
9
|
+
export async function addTodoAction(formData: FormData): Promise<void> {
|
|
10
|
+
const title = formData.get("title");
|
|
11
|
+
if (!title || typeof title !== "string" || title.trim() === "") return;
|
|
12
|
+
|
|
13
|
+
const supabase = await createClient();
|
|
14
|
+
const {
|
|
15
|
+
data: { user },
|
|
16
|
+
} = await supabase.auth.getUser();
|
|
17
|
+
if (!user) return;
|
|
18
|
+
|
|
19
|
+
await db.insert(todos).values({ userId: user.id, title: title.trim() });
|
|
20
|
+
revalidatePath("/");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function toggleTodoAction(id: string): Promise<void> {
|
|
24
|
+
const [todo] = await db.select().from(todos).where(eq(todos.id, id));
|
|
25
|
+
if (!todo) return;
|
|
26
|
+
|
|
27
|
+
await db.update(todos).set({ completed: !todo.completed }).where(eq(todos.id, id));
|
|
28
|
+
revalidatePath("/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function deleteTodoAction(id: string): Promise<void> {
|
|
32
|
+
await db.delete(todos).where(eq(todos.id, id));
|
|
33
|
+
revalidatePath("/");
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useTransition } from "react";
|
|
4
|
+
import { addTodoAction } from "../actions/todos.action";
|
|
5
|
+
|
|
6
|
+
export function AddTodoForm() {
|
|
7
|
+
const [isPending, startTransition] = useTransition();
|
|
8
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
9
|
+
|
|
10
|
+
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
const formData = new FormData(e.currentTarget);
|
|
13
|
+
startTransition(async () => {
|
|
14
|
+
await addTodoAction(formData);
|
|
15
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<form onSubmit={handleSubmit} className="flex gap-2" data-testid="add-todo-form">
|
|
21
|
+
<input
|
|
22
|
+
ref={inputRef}
|
|
23
|
+
name="title"
|
|
24
|
+
type="text"
|
|
25
|
+
placeholder="Nueva tarea…"
|
|
26
|
+
required
|
|
27
|
+
disabled={isPending}
|
|
28
|
+
className="flex-1 rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
|
29
|
+
aria-label="Nueva tarea"
|
|
30
|
+
/>
|
|
31
|
+
<button
|
|
32
|
+
type="submit"
|
|
33
|
+
disabled={isPending}
|
|
34
|
+
className="rounded bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50"
|
|
35
|
+
>
|
|
36
|
+
{isPending ? "Agregando…" : "Agregar"}
|
|
37
|
+
</button>
|
|
38
|
+
</form>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTransition } from "react";
|
|
4
|
+
import { toggleTodoAction, deleteTodoAction } from "../actions/todos.action";
|
|
5
|
+
import type { SelectTodo } from "@/shared/db/schema";
|
|
6
|
+
|
|
7
|
+
interface TodoListProps {
|
|
8
|
+
items: SelectTodo[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TodoList({ items }: TodoListProps) {
|
|
12
|
+
const [isPending, startTransition] = useTransition();
|
|
13
|
+
|
|
14
|
+
if (items.length === 0) {
|
|
15
|
+
return (
|
|
16
|
+
<p className="mt-4 text-center text-sm text-gray-500">
|
|
17
|
+
No tienes tareas. ¡Agrega una!
|
|
18
|
+
</p>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ul className="mt-4 space-y-2" data-testid="todo-list">
|
|
24
|
+
{items.map((todo) => (
|
|
25
|
+
<li
|
|
26
|
+
key={todo.id}
|
|
27
|
+
className="flex items-center justify-between rounded border p-3"
|
|
28
|
+
>
|
|
29
|
+
<label className="flex cursor-pointer items-center gap-3">
|
|
30
|
+
<input
|
|
31
|
+
type="checkbox"
|
|
32
|
+
checked={todo.completed}
|
|
33
|
+
disabled={isPending}
|
|
34
|
+
onChange={() => {
|
|
35
|
+
startTransition(() => toggleTodoAction(todo.id));
|
|
36
|
+
}}
|
|
37
|
+
aria-label={`Marcar "${todo.title}" como completada`}
|
|
38
|
+
/>
|
|
39
|
+
<span className={todo.completed ? "line-through text-gray-400" : ""}>
|
|
40
|
+
{todo.title}
|
|
41
|
+
</span>
|
|
42
|
+
</label>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
disabled={isPending}
|
|
46
|
+
onClick={() => {
|
|
47
|
+
startTransition(() => deleteTodoAction(todo.id));
|
|
48
|
+
}}
|
|
49
|
+
className="rounded px-2 py-1 text-sm text-red-500 hover:bg-red-50"
|
|
50
|
+
aria-label={`Eliminar "${todo.title}"`}
|
|
51
|
+
>
|
|
52
|
+
Eliminar
|
|
53
|
+
</button>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
1
|
+
import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
2
2
|
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
3
3
|
|
|
4
4
|
export const profiles = pgTable("profiles", {
|
|
@@ -13,3 +13,17 @@ export const selectProfileSchema = createSelectSchema(profiles);
|
|
|
13
13
|
|
|
14
14
|
export type InsertProfile = typeof profiles.$inferInsert;
|
|
15
15
|
export type SelectProfile = typeof profiles.$inferSelect;
|
|
16
|
+
|
|
17
|
+
export const todos = pgTable("todos", {
|
|
18
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
19
|
+
userId: uuid("user_id").notNull(),
|
|
20
|
+
title: text("title").notNull(),
|
|
21
|
+
completed: boolean("completed").notNull().default(false),
|
|
22
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const insertTodoSchema = createInsertSchema(todos);
|
|
26
|
+
export const selectTodoSchema = createSelectSchema(todos);
|
|
27
|
+
|
|
28
|
+
export type InsertTodo = typeof todos.$inferInsert;
|
|
29
|
+
export type SelectTodo = typeof todos.$inferSelect;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: AI SDK + Edge runtime rules. RISK 3 (cold starts).
|
|
3
|
-
globs: ["src/features/chat/**", "src/shared/lib/ai*"]
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# AI Rules (Risk 3: Vercel Cold Starts)
|
|
7
|
-
|
|
8
|
-
## Edge Runtime is Mandatory
|
|
9
|
-
```typescript
|
|
10
|
-
// ✅ ALL AI routes must export this
|
|
11
|
-
export const runtime = "edge";
|
|
12
|
-
|
|
13
|
-
// ❌ Never use Node.js runtime for AI routes (1-3s cold start)
|
|
14
|
-
// export const runtime = "nodejs"; // WRONG
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Never Mix Edge + DB
|
|
18
|
-
```typescript
|
|
19
|
-
// ❌ WRONG — DB + Edge in same route
|
|
20
|
-
export const runtime = "edge";
|
|
21
|
-
import { db } from "@/shared/db"; // WRONG — postgres driver needs Node.js
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
If you need DB data in an AI route:
|
|
25
|
-
1. Fetch DB data in a Server Component (Node.js)
|
|
26
|
-
2. Pass to client
|
|
27
|
-
3. Client sends to Edge AI route
|
|
28
|
-
|
|
29
|
-
## AI Model via AI Gateway
|
|
30
|
-
```typescript
|
|
31
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
32
|
-
|
|
33
|
-
const provider = createOpenAI({
|
|
34
|
-
baseURL: process.env.AI_GATEWAY_URL,
|
|
35
|
-
apiKey: process.env.AI_API_KEY,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
export const aiModel = provider(process.env.AI_MODEL ?? "minimax/minimax-m2.1");
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## Streaming Pattern
|
|
42
|
-
```typescript
|
|
43
|
-
import { streamText } from "ai";
|
|
44
|
-
import { aiModel } from "@/shared/lib/ai";
|
|
45
|
-
|
|
46
|
-
export const runtime = "edge";
|
|
47
|
-
|
|
48
|
-
export async function POST(req: Request) {
|
|
49
|
-
const { messages } = await req.json();
|
|
50
|
-
const result = streamText({ model: aiModel, messages });
|
|
51
|
-
return result.toDataStreamResponse();
|
|
52
|
-
}
|
|
53
|
-
```
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { streamText, type CoreMessage } from "ai";
|
|
2
|
-
|
|
3
|
-
import { aiModel } from "@/shared/lib/ai";
|
|
4
|
-
|
|
5
|
-
export const runtime = "edge";
|
|
6
|
-
|
|
7
|
-
export async function POST(req: Request) {
|
|
8
|
-
const { messages } = (await req.json()) as { messages: CoreMessage[] };
|
|
9
|
-
|
|
10
|
-
const result = streamText({
|
|
11
|
-
model: aiModel,
|
|
12
|
-
messages,
|
|
13
|
-
maxTokens: 2048,
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
return result.toDataStreamResponse({
|
|
17
|
-
getErrorMessage: (error) => {
|
|
18
|
-
console.error("[chat] stream error:", error);
|
|
19
|
-
if (error instanceof Error) return error.message;
|
|
20
|
-
if (typeof error === "object" && error !== null) return JSON.stringify(error);
|
|
21
|
-
return String(error);
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { ChatUi } from "../components/chat-ui";
|
|
5
|
-
|
|
6
|
-
const mockUseChat = vi.fn(() => ({
|
|
7
|
-
messages: [] as { id: string; role: string; content: string }[],
|
|
8
|
-
input: "",
|
|
9
|
-
handleInputChange: vi.fn(),
|
|
10
|
-
handleSubmit: vi.fn(),
|
|
11
|
-
status: "idle",
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
vi.mock("@ai-sdk/react", () => ({
|
|
15
|
-
useChat: (...args: unknown[]) => mockUseChat(...args),
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
describe("ChatUi", () => {
|
|
19
|
-
it("renders chat input", () => {
|
|
20
|
-
// Arrange + Act
|
|
21
|
-
render(<ChatUi />);
|
|
22
|
-
|
|
23
|
-
// Assert
|
|
24
|
-
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("renders send button", () => {
|
|
28
|
-
// Arrange + Act
|
|
29
|
-
render(<ChatUi />);
|
|
30
|
-
|
|
31
|
-
// Assert
|
|
32
|
-
expect(screen.getByRole("button", { name: /send/i })).toBeInTheDocument();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("renders messages list area", () => {
|
|
36
|
-
// Arrange + Act
|
|
37
|
-
render(<ChatUi />);
|
|
38
|
-
|
|
39
|
-
// Assert
|
|
40
|
-
expect(screen.getByTestId("chat-ui")).toBeInTheDocument();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("renders user and assistant messages", () => {
|
|
44
|
-
// Arrange
|
|
45
|
-
mockUseChat.mockReturnValueOnce({
|
|
46
|
-
messages: [
|
|
47
|
-
{ id: "1", role: "user", content: "Hello" },
|
|
48
|
-
{ id: "2", role: "assistant", content: "Hi there!" },
|
|
49
|
-
],
|
|
50
|
-
input: "",
|
|
51
|
-
handleInputChange: vi.fn(),
|
|
52
|
-
handleSubmit: vi.fn(),
|
|
53
|
-
status: "idle",
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Act
|
|
57
|
-
render(<ChatUi />);
|
|
58
|
-
|
|
59
|
-
// Assert
|
|
60
|
-
expect(screen.getByText("Hello")).toBeInTheDocument();
|
|
61
|
-
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("shows loading indicator when status is streaming", () => {
|
|
65
|
-
// Arrange
|
|
66
|
-
mockUseChat.mockReturnValueOnce({
|
|
67
|
-
messages: [],
|
|
68
|
-
input: "",
|
|
69
|
-
handleInputChange: vi.fn(),
|
|
70
|
-
handleSubmit: vi.fn(),
|
|
71
|
-
status: "streaming",
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// Act
|
|
75
|
-
render(<ChatUi />);
|
|
76
|
-
|
|
77
|
-
// Assert
|
|
78
|
-
expect(screen.getByText(/thinking/i)).toBeInTheDocument();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("shows loading indicator when status is submitted", () => {
|
|
82
|
-
// Arrange
|
|
83
|
-
mockUseChat.mockReturnValueOnce({
|
|
84
|
-
messages: [],
|
|
85
|
-
input: "",
|
|
86
|
-
handleInputChange: vi.fn(),
|
|
87
|
-
handleSubmit: vi.fn(),
|
|
88
|
-
status: "submitted",
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Act
|
|
92
|
-
render(<ChatUi />);
|
|
93
|
-
|
|
94
|
-
// Assert
|
|
95
|
-
expect(screen.getByText(/thinking/i)).toBeInTheDocument();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("strips <think> tags from message content", () => {
|
|
99
|
-
// Arrange
|
|
100
|
-
mockUseChat.mockReturnValueOnce({
|
|
101
|
-
messages: [
|
|
102
|
-
{
|
|
103
|
-
id: "1",
|
|
104
|
-
role: "assistant",
|
|
105
|
-
content: "<think>internal reasoning</think>Visible answer",
|
|
106
|
-
},
|
|
107
|
-
],
|
|
108
|
-
input: "",
|
|
109
|
-
handleInputChange: vi.fn(),
|
|
110
|
-
handleSubmit: vi.fn(),
|
|
111
|
-
status: "idle",
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Act
|
|
115
|
-
render(<ChatUi />);
|
|
116
|
-
|
|
117
|
-
// Assert
|
|
118
|
-
expect(screen.getByText("Visible answer")).toBeInTheDocument();
|
|
119
|
-
expect(screen.queryByText(/internal reasoning/)).not.toBeInTheDocument();
|
|
120
|
-
});
|
|
121
|
-
});
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
let capturedGetErrorMessage: ((error: unknown) => string) | undefined;
|
|
4
|
-
|
|
5
|
-
vi.mock("ai", () => ({
|
|
6
|
-
streamText: vi.fn(() => ({
|
|
7
|
-
toDataStreamResponse: vi.fn((opts?: { getErrorMessage?: (e: unknown) => string }) => {
|
|
8
|
-
capturedGetErrorMessage = opts?.getErrorMessage;
|
|
9
|
-
return new Response("data: done\n\n");
|
|
10
|
-
}),
|
|
11
|
-
})),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
vi.mock("@/shared/lib/ai", () => ({
|
|
15
|
-
aiModel: {},
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
describe("chat route", () => {
|
|
19
|
-
it("exports runtime as edge and POST handler", async () => {
|
|
20
|
-
// Arrange + Act
|
|
21
|
-
const mod = await import("../../../app/api/chat/route");
|
|
22
|
-
|
|
23
|
-
// Assert
|
|
24
|
-
expect(mod.runtime).toBe("edge");
|
|
25
|
-
expect(typeof mod.POST).toBe("function");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("calls streamText and returns a response", async () => {
|
|
29
|
-
// Arrange
|
|
30
|
-
const mod = await import("../../../app/api/chat/route");
|
|
31
|
-
const req = new Request("http://localhost/api/chat", {
|
|
32
|
-
method: "POST",
|
|
33
|
-
body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// Act
|
|
37
|
-
const response = await mod.POST(req);
|
|
38
|
-
|
|
39
|
-
// Assert
|
|
40
|
-
expect(response).toBeInstanceOf(Response);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("getErrorMessage returns error.message for Error instances", async () => {
|
|
44
|
-
// Arrange
|
|
45
|
-
const mod = await import("../../../app/api/chat/route");
|
|
46
|
-
const req = new Request("http://localhost/api/chat", {
|
|
47
|
-
method: "POST",
|
|
48
|
-
body: JSON.stringify({ messages: [] }),
|
|
49
|
-
});
|
|
50
|
-
await mod.POST(req);
|
|
51
|
-
|
|
52
|
-
// Act + Assert
|
|
53
|
-
expect(capturedGetErrorMessage).toBeDefined();
|
|
54
|
-
expect(capturedGetErrorMessage!(new Error("boom"))).toBe("boom");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("getErrorMessage returns JSON for plain objects", async () => {
|
|
58
|
-
// Arrange
|
|
59
|
-
const mod = await import("../../../app/api/chat/route");
|
|
60
|
-
const req = new Request("http://localhost/api/chat", {
|
|
61
|
-
method: "POST",
|
|
62
|
-
body: JSON.stringify({ messages: [] }),
|
|
63
|
-
});
|
|
64
|
-
await mod.POST(req);
|
|
65
|
-
|
|
66
|
-
// Act + Assert
|
|
67
|
-
expect(capturedGetErrorMessage!({ code: 42 })).toBe('{"code":42}');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("getErrorMessage returns String() for primitives", async () => {
|
|
71
|
-
// Arrange
|
|
72
|
-
const mod = await import("../../../app/api/chat/route");
|
|
73
|
-
const req = new Request("http://localhost/api/chat", {
|
|
74
|
-
method: "POST",
|
|
75
|
-
body: JSON.stringify({ messages: [] }),
|
|
76
|
-
});
|
|
77
|
-
await mod.POST(req);
|
|
78
|
-
|
|
79
|
-
// Act + Assert
|
|
80
|
-
expect(capturedGetErrorMessage!("plain string")).toBe("plain string");
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("@ai-sdk/react", () => ({
|
|
4
|
-
useChat: vi.fn(() => ({ messages: [], input: "" })),
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
describe("use-chat re-export", () => {
|
|
8
|
-
it("re-exports useChat from @ai-sdk/react", async () => {
|
|
9
|
-
// Arrange + Act
|
|
10
|
-
const mod = await import("../hooks/use-chat");
|
|
11
|
-
|
|
12
|
-
// Assert
|
|
13
|
-
expect(typeof mod.useChat).toBe("function");
|
|
14
|
-
});
|
|
15
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useChat } from "@ai-sdk/react";
|
|
4
|
-
|
|
5
|
-
export function ChatUi() {
|
|
6
|
-
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
|
|
7
|
-
api: "/api/chat",
|
|
8
|
-
});
|
|
9
|
-
const isLoading = status === "streaming" || status === "submitted";
|
|
10
|
-
|
|
11
|
-
return (
|
|
12
|
-
<div className="flex flex-col gap-4" data-testid="chat-ui">
|
|
13
|
-
<div className="h-96 overflow-y-auto rounded border p-4 space-y-2">
|
|
14
|
-
{messages.map((message) => (
|
|
15
|
-
<div
|
|
16
|
-
key={message.id}
|
|
17
|
-
className={`rounded p-2 ${
|
|
18
|
-
message.role === "user" ? "bg-primary/10 ml-8" : "bg-muted mr-8"
|
|
19
|
-
}`}
|
|
20
|
-
>
|
|
21
|
-
<p className="text-sm">{message.content.replace(/<think>[\s\S]*?<\/think>\s*/g, "")}</p>
|
|
22
|
-
</div>
|
|
23
|
-
))}
|
|
24
|
-
{isLoading && (
|
|
25
|
-
<div className="bg-muted mr-8 rounded p-2">
|
|
26
|
-
<p className="text-sm text-muted-foreground">Thinking...</p>
|
|
27
|
-
</div>
|
|
28
|
-
)}
|
|
29
|
-
</div>
|
|
30
|
-
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
31
|
-
<input
|
|
32
|
-
value={input}
|
|
33
|
-
onChange={handleInputChange}
|
|
34
|
-
placeholder="Ask anything..."
|
|
35
|
-
className="flex-1 rounded border px-3 py-2"
|
|
36
|
-
data-testid="chat-input"
|
|
37
|
-
/>
|
|
38
|
-
<button
|
|
39
|
-
type="submit"
|
|
40
|
-
disabled={isLoading || !input.trim()}
|
|
41
|
-
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
42
|
-
>
|
|
43
|
-
Send
|
|
44
|
-
</button>
|
|
45
|
-
</form>
|
|
46
|
-
</div>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { useChat } from "@ai-sdk/react";
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
2
|
-
|
|
3
|
-
const provider = createOpenAI({
|
|
4
|
-
baseURL: process.env.AI_GATEWAY_URL ?? "https://gateway.ai.vercel.app/v1",
|
|
5
|
-
apiKey: process.env.AI_API_KEY ?? "",
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
export const aiModel = provider(process.env.AI_MODEL ?? "minimax/minimax-m2.1");
|