nextjs-hackathon-stack 0.1.23 → 0.1.24
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/package.json +1 -1
- package/template/eslint.config.ts +5 -1
- package/template/middleware.ts +2 -1
- package/template/src/app/(protected)/page.tsx +8 -5
- package/template/src/app/__tests__/protected-layout.test.tsx +1 -1
- package/template/src/app/__tests__/protected-page.test.tsx +21 -1
- package/template/src/features/auth/__tests__/login-form.test.tsx +1 -1
- package/template/src/features/auth/__tests__/login.action.test.ts +1 -1
- package/template/src/features/auth/__tests__/logout.action.test.ts +1 -1
- package/template/src/features/auth/actions/login.action.ts +1 -1
- package/template/src/features/auth/components/login-form.tsx +1 -1
- package/template/src/features/todos/__tests__/add-todo-form.test.tsx +1 -1
- package/template/src/features/todos/__tests__/todo-list.test.tsx +2 -2
- package/template/src/features/todos/__tests__/todos.action.test.ts +1 -1
- package/template/src/features/todos/actions/todos.action.ts +2 -1
- package/template/src/features/todos/components/add-todo-form.tsx +3 -1
- package/template/src/features/todos/components/todo-list.tsx +6 -4
- package/template/src/shared/__tests__/middleware.test.ts +7 -7
- package/template/src/shared/__tests__/supabase-middleware.test.ts +19 -14
- package/template/src/shared/__tests__/supabase-server.test.ts +10 -6
- package/template/src/shared/__tests__/utils.test.ts +5 -2
- package/template/src/shared/components/ui/button.tsx +2 -1
- package/template/src/shared/components/ui/spinner.tsx +1 -0
package/package.json
CHANGED
|
@@ -61,6 +61,10 @@ export default tseslint.config(
|
|
|
61
61
|
},
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
|
-
|
|
64
|
+
files: ["**/error.tsx"],
|
|
65
|
+
rules: { "no-console": "off" },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
ignores: [".next/**", "node_modules/**", "dist/**", "eslint.config.ts", "next-env.d.ts", "next.config.ts", "playwright.config.ts", "drizzle.config.ts", "vitest.config.ts", "postcss.config.mjs"],
|
|
65
69
|
}
|
|
66
70
|
);
|
package/template/middleware.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
|
|
3
4
|
import { logoutAction } from "@/features/auth/actions/logout.action";
|
|
4
|
-
import {
|
|
5
|
+
import { AddTodoForm } from "@/features/todos/components/add-todo-form";
|
|
6
|
+
import { TodoList } from "@/features/todos/components/todo-list";
|
|
5
7
|
import { db } from "@/shared/db";
|
|
6
8
|
import { todos } from "@/shared/db/schema";
|
|
7
|
-
import {
|
|
9
|
+
import { createClient } from "@/shared/lib/supabase/server";
|
|
8
10
|
|
|
9
11
|
export default async function HomePage() {
|
|
10
12
|
const supabase = await createClient();
|
|
11
13
|
const {
|
|
12
14
|
data: { user },
|
|
13
15
|
} = await supabase.auth.getUser();
|
|
14
|
-
|
|
16
|
+
if (!user) redirect("/login");
|
|
17
|
+
const items = await db.select().from(todos).where(eq(todos.userId, user.id));
|
|
15
18
|
|
|
16
19
|
return (
|
|
17
20
|
<main className="container mx-auto max-w-2xl p-8">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mockGetUser = vi.fn();
|
|
4
4
|
const mockSelect = vi.fn();
|
|
5
|
+
const mockRedirect = vi.fn();
|
|
5
6
|
|
|
6
7
|
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
7
8
|
createClient: vi.fn(() =>
|
|
@@ -29,7 +30,26 @@ vi.mock("@/features/todos/components/add-todo-form", () => ({
|
|
|
29
30
|
AddTodoForm: () => <div data-testid="add-todo-form" />,
|
|
30
31
|
}));
|
|
31
32
|
|
|
33
|
+
vi.mock("next/navigation", () => ({
|
|
34
|
+
redirect: (url: string): unknown => mockRedirect(url),
|
|
35
|
+
}));
|
|
36
|
+
|
|
32
37
|
describe("HomePage (protected)", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
mockRedirect.mockImplementation(() => { throw new Error("NEXT_REDIRECT"); });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("redirects to /login when user is not authenticated", async () => {
|
|
44
|
+
// Arrange
|
|
45
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
46
|
+
const { default: HomePage } = await import("../(protected)/page");
|
|
47
|
+
|
|
48
|
+
// Act + Assert
|
|
49
|
+
await expect(HomePage()).rejects.toThrow("NEXT_REDIRECT");
|
|
50
|
+
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
|
51
|
+
});
|
|
52
|
+
|
|
33
53
|
it("renderiza la página con las tareas del usuario", async () => {
|
|
34
54
|
// Arrange
|
|
35
55
|
mockGetUser.mockResolvedValue({ data: { user: { id: "u1", email: "user@example.com" } } });
|
|
@@ -7,7 +7,7 @@ import { LoginForm } from "../components/login-form";
|
|
|
7
7
|
const mockLoginAction = vi.fn().mockResolvedValue({ status: "idle" });
|
|
8
8
|
|
|
9
9
|
vi.mock("../actions/login.action", () => ({
|
|
10
|
-
loginAction: (...args: unknown[]) => mockLoginAction(...args),
|
|
10
|
+
loginAction: (...args: unknown[]): unknown => mockLoginAction(...args),
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
13
|
describe("LoginForm", () => {
|
|
@@ -25,7 +25,7 @@ export async function loginAction(
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
if (!parsed.success) {
|
|
28
|
-
return { status: "error", message: parsed.error.issues[0]
|
|
28
|
+
return { status: "error", message: (parsed.error.issues[0] as { message: string }).message };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const supabase = await createClient();
|
|
@@ -7,7 +7,7 @@ import { AddTodoForm } from "../components/add-todo-form";
|
|
|
7
7
|
const mockAdd = vi.fn().mockResolvedValue(undefined);
|
|
8
8
|
|
|
9
9
|
vi.mock("../actions/todos.action", () => ({
|
|
10
|
-
addTodoAction: (...args: unknown[]) => mockAdd(...args),
|
|
10
|
+
addTodoAction: (...args: unknown[]): unknown => mockAdd(...args),
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
13
|
describe("AddTodoForm", () => {
|
|
@@ -8,8 +8,8 @@ const mockToggle = vi.fn().mockResolvedValue(undefined);
|
|
|
8
8
|
const mockDelete = vi.fn().mockResolvedValue(undefined);
|
|
9
9
|
|
|
10
10
|
vi.mock("../actions/todos.action", () => ({
|
|
11
|
-
toggleTodoAction: (...args: unknown[]) => mockToggle(...args),
|
|
12
|
-
deleteTodoAction: (...args: unknown[]) => mockDelete(...args),
|
|
11
|
+
toggleTodoAction: (...args: unknown[]): unknown => mockToggle(...args),
|
|
12
|
+
deleteTodoAction: (...args: unknown[]): unknown => mockDelete(...args),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
const baseTodo = {
|
|
@@ -8,7 +8,7 @@ const mockRevalidatePath = vi.fn();
|
|
|
8
8
|
const mockGetUser = vi.fn();
|
|
9
9
|
|
|
10
10
|
vi.mock("next/cache", () => ({
|
|
11
|
-
revalidatePath: (path: string) => mockRevalidatePath(path),
|
|
11
|
+
revalidatePath: (path: string): unknown => mockRevalidatePath(path),
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
14
|
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
|
-
import { revalidatePath } from "next/cache";
|
|
4
3
|
import { eq } from "drizzle-orm";
|
|
4
|
+
import { revalidatePath } from "next/cache";
|
|
5
|
+
|
|
5
6
|
import { db } from "@/shared/db";
|
|
6
7
|
import { todos } from "@/shared/db/schema";
|
|
7
8
|
import { createClient } from "@/shared/lib/supabase/server";
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useRef, useTransition } from "react";
|
|
4
|
+
|
|
4
5
|
import { addTodoAction } from "../actions/todos.action";
|
|
6
|
+
|
|
5
7
|
import { Button } from "@/shared/components/ui/button";
|
|
6
8
|
|
|
7
9
|
export function AddTodoForm() {
|
|
8
10
|
const [isPending, startTransition] = useTransition();
|
|
9
11
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
10
12
|
|
|
11
|
-
function handleSubmit(e: React.
|
|
13
|
+
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
|
12
14
|
e.preventDefault();
|
|
13
15
|
const formData = new FormData(e.currentTarget);
|
|
14
16
|
startTransition(async () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTransition } from "react";
|
|
4
|
-
|
|
5
|
-
import
|
|
4
|
+
|
|
5
|
+
import { deleteTodoAction, toggleTodoAction } from "../actions/todos.action";
|
|
6
|
+
|
|
6
7
|
import { Spinner } from "@/shared/components/ui/spinner";
|
|
8
|
+
import type { SelectTodo } from "@/shared/db/schema";
|
|
7
9
|
|
|
8
10
|
interface TodoListProps {
|
|
9
11
|
items: SelectTodo[];
|
|
@@ -33,7 +35,7 @@ export function TodoList({ items }: TodoListProps) {
|
|
|
33
35
|
checked={todo.completed}
|
|
34
36
|
disabled={isPending}
|
|
35
37
|
onChange={() => {
|
|
36
|
-
startTransition(() => toggleTodoAction(todo.id));
|
|
38
|
+
startTransition(() => { void toggleTodoAction(todo.id); });
|
|
37
39
|
}}
|
|
38
40
|
aria-label={`Marcar "${todo.title}" como completada`}
|
|
39
41
|
/>
|
|
@@ -46,7 +48,7 @@ export function TodoList({ items }: TodoListProps) {
|
|
|
46
48
|
type="button"
|
|
47
49
|
disabled={isPending}
|
|
48
50
|
onClick={() => {
|
|
49
|
-
startTransition(() => deleteTodoAction(todo.id));
|
|
51
|
+
startTransition(() => { void deleteTodoAction(todo.id); });
|
|
50
52
|
}}
|
|
51
53
|
className="rounded px-2 py-1 text-sm text-red-500 hover:bg-red-50"
|
|
52
54
|
aria-label={`Eliminar "${todo.title}"`}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
1
|
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
const mockUpdateSession = vi.fn();
|
|
5
5
|
|
|
6
6
|
vi.mock("@/shared/lib/supabase/middleware", () => ({
|
|
7
|
-
updateSession: (...args: unknown[]) => mockUpdateSession(...args),
|
|
7
|
+
updateSession: (...args: unknown[]): unknown => mockUpdateSession(...args),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
10
|
describe("middleware", () => {
|
|
@@ -12,11 +12,11 @@ describe("middleware", () => {
|
|
|
12
12
|
// Arrange
|
|
13
13
|
const mockResponse = new Response(null, { status: 200 });
|
|
14
14
|
mockUpdateSession.mockResolvedValue(mockResponse);
|
|
15
|
-
const
|
|
15
|
+
const mod = await import("../../../middleware") as { middleware: (req: NextRequest) => Promise<Response> };
|
|
16
16
|
const req = new NextRequest("http://localhost/dashboard");
|
|
17
17
|
|
|
18
18
|
// Act
|
|
19
|
-
const response = await middleware(req);
|
|
19
|
+
const response = await mod.middleware(req);
|
|
20
20
|
|
|
21
21
|
// Assert
|
|
22
22
|
expect(mockUpdateSession).toHaveBeenCalledWith(req);
|
|
@@ -25,10 +25,10 @@ describe("middleware", () => {
|
|
|
25
25
|
|
|
26
26
|
it("exports a matcher config", async () => {
|
|
27
27
|
// Arrange + Act
|
|
28
|
-
const
|
|
28
|
+
const mod = await import("../../../middleware") as { config: { matcher: string[] } };
|
|
29
29
|
|
|
30
30
|
// Assert
|
|
31
|
-
expect(config.matcher).toBeDefined();
|
|
32
|
-
expect(Array.isArray(config.matcher)).toBe(true);
|
|
31
|
+
expect(mod.config.matcher).toBeDefined();
|
|
32
|
+
expect(Array.isArray(mod.config.matcher)).toBe(true);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
@@ -1,31 +1,34 @@
|
|
|
1
|
+
import type * as NextServerTypes from "next/server";
|
|
1
2
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
3
|
|
|
3
4
|
const mockGetUser = vi.fn();
|
|
4
5
|
const mockCreateServerClient = vi.fn();
|
|
5
6
|
const mockNextResponseNext = vi.fn();
|
|
6
7
|
const mockNextResponseRedirect = vi.fn();
|
|
8
|
+
const mockRequestGetAll = vi.fn(() => []);
|
|
9
|
+
const mockRequestSet = vi.fn();
|
|
7
10
|
|
|
8
11
|
vi.mock("@supabase/ssr", () => ({
|
|
9
|
-
createServerClient: (...args: unknown[]) => mockCreateServerClient(...args),
|
|
12
|
+
createServerClient: (...args: unknown[]): unknown => mockCreateServerClient(...args),
|
|
10
13
|
}));
|
|
11
14
|
|
|
12
15
|
vi.mock("next/server", async () => {
|
|
13
|
-
const actual = await vi.importActual<typeof
|
|
16
|
+
const actual = await vi.importActual<typeof NextServerTypes>("next/server");
|
|
14
17
|
return {
|
|
15
18
|
...actual,
|
|
16
19
|
NextResponse: {
|
|
17
|
-
next: (...args: unknown[]) => mockNextResponseNext(...args),
|
|
18
|
-
redirect: (...args: unknown[]) => mockNextResponseRedirect(...args),
|
|
20
|
+
next: (...args: unknown[]): unknown => mockNextResponseNext(...args),
|
|
21
|
+
redirect: (...args: unknown[]): unknown => mockNextResponseRedirect(...args),
|
|
19
22
|
},
|
|
20
23
|
};
|
|
21
24
|
});
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
interface CookieOptions { path?: string; maxAge?: number }
|
|
27
|
+
interface CookieItem { name: string; value: string; options: CookieOptions }
|
|
28
|
+
interface CookieCallbacks {
|
|
26
29
|
getAll: () => CookieItem[];
|
|
27
30
|
setAll: (cookies: CookieItem[]) => void;
|
|
28
|
-
}
|
|
31
|
+
}
|
|
29
32
|
|
|
30
33
|
function makeRequest(pathname: string) {
|
|
31
34
|
return {
|
|
@@ -33,10 +36,10 @@ function makeRequest(pathname: string) {
|
|
|
33
36
|
pathname,
|
|
34
37
|
clone: () => ({ pathname, toString: () => `http://localhost${pathname}` }),
|
|
35
38
|
},
|
|
36
|
-
cookies: { getAll:
|
|
39
|
+
cookies: { getAll: mockRequestGetAll, set: mockRequestSet },
|
|
37
40
|
headers: new Headers(),
|
|
38
41
|
url: `http://localhost${pathname}`,
|
|
39
|
-
} as unknown as
|
|
42
|
+
} as unknown as NextServerTypes.NextRequest;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
describe("supabase middleware (updateSession)", () => {
|
|
@@ -85,10 +88,11 @@ describe("supabase middleware (updateSession)", () => {
|
|
|
85
88
|
await updateSession(req);
|
|
86
89
|
|
|
87
90
|
// Act
|
|
88
|
-
capturedCookieCallbacks
|
|
91
|
+
if (!capturedCookieCallbacks) throw new Error("capturedCookieCallbacks not set");
|
|
92
|
+
capturedCookieCallbacks.getAll();
|
|
89
93
|
|
|
90
94
|
// Assert
|
|
91
|
-
expect(
|
|
95
|
+
expect(mockRequestGetAll).toHaveBeenCalled();
|
|
92
96
|
});
|
|
93
97
|
|
|
94
98
|
it("invokes setAll cookie callback and updates request + response cookies", async () => {
|
|
@@ -99,12 +103,13 @@ describe("supabase middleware (updateSession)", () => {
|
|
|
99
103
|
await updateSession(req);
|
|
100
104
|
|
|
101
105
|
// Act
|
|
102
|
-
capturedCookieCallbacks
|
|
106
|
+
if (!capturedCookieCallbacks) throw new Error("capturedCookieCallbacks not set");
|
|
107
|
+
capturedCookieCallbacks.setAll([
|
|
103
108
|
{ name: "sb-token", value: "abc", options: { path: "/" } },
|
|
104
109
|
]);
|
|
105
110
|
|
|
106
111
|
// Assert
|
|
107
|
-
expect(
|
|
112
|
+
expect(mockRequestSet).toHaveBeenCalledWith("sb-token", "abc");
|
|
108
113
|
expect(fakeResponse.cookies.set).toHaveBeenCalledWith("sb-token", "abc", { path: "/" });
|
|
109
114
|
});
|
|
110
115
|
|
|
@@ -5,7 +5,7 @@ const mockGetAll = vi.fn().mockReturnValue([]);
|
|
|
5
5
|
const mockCreateServerClient = vi.fn();
|
|
6
6
|
|
|
7
7
|
vi.mock("@supabase/ssr", () => ({
|
|
8
|
-
createServerClient: (...args: unknown[]) => mockCreateServerClient(...args),
|
|
8
|
+
createServerClient: (...args: unknown[]): unknown => mockCreateServerClient(...args),
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
11
|
vi.mock("next/headers", () => ({
|
|
@@ -43,7 +43,8 @@ describe("supabase server", () => {
|
|
|
43
43
|
|
|
44
44
|
// Act
|
|
45
45
|
await createClient();
|
|
46
|
-
capturedGetAll
|
|
46
|
+
if (!capturedGetAll) throw new Error("capturedGetAll not set");
|
|
47
|
+
capturedGetAll();
|
|
47
48
|
|
|
48
49
|
// Assert
|
|
49
50
|
expect(mockGetAll).toHaveBeenCalled();
|
|
@@ -64,7 +65,8 @@ describe("supabase server", () => {
|
|
|
64
65
|
await createClient();
|
|
65
66
|
|
|
66
67
|
// Act
|
|
67
|
-
capturedSetAll
|
|
68
|
+
if (!capturedSetAll) throw new Error("capturedSetAll not set");
|
|
69
|
+
capturedSetAll([{ name: "token", value: "abc", options: { path: "/" } }]);
|
|
68
70
|
|
|
69
71
|
// Assert
|
|
70
72
|
expect(mockSet).toHaveBeenCalledWith("token", "abc", { path: "/" });
|
|
@@ -88,8 +90,10 @@ describe("supabase server", () => {
|
|
|
88
90
|
await createClient();
|
|
89
91
|
|
|
90
92
|
// Act + Assert
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if (!capturedSetAll) throw new Error("capturedSetAll not set");
|
|
94
|
+
const setAll = capturedSetAll;
|
|
95
|
+
expect(() => {
|
|
96
|
+
setAll([{ name: "token", value: "abc", options: {} }]);
|
|
97
|
+
}).not.toThrow();
|
|
94
98
|
});
|
|
95
99
|
});
|
|
@@ -9,8 +9,11 @@ describe("cn", () => {
|
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it("omits falsy conditional classes", () => {
|
|
12
|
-
// Arrange
|
|
13
|
-
|
|
12
|
+
// Arrange
|
|
13
|
+
const falsy = false as boolean;
|
|
14
|
+
|
|
15
|
+
// Act + Assert
|
|
16
|
+
expect(cn("foo", falsy && "bar", "baz")).toBe("foo baz");
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
it("handles undefined values", () => {
|
|
@@ -2,9 +2,10 @@ import { Slot } from "@radix-ui/react-slot"
|
|
|
2
2
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
|
-
import { cn } from "@/shared/lib/utils"
|
|
6
5
|
import { Spinner } from "./spinner"
|
|
7
6
|
|
|
7
|
+
import { cn } from "@/shared/lib/utils"
|
|
8
|
+
|
|
8
9
|
const buttonVariants = cva(
|
|
9
10
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
10
11
|
{
|