nextjs-hackathon-stack 0.1.23 → 0.1.25
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/.cursor/agents/security-researcher.md +1 -1
- package/template/.cursor/rules/general.mdc +1 -1
- package/template/.cursor/rules/nextjs.mdc +3 -3
- package/template/.cursor/rules/supabase.mdc +5 -0
- package/template/.cursor/skills/security-audit/SKILL.md +1 -1
- package/template/AGENTS.md +21 -0
- package/template/CLAUDE.md +15 -0
- package/template/README.md +2 -2
- package/template/eslint.config.ts +5 -1
- package/template/next.config.ts +3 -0
- package/template/package.json.tmpl +4 -3
- package/template/{middleware.ts → proxy.ts} +3 -2
- 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 → proxy.test.ts} +8 -8
- 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
|
@@ -12,7 +12,7 @@ readonly: true
|
|
|
12
12
|
### Authentication & Authorization
|
|
13
13
|
- [ ] Supabase RLS enabled on all tables
|
|
14
14
|
- [ ] Auth check in every protected API route
|
|
15
|
-
- [ ] Session validation in
|
|
15
|
+
- [ ] Session validation in proxy
|
|
16
16
|
- [ ] No hardcoded credentials or API keys
|
|
17
17
|
|
|
18
18
|
### Input Validation
|
|
@@ -6,7 +6,7 @@ alwaysApply: true
|
|
|
6
6
|
# Stack Overview
|
|
7
7
|
|
|
8
8
|
## Technology Stack
|
|
9
|
-
- **Framework**: Next.js
|
|
9
|
+
- **Framework**: Next.js 16 (App Router)
|
|
10
10
|
- **Database**: Supabase (PostgreSQL) with Drizzle ORM for schema/migrations
|
|
11
11
|
- **Auth**: Supabase Auth via `@supabase/ssr`
|
|
12
12
|
- **ORM**: Drizzle + `drizzle-zod` (schema + migrations ONLY — no runtime queries)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Next.js
|
|
2
|
+
description: Next.js 16 App Router patterns.
|
|
3
3
|
globs: ["src/app/**"]
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Next.js
|
|
6
|
+
# Next.js 16 Rules
|
|
7
7
|
|
|
8
8
|
## App Router Conventions
|
|
9
9
|
- `page.tsx` — public route page component
|
|
@@ -11,7 +11,7 @@ globs: ["src/app/**"]
|
|
|
11
11
|
- `loading.tsx` — Suspense boundary UI
|
|
12
12
|
- `error.tsx` — Error boundary UI
|
|
13
13
|
- `route.ts` — API route handler
|
|
14
|
-
- `
|
|
14
|
+
- `proxy.ts` — Request proxy (project root, Node.js runtime)
|
|
15
15
|
|
|
16
16
|
## Route Groups
|
|
17
17
|
- `(auth)` — unauthenticated routes
|
|
@@ -34,3 +34,8 @@ const users = await db.select().from(usersTable); // WRONG — bypasses RLS
|
|
|
34
34
|
- Enable RLS on ALL tables in Supabase dashboard
|
|
35
35
|
- Every table must have explicit policies
|
|
36
36
|
- Test RLS policies in migrations
|
|
37
|
+
|
|
38
|
+
## Environment Variables
|
|
39
|
+
- `NEXT_PUBLIC_SUPABASE_URL` + `NEXT_PUBLIC_SUPABASE_ANON_KEY` — client-safe, used in browser + server
|
|
40
|
+
- `DATABASE_URL` — server-only, used by Drizzle for migrations (`drizzle.config.ts`)
|
|
41
|
+
- See `.env.example` for all required variables
|
|
@@ -29,7 +29,7 @@ For each table in `src/shared/db/schema.ts`:
|
|
|
29
29
|
- Confirm explicit policies exist
|
|
30
30
|
|
|
31
31
|
### 4. Auth Coverage
|
|
32
|
-
- Verify `
|
|
32
|
+
- Verify `proxy.ts` protects all non-public routes
|
|
33
33
|
- Check every `route.ts` in protected features has auth check
|
|
34
34
|
- Verify `app/(protected)/layout.tsx` has server-side auth check
|
|
35
35
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
|
|
3
|
+
# Next.js: ALWAYS read docs before coding
|
|
4
|
+
|
|
5
|
+
Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
|
|
6
|
+
|
|
7
|
+
<!-- END:nextjs-agent-rules -->
|
|
8
|
+
|
|
9
|
+
# Environment variables
|
|
10
|
+
|
|
11
|
+
Read `.env.example` for the full list. Key vars:
|
|
12
|
+
|
|
13
|
+
| Variable | Visibility | Where to get it |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `NEXT_PUBLIC_SUPABASE_URL` | Client + Server | Supabase > Project Settings > API |
|
|
16
|
+
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Client + Server | Supabase > Project Settings > API |
|
|
17
|
+
| `DATABASE_URL` | Server only | Supabase > Project Settings > Database > Connection string (Session mode) |
|
|
18
|
+
|
|
19
|
+
- `NEXT_PUBLIC_*` vars are safe to expose to the browser — they are public by design
|
|
20
|
+
- Never add `NEXT_PUBLIC_` prefix to secret keys (`DATABASE_URL`, `AI_API_KEY`)
|
|
21
|
+
- `.env.local` is gitignored — never commit real credentials
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@AGENTS.md
|
|
2
|
+
|
|
3
|
+
# Architecture
|
|
4
|
+
|
|
5
|
+
Feature-based structure: `src/features/* → src/shared/*` (never reverse).
|
|
6
|
+
|
|
7
|
+
- **Drizzle** = schema + migrations ONLY. Never use Drizzle for runtime queries.
|
|
8
|
+
- **supabase-js** = all runtime queries. RLS is always active.
|
|
9
|
+
- **Zod schemas** for DB types: auto-generate via `drizzle-zod`, never write manually.
|
|
10
|
+
|
|
11
|
+
# Testing
|
|
12
|
+
|
|
13
|
+
- 100% coverage required — `pnpm test:coverage` must pass
|
|
14
|
+
- AAA pattern (Arrange / Act / Assert) in every test
|
|
15
|
+
- Mock only external boundaries (Supabase, HTTP, DB) — never mock internal code
|
package/template/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# {{projectName}}
|
|
2
2
|
|
|
3
|
-
Full-stack Next.js
|
|
3
|
+
Full-stack Next.js 16 hackathon starter.
|
|
4
4
|
|
|
5
5
|
## Stack
|
|
6
6
|
|
|
7
7
|
| Layer | Tool |
|
|
8
8
|
|---|---|
|
|
9
|
-
| Framework | Next.js
|
|
9
|
+
| Framework | Next.js 16 + App Router |
|
|
10
10
|
| Database | Supabase (PostgreSQL) |
|
|
11
11
|
| Auth | Supabase Auth |
|
|
12
12
|
| ORM | Drizzle (schema + migrations) |
|
|
@@ -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/next.config.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "next dev
|
|
6
|
+
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"lint": "eslint . --max-warnings 0",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"prepare": "husky"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"next": "^
|
|
24
|
+
"next": "^16.2",
|
|
25
25
|
"@supabase/supabase-js": "^2",
|
|
26
26
|
"@supabase/ssr": "^0.6",
|
|
27
27
|
"drizzle-orm": "^0.44",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@types/node": "^22",
|
|
46
46
|
"@types/react": "^19",
|
|
47
47
|
"@types/react-dom": "^19",
|
|
48
|
+
"@next/env": "^16",
|
|
48
49
|
"drizzle-kit": "^0.30",
|
|
49
50
|
"vitest": "^3",
|
|
50
51
|
"@vitejs/plugin-react": "^4",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"eslint": "^9",
|
|
61
62
|
"@eslint/js": "^9",
|
|
62
63
|
"typescript-eslint": "^8",
|
|
63
|
-
"@next/eslint-plugin-next": "^
|
|
64
|
+
"@next/eslint-plugin-next": "^16",
|
|
64
65
|
"eslint-plugin-react": "^7",
|
|
65
66
|
"eslint-plugin-react-hooks": "^5",
|
|
66
67
|
"eslint-plugin-import-x": "^4",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
|
|
2
3
|
import { updateSession } from "@/shared/lib/supabase/middleware";
|
|
3
4
|
|
|
4
|
-
export async function
|
|
5
|
+
export async function proxy(request: NextRequest) {
|
|
5
6
|
return updateSession(request);
|
|
6
7
|
}
|
|
7
8
|
|
|
@@ -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,22 +1,22 @@
|
|
|
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
|
-
describe("
|
|
10
|
+
describe("proxy", () => {
|
|
11
11
|
it("delegates to updateSession", async () => {
|
|
12
12
|
// Arrange
|
|
13
13
|
const mockResponse = new Response(null, { status: 200 });
|
|
14
14
|
mockUpdateSession.mockResolvedValue(mockResponse);
|
|
15
|
-
const
|
|
15
|
+
const mod = await import("../../../proxy") as { proxy: (req: NextRequest) => Promise<Response> };
|
|
16
16
|
const req = new NextRequest("http://localhost/dashboard");
|
|
17
17
|
|
|
18
18
|
// Act
|
|
19
|
-
const response = await
|
|
19
|
+
const response = await mod.proxy(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("../../../proxy") 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
|
{
|