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.
Files changed (26) hide show
  1. package/dist/index.js +7 -8
  2. package/package.json +1 -1
  3. package/template/_env.example +0 -7
  4. package/template/package.json.tmpl +0 -3
  5. package/template/src/app/(protected)/page.tsx +16 -7
  6. package/template/src/app/__tests__/protected-page.test.tsx +23 -6
  7. package/template/src/e2e/todos.spec.ts +14 -0
  8. package/template/src/features/auth/__tests__/login-form.test.tsx +15 -15
  9. package/template/src/features/auth/components/login-form.tsx +7 -7
  10. package/template/src/features/todos/__tests__/add-todo-form.test.tsx +69 -0
  11. package/template/src/features/todos/__tests__/todo-list.test.tsx +85 -0
  12. package/template/src/features/todos/__tests__/todos.action.test.ts +145 -0
  13. package/template/src/features/todos/actions/todos.action.ts +34 -0
  14. package/template/src/features/todos/components/add-todo-form.tsx +40 -0
  15. package/template/src/features/todos/components/todo-list.tsx +58 -0
  16. package/template/src/shared/db/schema.ts +15 -1
  17. package/template/.cursor/rules/ai.mdc +0 -53
  18. package/template/src/app/api/chat/route.ts +0 -24
  19. package/template/src/e2e/chat.spec.ts +0 -8
  20. package/template/src/features/chat/__tests__/chat-ui.test.tsx +0 -121
  21. package/template/src/features/chat/__tests__/route.test.ts +0 -82
  22. package/template/src/features/chat/__tests__/use-chat.test.ts +0 -15
  23. package/template/src/features/chat/components/chat-ui.tsx +0 -48
  24. package/template/src/features/chat/hooks/use-chat.ts +0 -1
  25. package/template/src/shared/__tests__/ai.test.ts +0 -9
  26. 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(`Project "${projectName}" created!`));
154
+ success(pc2.bold(`\xA1Proyecto "${projectName}" creado!`));
155
155
  console.log(`
156
- ${pc2.dim("Next steps:")}`);
156
+ ${pc2.dim("Siguientes pasos:")}`);
157
157
  console.log(` ${pc2.cyan(`cd ${projectName}`)}`);
158
- console.log(` ${pc2.yellow("Edit .env.local and fill in your API keys:")}`);
159
- console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_URL")} \u2014 from supabase.com > Project Settings > API`);
160
- console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_ANON_KEY")} \u2014 from supabase.com > Project Settings > API`);
161
- console.log(` ${pc2.dim("DATABASE_URL")} \u2014 from supabase.com > Project Settings > Database`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextjs-hackathon-stack",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Scaffold a full-stack Next.js hackathon starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { ChatUi } from "@/features/chat/components/chat-ui";
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-4 flex items-center justify-between">
14
- <h1 className="text-2xl font-bold">Welcome, {user?.email}</h1>
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 type="submit" className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300">
17
- Log out
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
- <ChatUi />
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("@/features/chat/components/chat-ui", () => ({
14
- ChatUi: () => <div data-testid="chat-ui" />,
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("renders welcome message with user email", async () => {
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("renders with null user gracefully", async () => {
46
+ it("renderiza correctamente con lista de tareas vacía", async () => {
31
47
  // Arrange
32
- mockGetUser.mockResolvedValue({ data: { user: null } });
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(/email/i)).toBeInTheDocument();
25
- expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
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: /sign in/i })).toBeInTheDocument();
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: /sign in/i }));
42
+ await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
43
43
 
44
44
  // Assert
45
- expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
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(/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 }));
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(/at least 8 characters/i)).toBeInTheDocument();
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(/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 }));
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(/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 }));
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("Invalid email"),
17
- password: z.string().min(8, "Password must be at least 8 characters"),
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>Sign in</CardTitle>
45
- <CardDescription>Enter your email and password below</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">Email</Label>
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">Password</Label>
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 ? "Signing in…" : "Sign in"}
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,8 +0,0 @@
1
- import { test, expect } from "@playwright/test";
2
-
3
- test.describe("Chat feature", () => {
4
- test("login page is accessible", async ({ page }) => {
5
- await page.goto("/login");
6
- await expect(page.getByTestId("login-form")).toBeVisible();
7
- });
8
- });
@@ -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,9 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- import { aiModel } from "../lib/ai";
4
-
5
- describe("aiModel", () => {
6
- it("exports aiModel", () => {
7
- expect(aiModel).toBeDefined();
8
- });
9
- });
@@ -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");