nextjs-hackathon-stack 0.1.30 → 0.1.32
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 +6 -3
- package/package.json +1 -1
- package/template/.cursor/agents/backend.md +10 -59
- package/template/.cursor/agents/business-intelligence.md +43 -15
- package/template/.cursor/agents/code-reviewer.md +2 -2
- package/template/.cursor/agents/frontend.md +6 -10
- package/template/.cursor/agents/security-researcher.md +8 -1
- package/template/.cursor/agents/technical-lead.md +47 -18
- package/template/.cursor/agents/test-qa.md +9 -49
- package/template/.cursor/rules/architecture.mdc +5 -2
- package/template/.cursor/rules/coding-standards.mdc +53 -3
- package/template/.cursor/rules/components.mdc +7 -0
- package/template/.cursor/rules/data-fetching.mdc +56 -5
- package/template/.cursor/rules/forms.mdc +4 -0
- package/template/.cursor/rules/general.mdc +8 -5
- package/template/.cursor/rules/nextjs.mdc +27 -3
- package/template/.cursor/rules/security.mdc +56 -3
- package/template/.cursor/rules/supabase.mdc +19 -3
- package/template/.cursor/rules/testing.mdc +21 -4
- package/template/.cursor/skills/create-feature/SKILL.md +29 -8
- package/template/.cursor/skills/create-feature/references/server-action-test-template.md +51 -0
- package/template/.cursor/skills/review-branch/SKILL.md +2 -22
- package/template/.cursor/skills/review-branch/references/review-checklist.md +36 -0
- package/template/.cursor/skills/security-audit/SKILL.md +8 -39
- package/template/.cursor/skills/security-audit/references/audit-steps.md +41 -0
- package/template/CLAUDE.md +43 -10
- package/template/eslint.config.ts +7 -1
- package/template/next.config.ts +5 -1
- package/template/src/app/(protected)/page.tsx +2 -4
- package/template/src/app/__tests__/auth-callback.test.ts +14 -0
- package/template/src/app/__tests__/protected-page.test.tsx +11 -13
- package/template/src/app/api/auth/callback/route.ts +2 -1
- package/template/src/e2e/login.spec.ts +4 -4
- package/template/src/features/todos/__tests__/todos.action.test.ts +78 -44
- package/template/src/features/todos/__tests__/todos.queries.test.ts +101 -0
- package/template/src/features/todos/actions/todos.action.ts +41 -27
- package/template/src/features/todos/queries/todos.queries.ts +16 -0
- package/template/src/shared/lib/supabase/middleware.ts +0 -1
- package/template/src/shared/lib/supabase/server.ts +0 -1
- package/template/tailwind.css +14 -14
|
@@ -69,4 +69,18 @@ describe("auth callback route", () => {
|
|
|
69
69
|
expect(response.status).toBe(307);
|
|
70
70
|
expect(response.headers.get("location")).toContain("/dashboard");
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it("ignores protocol-relative next param to prevent open redirect", async () => {
|
|
74
|
+
// Arrange
|
|
75
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
|
|
76
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
77
|
+
const req = new Request("http://localhost/api/auth/callback?code=valid-code&next=//evil.com");
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const response = await GET(req);
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(response.status).toBe(307);
|
|
84
|
+
expect(response.headers.get("location")).toBe("http://localhost/");
|
|
85
|
+
});
|
|
72
86
|
});
|
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mockGetUser = vi.fn();
|
|
4
|
-
const
|
|
4
|
+
const mockFrom = vi.fn();
|
|
5
5
|
const mockRedirect = vi.fn();
|
|
6
6
|
|
|
7
7
|
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
8
8
|
createClient: vi.fn(() =>
|
|
9
9
|
Promise.resolve({
|
|
10
10
|
auth: { getUser: mockGetUser },
|
|
11
|
+
from: mockFrom,
|
|
11
12
|
})
|
|
12
13
|
),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
|
-
vi.mock("@/shared/db", () => ({
|
|
16
|
-
db: {
|
|
17
|
-
select: vi.fn(() => ({
|
|
18
|
-
from: vi.fn(() => ({
|
|
19
|
-
where: mockSelect,
|
|
20
|
-
})),
|
|
21
|
-
})),
|
|
22
|
-
},
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
16
|
vi.mock("@/features/todos/components/todo-list", () => ({
|
|
26
17
|
TodoList: () => <div data-testid="todo-list" />,
|
|
27
18
|
}));
|
|
@@ -34,6 +25,13 @@ vi.mock("next/navigation", () => ({
|
|
|
34
25
|
redirect: (url: string): unknown => mockRedirect(url),
|
|
35
26
|
}));
|
|
36
27
|
|
|
28
|
+
function makeTodosChain(data: unknown[]) {
|
|
29
|
+
return {
|
|
30
|
+
select: vi.fn().mockReturnThis(),
|
|
31
|
+
eq: vi.fn(() => Promise.resolve({ data, error: null })),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
37
35
|
describe("HomePage (protected)", () => {
|
|
38
36
|
beforeEach(() => {
|
|
39
37
|
vi.clearAllMocks();
|
|
@@ -53,7 +51,7 @@ describe("HomePage (protected)", () => {
|
|
|
53
51
|
it("renderiza la página con las tareas del usuario", async () => {
|
|
54
52
|
// Arrange
|
|
55
53
|
mockGetUser.mockResolvedValue({ data: { user: { id: "u1", email: "user@example.com" } } });
|
|
56
|
-
|
|
54
|
+
mockFrom.mockReturnValue(makeTodosChain([]));
|
|
57
55
|
const { default: HomePage } = await import("../(protected)/page");
|
|
58
56
|
|
|
59
57
|
// Act
|
|
@@ -66,7 +64,7 @@ describe("HomePage (protected)", () => {
|
|
|
66
64
|
it("renderiza correctamente con lista de tareas vacía", async () => {
|
|
67
65
|
// Arrange
|
|
68
66
|
mockGetUser.mockResolvedValue({ data: { user: { id: "u1", email: "user@example.com" } } });
|
|
69
|
-
|
|
67
|
+
mockFrom.mockReturnValue(makeTodosChain([]));
|
|
70
68
|
const { default: HomePage } = await import("../(protected)/page");
|
|
71
69
|
|
|
72
70
|
// Act
|
|
@@ -5,7 +5,8 @@ import { createClient } from "@/shared/lib/supabase/server";
|
|
|
5
5
|
export async function GET(request: Request) {
|
|
6
6
|
const { searchParams, origin } = new URL(request.url);
|
|
7
7
|
const code = searchParams.get("code");
|
|
8
|
-
const
|
|
8
|
+
const rawNext = searchParams.get("next") ?? "/";
|
|
9
|
+
const next = rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/";
|
|
9
10
|
|
|
10
11
|
if (!code) {
|
|
11
12
|
return NextResponse.redirect(`${origin}/login?error=no_code`);
|
|
@@ -9,13 +9,13 @@ test.describe("Login flow", () => {
|
|
|
9
9
|
test("shows login form", async ({ page }) => {
|
|
10
10
|
await page.goto("/login");
|
|
11
11
|
await expect(page.getByTestId("login-form")).toBeVisible();
|
|
12
|
-
await expect(page.getByLabel(/
|
|
13
|
-
await expect(page.getByLabel(/
|
|
12
|
+
await expect(page.getByLabel(/correo electrónico/i)).toBeVisible();
|
|
13
|
+
await expect(page.getByLabel(/contraseña/i)).toBeVisible();
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
test("shows validation errors on empty submit", async ({ page }) => {
|
|
17
17
|
await page.goto("/login");
|
|
18
|
-
await page.getByRole("button", { name: /
|
|
19
|
-
await expect(page.getByText(/
|
|
18
|
+
await page.getByRole("button", { name: /iniciar sesión/i }).click();
|
|
19
|
+
await expect(page.getByText(/correo electrónico inválido/i)).toBeVisible();
|
|
20
20
|
});
|
|
21
21
|
});
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
|
-
const mockInsert = vi.fn();
|
|
4
|
-
const mockUpdate = vi.fn();
|
|
5
|
-
const mockDelete = vi.fn();
|
|
6
|
-
const mockSelectWhere = vi.fn();
|
|
7
3
|
const mockRevalidatePath = vi.fn();
|
|
8
4
|
const mockGetUser = vi.fn();
|
|
5
|
+
const mockFrom = vi.fn();
|
|
9
6
|
|
|
10
7
|
vi.mock("next/cache", () => ({
|
|
11
8
|
revalidatePath: (path: string): unknown => mockRevalidatePath(path),
|
|
@@ -15,22 +12,27 @@ vi.mock("@/shared/lib/supabase/server", () => ({
|
|
|
15
12
|
createClient: vi.fn(() =>
|
|
16
13
|
Promise.resolve({
|
|
17
14
|
auth: { getUser: mockGetUser },
|
|
15
|
+
from: mockFrom,
|
|
18
16
|
})
|
|
19
17
|
),
|
|
20
18
|
}));
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
vi.
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
interface QueryResult { data: unknown; error: unknown }
|
|
21
|
+
|
|
22
|
+
function makeChain(result: QueryResult) {
|
|
23
|
+
const chain: Record<string, unknown> = {};
|
|
24
|
+
chain.insert = vi.fn(() => chain);
|
|
25
|
+
chain.select = vi.fn(() => chain);
|
|
26
|
+
chain.update = vi.fn(() => chain);
|
|
27
|
+
chain.delete = vi.fn(() => chain);
|
|
28
|
+
chain.eq = vi.fn(() => chain);
|
|
29
|
+
chain.single = vi.fn(() => Promise.resolve(result));
|
|
30
|
+
chain.then = (
|
|
31
|
+
onFulfilled: (v: QueryResult) => unknown,
|
|
32
|
+
onRejected?: (e: unknown) => unknown
|
|
33
|
+
) => Promise.resolve(result).then(onFulfilled, onRejected);
|
|
34
|
+
return chain;
|
|
35
|
+
}
|
|
34
36
|
|
|
35
37
|
describe("addTodoAction", () => {
|
|
36
38
|
beforeEach(() => {
|
|
@@ -48,58 +50,57 @@ describe("addTodoAction", () => {
|
|
|
48
50
|
|
|
49
51
|
// Assert
|
|
50
52
|
expect(result.status).toBe("error");
|
|
51
|
-
expect(
|
|
53
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
it("retorna error si no
|
|
56
|
+
it("retorna error si el campo title no está presente", async () => {
|
|
55
57
|
// Arrange
|
|
56
|
-
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
57
58
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
58
59
|
const fd = new FormData();
|
|
59
|
-
fd.set("title", "Mi tarea");
|
|
60
60
|
|
|
61
61
|
// Act
|
|
62
62
|
const result = await addTodoAction(fd);
|
|
63
63
|
|
|
64
64
|
// Assert
|
|
65
65
|
expect(result.status).toBe("error");
|
|
66
|
-
expect(
|
|
66
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("
|
|
69
|
+
it("retorna error si no hay usuario autenticado", async () => {
|
|
70
70
|
// Arrange
|
|
71
|
-
mockGetUser.mockResolvedValue({ data: { user:
|
|
72
|
-
mockInsert.mockResolvedValue(undefined);
|
|
71
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
73
72
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
74
73
|
const fd = new FormData();
|
|
75
|
-
fd.set("title", "
|
|
74
|
+
fd.set("title", "Mi tarea");
|
|
76
75
|
|
|
77
76
|
// Act
|
|
78
77
|
const result = await addTodoAction(fd);
|
|
79
78
|
|
|
80
79
|
// Assert
|
|
81
|
-
expect(result.status).toBe("
|
|
82
|
-
expect(
|
|
83
|
-
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
80
|
+
expect(result.status).toBe("error");
|
|
81
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
84
82
|
});
|
|
85
83
|
|
|
86
|
-
it("
|
|
84
|
+
it("inserta la tarea, llama a revalidatePath y retorna success cuando el usuario existe", async () => {
|
|
87
85
|
// Arrange
|
|
86
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
87
|
+
mockFrom.mockReturnValue(makeChain({ data: null, error: null }));
|
|
88
88
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
89
89
|
const fd = new FormData();
|
|
90
|
+
fd.set("title", "Nueva tarea");
|
|
90
91
|
|
|
91
92
|
// Act
|
|
92
93
|
const result = await addTodoAction(fd);
|
|
93
94
|
|
|
94
95
|
// Assert
|
|
95
|
-
expect(result.status).toBe("
|
|
96
|
-
expect(
|
|
96
|
+
expect(result.status).toBe("success");
|
|
97
|
+
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
it("retorna error si la inserción
|
|
100
|
+
it("retorna error si la inserción falla", async () => {
|
|
100
101
|
// Arrange
|
|
101
102
|
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
102
|
-
|
|
103
|
+
mockFrom.mockReturnValue(makeChain({ data: null, error: new Error("DB error") }));
|
|
103
104
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
104
105
|
const fd = new FormData();
|
|
105
106
|
fd.set("title", "Tarea fallida");
|
|
@@ -117,9 +118,25 @@ describe("toggleTodoAction", () => {
|
|
|
117
118
|
vi.clearAllMocks();
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
it("retorna error si no hay usuario autenticado", async () => {
|
|
122
|
+
// Arrange
|
|
123
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
124
|
+
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
const result = await toggleTodoAction("1");
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
expect(result.status).toBe("error");
|
|
131
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
120
134
|
it("retorna error si la tarea no existe", async () => {
|
|
121
135
|
// Arrange
|
|
122
|
-
|
|
136
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
137
|
+
mockFrom.mockReturnValue(
|
|
138
|
+
makeChain({ data: null, error: { code: "PGRST116" } })
|
|
139
|
+
);
|
|
123
140
|
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
124
141
|
|
|
125
142
|
// Act
|
|
@@ -127,13 +144,14 @@ describe("toggleTodoAction", () => {
|
|
|
127
144
|
|
|
128
145
|
// Assert
|
|
129
146
|
expect(result.status).toBe("error");
|
|
130
|
-
expect(mockUpdate).not.toHaveBeenCalled();
|
|
131
147
|
});
|
|
132
148
|
|
|
133
149
|
it("invierte completed, llama a revalidatePath y retorna success", async () => {
|
|
134
150
|
// Arrange
|
|
135
|
-
|
|
136
|
-
|
|
151
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
152
|
+
mockFrom
|
|
153
|
+
.mockReturnValueOnce(makeChain({ data: { completed: false }, error: null }))
|
|
154
|
+
.mockReturnValueOnce(makeChain({ data: null, error: null }));
|
|
137
155
|
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
138
156
|
|
|
139
157
|
// Act
|
|
@@ -141,13 +159,15 @@ describe("toggleTodoAction", () => {
|
|
|
141
159
|
|
|
142
160
|
// Assert
|
|
143
161
|
expect(result.status).toBe("success");
|
|
144
|
-
expect(mockUpdate).toHaveBeenCalled();
|
|
145
162
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
146
163
|
});
|
|
147
164
|
|
|
148
|
-
it("retorna error si la actualización
|
|
165
|
+
it("retorna error si la actualización falla", async () => {
|
|
149
166
|
// Arrange
|
|
150
|
-
|
|
167
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
168
|
+
mockFrom
|
|
169
|
+
.mockReturnValueOnce(makeChain({ data: { completed: true }, error: null }))
|
|
170
|
+
.mockReturnValueOnce(makeChain({ data: null, error: new Error("DB error") }));
|
|
151
171
|
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
152
172
|
|
|
153
173
|
// Act
|
|
@@ -163,9 +183,23 @@ describe("deleteTodoAction", () => {
|
|
|
163
183
|
vi.clearAllMocks();
|
|
164
184
|
});
|
|
165
185
|
|
|
186
|
+
it("retorna error si no hay usuario autenticado", async () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
189
|
+
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
190
|
+
|
|
191
|
+
// Act
|
|
192
|
+
const result = await deleteTodoAction("1");
|
|
193
|
+
|
|
194
|
+
// Assert
|
|
195
|
+
expect(result.status).toBe("error");
|
|
196
|
+
expect(mockFrom).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
166
199
|
it("elimina la tarea, llama a revalidatePath y retorna success", async () => {
|
|
167
200
|
// Arrange
|
|
168
|
-
|
|
201
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
202
|
+
mockFrom.mockReturnValue(makeChain({ data: null, error: null }));
|
|
169
203
|
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
170
204
|
|
|
171
205
|
// Act
|
|
@@ -173,13 +207,13 @@ describe("deleteTodoAction", () => {
|
|
|
173
207
|
|
|
174
208
|
// Assert
|
|
175
209
|
expect(result.status).toBe("success");
|
|
176
|
-
expect(mockDelete).toHaveBeenCalled();
|
|
177
210
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
178
211
|
});
|
|
179
212
|
|
|
180
|
-
it("retorna error si la eliminación
|
|
213
|
+
it("retorna error si la eliminación falla", async () => {
|
|
181
214
|
// Arrange
|
|
182
|
-
|
|
215
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
216
|
+
mockFrom.mockReturnValue(makeChain({ data: null, error: new Error("DB error") }));
|
|
183
217
|
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
184
218
|
|
|
185
219
|
// Act
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getTodoById, getTodosByUserId } from "../queries/todos.queries";
|
|
4
|
+
|
|
5
|
+
import type { createClient } from "@/shared/lib/supabase/server";
|
|
6
|
+
|
|
7
|
+
type Client = Awaited<ReturnType<typeof createClient>>;
|
|
8
|
+
interface QueryResult { data: unknown; error: unknown }
|
|
9
|
+
|
|
10
|
+
function makeSupabase(result: QueryResult) {
|
|
11
|
+
const chain: Record<string, unknown> = {};
|
|
12
|
+
chain.select = vi.fn(() => chain);
|
|
13
|
+
chain.eq = vi.fn(() => chain);
|
|
14
|
+
chain.single = vi.fn(() => Promise.resolve(result));
|
|
15
|
+
chain.then = (
|
|
16
|
+
onFulfilled: (v: QueryResult) => unknown,
|
|
17
|
+
onRejected?: (e: unknown) => unknown
|
|
18
|
+
) => Promise.resolve(result).then(onFulfilled, onRejected);
|
|
19
|
+
return { from: vi.fn(() => chain) } as unknown as Client;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("getTodosByUserId", () => {
|
|
23
|
+
it("retorna los todos del usuario cuando la consulta tiene éxito", async () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const todos = [{ id: "1", title: "Tarea", completed: false, user_id: "u1" }];
|
|
26
|
+
const supabase = makeSupabase({ data: todos, error: null });
|
|
27
|
+
|
|
28
|
+
// Act
|
|
29
|
+
const result = await getTodosByUserId(supabase, "u1");
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(result.data).toEqual(todos);
|
|
33
|
+
expect(result.error).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("retorna lista vacía cuando el usuario no tiene todos", async () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const supabase = makeSupabase({ data: [], error: null });
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const result = await getTodosByUserId(supabase, "u1");
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(result.data).toEqual([]);
|
|
45
|
+
expect(result.error).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("retorna error cuando falla la consulta", async () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
const dbError = new Error("DB error");
|
|
51
|
+
const supabase = makeSupabase({ data: null, error: dbError });
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
const result = await getTodosByUserId(supabase, "u1");
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(result.data).toBeNull();
|
|
58
|
+
expect(result.error).toBe(dbError);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("getTodoById", () => {
|
|
63
|
+
it("retorna el todo cuando existe y pertenece al usuario", async () => {
|
|
64
|
+
// Arrange
|
|
65
|
+
const todo = { id: "1", completed: false };
|
|
66
|
+
const supabase = makeSupabase({ data: todo, error: null });
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
const result = await getTodoById(supabase, "1", "u1");
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(result.data).toEqual(todo);
|
|
73
|
+
expect(result.error).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("retorna error cuando el todo no existe", async () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const dbError = { code: "PGRST116" };
|
|
79
|
+
const supabase = makeSupabase({ data: null, error: dbError });
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
const result = await getTodoById(supabase, "non-existent", "u1");
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
expect(result.data).toBeNull();
|
|
86
|
+
expect(result.error).toEqual(dbError);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("retorna error cuando falla la consulta", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const dbError = new Error("DB error");
|
|
92
|
+
const supabase = makeSupabase({ data: null, error: dbError });
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const result = await getTodoById(supabase, "1", "u1");
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
expect(result.data).toBeNull();
|
|
99
|
+
expect(result.error).toBe(dbError);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
3
|
import { revalidatePath } from "next/cache";
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
import { todos } from "@/shared/db/schema";
|
|
5
|
+
import { getTodoById } from "@/features/todos/queries/todos.queries";
|
|
8
6
|
import type { ActionResult } from "@/shared/lib/action-result";
|
|
9
7
|
import { createClient } from "@/shared/lib/supabase/server";
|
|
10
8
|
|
|
@@ -20,34 +18,50 @@ export async function addTodoAction(formData: FormData): Promise<ActionResult> {
|
|
|
20
18
|
} = await supabase.auth.getUser();
|
|
21
19
|
if (!user) return { status: "error", message: "No autenticado" };
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
21
|
+
const { error } = await supabase
|
|
22
|
+
.from("todos")
|
|
23
|
+
.insert({ user_id: user.id, title: title.trim() });
|
|
24
|
+
if (error) return { status: "error", message: "Error al agregar la tarea" };
|
|
25
|
+
|
|
26
|
+
revalidatePath("/");
|
|
27
|
+
return { status: "success", message: "Tarea agregada" };
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export async function toggleTodoAction(id: string): Promise<ActionResult> {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
31
|
+
const supabase = await createClient();
|
|
32
|
+
const {
|
|
33
|
+
data: { user },
|
|
34
|
+
} = await supabase.auth.getUser();
|
|
35
|
+
if (!user) return { status: "error", message: "No autenticado" };
|
|
36
|
+
|
|
37
|
+
const { data: todo, error: fetchError } = await getTodoById(supabase, id, user.id);
|
|
38
|
+
if (fetchError ?? !todo) return { status: "error", message: "Tarea no encontrada" };
|
|
39
|
+
|
|
40
|
+
const { error } = await supabase
|
|
41
|
+
.from("todos")
|
|
42
|
+
.update({ completed: !todo.completed })
|
|
43
|
+
.eq("id", id)
|
|
44
|
+
.eq("user_id", user.id);
|
|
45
|
+
if (error) return { status: "error", message: "Error al actualizar la tarea" };
|
|
46
|
+
|
|
47
|
+
revalidatePath("/");
|
|
48
|
+
return { status: "success", message: "Tarea actualizada" };
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export async function deleteTodoAction(id: string): Promise<ActionResult> {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
52
|
+
const supabase = await createClient();
|
|
53
|
+
const {
|
|
54
|
+
data: { user },
|
|
55
|
+
} = await supabase.auth.getUser();
|
|
56
|
+
if (!user) return { status: "error", message: "No autenticado" };
|
|
57
|
+
|
|
58
|
+
const { error } = await supabase
|
|
59
|
+
.from("todos")
|
|
60
|
+
.delete()
|
|
61
|
+
.eq("id", id)
|
|
62
|
+
.eq("user_id", user.id);
|
|
63
|
+
if (error) return { status: "error", message: "Error al eliminar la tarea" };
|
|
64
|
+
|
|
65
|
+
revalidatePath("/");
|
|
66
|
+
return { status: "success", message: "Tarea eliminada" };
|
|
53
67
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { createClient } from "@/shared/lib/supabase/server";
|
|
2
|
+
|
|
3
|
+
type Client = Awaited<ReturnType<typeof createClient>>;
|
|
4
|
+
|
|
5
|
+
export async function getTodosByUserId(supabase: Client, userId: string) {
|
|
6
|
+
return supabase.from("todos").select("*").eq("user_id", userId);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getTodoById(supabase: Client, id: string, userId: string) {
|
|
10
|
+
return supabase
|
|
11
|
+
.from("todos")
|
|
12
|
+
.select("completed")
|
|
13
|
+
.eq("id", id)
|
|
14
|
+
.eq("user_id", userId)
|
|
15
|
+
.single();
|
|
16
|
+
}
|
|
@@ -4,7 +4,6 @@ import { NextResponse, type NextRequest } from "next/server";
|
|
|
4
4
|
export async function updateSession(request: NextRequest) {
|
|
5
5
|
let supabaseResponse = NextResponse.next({ request });
|
|
6
6
|
|
|
7
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
8
7
|
const supabase = createServerClient(
|
|
9
8
|
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
|
|
10
9
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
|
|
@@ -4,7 +4,6 @@ import { cookies } from "next/headers";
|
|
|
4
4
|
export async function createClient() {
|
|
5
5
|
const cookieStore = await cookies();
|
|
6
6
|
|
|
7
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
8
7
|
return createServerClient(
|
|
9
8
|
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
|
|
10
9
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
|
package/template/tailwind.css
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
3
|
@theme {
|
|
4
|
-
--color-background:
|
|
5
|
-
--color-foreground:
|
|
6
|
-
--color-primary:
|
|
7
|
-
--color-primary-foreground:
|
|
8
|
-
--color-secondary:
|
|
9
|
-
--color-secondary-foreground:
|
|
10
|
-
--color-muted:
|
|
11
|
-
--color-muted-foreground:
|
|
12
|
-
--color-accent:
|
|
13
|
-
--color-accent-foreground:
|
|
4
|
+
--color-background: #ffffff;
|
|
5
|
+
--color-foreground: #111111;
|
|
6
|
+
--color-primary: #022633;
|
|
7
|
+
--color-primary-foreground: #ffffff;
|
|
8
|
+
--color-secondary: #f6a623;
|
|
9
|
+
--color-secondary-foreground: #ffffff;
|
|
10
|
+
--color-muted: #f5f5f5;
|
|
11
|
+
--color-muted-foreground: #666666;
|
|
12
|
+
--color-accent: #044a68;
|
|
13
|
+
--color-accent-foreground: #ffffff;
|
|
14
14
|
--color-destructive: hsl(0 84.2% 60.2%);
|
|
15
|
-
--color-destructive-foreground:
|
|
16
|
-
--color-border:
|
|
17
|
-
--color-input:
|
|
18
|
-
--color-ring:
|
|
15
|
+
--color-destructive-foreground: #ffffff;
|
|
16
|
+
--color-border: #e5e5e5;
|
|
17
|
+
--color-input: #e5e5e5;
|
|
18
|
+
--color-ring: #022633;
|
|
19
19
|
--radius: 0.5rem;
|
|
20
20
|
}
|