nextjs-hackathon-stack 0.1.11 → 0.1.13
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/README.md +4 -9
- package/dist/index.js +8 -2
- package/package.json +2 -2
- package/template/.cursor/rules/ai.mdc +4 -4
- package/template/.cursor/rules/general.mdc +2 -2
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +6 -7
- package/template/_env.example +7 -7
- package/template/package.json.tmpl +8 -2
- package/template/src/app/__tests__/auth-callback.test.ts +72 -0
- package/template/src/app/__tests__/error.test.tsx +71 -0
- package/template/src/app/__tests__/layout.test.tsx +22 -0
- package/template/src/app/__tests__/login-page.test.tsx +34 -0
- package/template/src/app/__tests__/protected-layout.test.tsx +43 -0
- package/template/src/app/__tests__/protected-page.test.tsx +41 -0
- package/template/src/app/api/chat/route.ts +3 -1
- package/template/src/features/auth/__tests__/login-form.test.tsx +53 -2
- package/template/src/features/auth/__tests__/login.action.test.ts +82 -0
- package/template/src/features/auth/__tests__/logout.action.test.ts +32 -0
- package/template/src/features/auth/actions/login.action.ts +1 -1
- package/template/src/features/auth/components/login-form.tsx +4 -3
- package/template/src/features/chat/__tests__/chat-ui.test.tsx +97 -7
- package/template/src/features/chat/__tests__/route.test.ts +66 -3
- package/template/src/features/chat/__tests__/use-chat.test.ts +15 -0
- package/template/src/features/chat/components/chat-ui.tsx +1 -1
- package/template/src/shared/__tests__/middleware.test.ts +34 -0
- package/template/src/shared/__tests__/schema.test.ts +16 -3
- package/template/src/shared/__tests__/supabase-middleware.test.ts +162 -0
- package/template/src/shared/__tests__/supabase-server.test.ts +76 -3
- package/template/src/shared/__tests__/ui-button.test.tsx +52 -0
- package/template/src/shared/__tests__/ui-card.test.tsx +78 -0
- package/template/src/shared/__tests__/utils.test.ts +30 -0
- package/template/src/shared/lib/ai.ts +2 -2
- package/template/vitest.config.ts +5 -0
- package/template/src/features/tts/__tests__/route.test.ts +0 -13
- package/template/src/features/tts/__tests__/tts-player.test.tsx +0 -21
- package/template/src/features/tts/api/route.ts +0 -14
- package/template/src/features/tts/components/tts-player.tsx +0 -59
- package/template/src/features/video/__tests__/route.test.ts +0 -13
- package/template/src/features/video/__tests__/video-generator.test.tsx +0 -21
- package/template/src/features/video/api/route.ts +0 -14
- package/template/src/features/video/components/video-generator.tsx +0 -56
- package/template/src/shared/__tests__/minimax-media.test.ts +0 -58
- package/template/src/shared/lib/minimax-media.ts +0 -63
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockSignInWithPassword = vi.fn();
|
|
4
|
+
const mockRedirect = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
7
|
+
createClient: vi.fn(() =>
|
|
8
|
+
Promise.resolve({
|
|
9
|
+
auth: { signInWithPassword: mockSignInWithPassword },
|
|
10
|
+
})
|
|
11
|
+
),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
redirect: (url: string) => mockRedirect(url),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("loginAction", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns error when email is invalid", async () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const { loginAction } = await import("../actions/login.action");
|
|
26
|
+
const fd = new FormData();
|
|
27
|
+
fd.set("email", "not-an-email");
|
|
28
|
+
fd.set("password", "password123");
|
|
29
|
+
|
|
30
|
+
// Act
|
|
31
|
+
const result = await loginAction({ status: "idle" }, fd);
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
34
|
+
expect(result.status).toBe("error");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns error when password is too short", async () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const { loginAction } = await import("../actions/login.action");
|
|
40
|
+
const fd = new FormData();
|
|
41
|
+
fd.set("email", "user@example.com");
|
|
42
|
+
fd.set("password", "short");
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const result = await loginAction({ status: "idle" }, fd);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(result.status).toBe("error");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns error on auth failure", async () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
mockSignInWithPassword.mockResolvedValue({
|
|
54
|
+
error: { message: "Invalid credentials" },
|
|
55
|
+
});
|
|
56
|
+
const { loginAction } = await import("../actions/login.action");
|
|
57
|
+
const fd = new FormData();
|
|
58
|
+
fd.set("email", "user@example.com");
|
|
59
|
+
fd.set("password", "password123");
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
const result = await loginAction({ status: "idle" }, fd);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(result).toEqual({ status: "error", message: "Invalid credentials" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("redirects to / on successful login", async () => {
|
|
69
|
+
// Arrange
|
|
70
|
+
mockSignInWithPassword.mockResolvedValue({ error: null });
|
|
71
|
+
const { loginAction } = await import("../actions/login.action");
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.set("email", "user@example.com");
|
|
74
|
+
fd.set("password", "password123");
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
await loginAction({ status: "idle" }, fd);
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(mockRedirect).toHaveBeenCalledWith("/");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockSignOut = vi.fn();
|
|
4
|
+
const mockRedirect = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
7
|
+
createClient: vi.fn(() =>
|
|
8
|
+
Promise.resolve({
|
|
9
|
+
auth: { signOut: mockSignOut },
|
|
10
|
+
})
|
|
11
|
+
),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
redirect: (url: string) => mockRedirect(url),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("logoutAction", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
mockSignOut.mockResolvedValue({});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("calls signOut and redirects to /login", async () => {
|
|
25
|
+
const { logoutAction } = await import("../actions/logout.action");
|
|
26
|
+
|
|
27
|
+
await logoutAction();
|
|
28
|
+
|
|
29
|
+
expect(mockSignOut).toHaveBeenCalled();
|
|
30
|
+
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -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]!.message };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const supabase = await createClient();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
-
import { useActionState } from "react";
|
|
4
|
+
import { useActionState, useTransition } from "react";
|
|
5
5
|
import { useForm } from "react-hook-form";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
@@ -22,7 +22,8 @@ type FormValues = z.infer<typeof schema>;
|
|
|
22
22
|
const initialState: LoginActionState = { status: "idle" };
|
|
23
23
|
|
|
24
24
|
export function LoginForm() {
|
|
25
|
-
const [state, formAction
|
|
25
|
+
const [state, formAction] = useActionState(loginAction, initialState);
|
|
26
|
+
const [isPending, startTransition] = useTransition();
|
|
26
27
|
|
|
27
28
|
const {
|
|
28
29
|
register,
|
|
@@ -34,7 +35,7 @@ export function LoginForm() {
|
|
|
34
35
|
const fd = new FormData();
|
|
35
36
|
fd.set("email", data.email);
|
|
36
37
|
fd.set("password", data.password);
|
|
37
|
-
formAction(fd);
|
|
38
|
+
startTransition(() => formAction(fd));
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
return (
|
|
@@ -3,29 +3,119 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
3
3
|
|
|
4
4
|
import { ChatUi } from "../components/chat-ui";
|
|
5
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
|
+
|
|
6
14
|
vi.mock("@ai-sdk/react", () => ({
|
|
7
|
-
useChat:
|
|
8
|
-
messages: [],
|
|
9
|
-
input: "",
|
|
10
|
-
handleInputChange: vi.fn(),
|
|
11
|
-
handleSubmit: vi.fn(),
|
|
12
|
-
isLoading: false,
|
|
13
|
-
})),
|
|
15
|
+
useChat: (...args: unknown[]) => mockUseChat(...args),
|
|
14
16
|
}));
|
|
15
17
|
|
|
16
18
|
describe("ChatUi", () => {
|
|
17
19
|
it("renders chat input", () => {
|
|
20
|
+
// Arrange + Act
|
|
18
21
|
render(<ChatUi />);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
19
24
|
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
|
|
20
25
|
});
|
|
21
26
|
|
|
22
27
|
it("renders send button", () => {
|
|
28
|
+
// Arrange + Act
|
|
23
29
|
render(<ChatUi />);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
24
32
|
expect(screen.getByRole("button", { name: /send/i })).toBeInTheDocument();
|
|
25
33
|
});
|
|
26
34
|
|
|
27
35
|
it("renders messages list area", () => {
|
|
36
|
+
// Arrange + Act
|
|
28
37
|
render(<ChatUi />);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
29
40
|
expect(screen.getByTestId("chat-ui")).toBeInTheDocument();
|
|
30
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
|
+
});
|
|
31
121
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
let capturedGetErrorMessage: ((error: unknown) => string) | undefined;
|
|
4
|
+
|
|
3
5
|
vi.mock("ai", () => ({
|
|
4
6
|
streamText: vi.fn(() => ({
|
|
5
|
-
toDataStreamResponse: vi.fn((
|
|
7
|
+
toDataStreamResponse: vi.fn((opts?: { getErrorMessage?: (e: unknown) => string }) => {
|
|
8
|
+
capturedGetErrorMessage = opts?.getErrorMessage;
|
|
9
|
+
return new Response("data: done\n\n");
|
|
10
|
+
}),
|
|
6
11
|
})),
|
|
7
12
|
}));
|
|
8
13
|
|
|
@@ -11,9 +16,67 @@ vi.mock("@/shared/lib/ai", () => ({
|
|
|
11
16
|
}));
|
|
12
17
|
|
|
13
18
|
describe("chat route", () => {
|
|
14
|
-
it("
|
|
15
|
-
|
|
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
|
|
16
24
|
expect(mod.runtime).toBe("edge");
|
|
17
25
|
expect(typeof mod.POST).toBe("function");
|
|
18
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
|
+
});
|
|
19
82
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
});
|
|
@@ -18,7 +18,7 @@ export function ChatUi() {
|
|
|
18
18
|
message.role === "user" ? "bg-primary/10 ml-8" : "bg-muted mr-8"
|
|
19
19
|
}`}
|
|
20
20
|
>
|
|
21
|
-
<p className="text-sm">{message.content}</p>
|
|
21
|
+
<p className="text-sm">{message.content.replace(/<think>[\s\S]*?<\/think>\s*/g, "")}</p>
|
|
22
22
|
</div>
|
|
23
23
|
))}
|
|
24
24
|
{isLoading && (
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
const mockUpdateSession = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/lib/supabase/middleware", () => ({
|
|
7
|
+
updateSession: (...args: unknown[]) => mockUpdateSession(...args),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("middleware", () => {
|
|
11
|
+
it("delegates to updateSession", async () => {
|
|
12
|
+
// Arrange
|
|
13
|
+
const mockResponse = new Response(null, { status: 200 });
|
|
14
|
+
mockUpdateSession.mockResolvedValue(mockResponse);
|
|
15
|
+
const { middleware } = await import("../../middleware");
|
|
16
|
+
const req = new NextRequest("http://localhost/dashboard");
|
|
17
|
+
|
|
18
|
+
// Act
|
|
19
|
+
const response = await middleware(req);
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(mockUpdateSession).toHaveBeenCalledWith(req);
|
|
23
|
+
expect(response).toBe(mockResponse);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("exports a matcher config", async () => {
|
|
27
|
+
// Arrange + Act
|
|
28
|
+
const { config } = await import("../../middleware");
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(config.matcher).toBeDefined();
|
|
32
|
+
expect(Array.isArray(config.matcher)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -4,16 +4,29 @@ import { insertProfileSchema, selectProfileSchema } from "../db/schema";
|
|
|
4
4
|
|
|
5
5
|
describe("profiles schema", () => {
|
|
6
6
|
it("insertProfileSchema validates required fields", () => {
|
|
7
|
-
|
|
7
|
+
// Arrange + Act
|
|
8
|
+
const result = insertProfileSchema.safeParse({
|
|
9
|
+
id: "123e4567-e89b-12d3-a456-426614174000",
|
|
10
|
+
email: "test@example.com",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Assert
|
|
8
14
|
expect(result.success).toBe(true);
|
|
9
15
|
});
|
|
10
16
|
|
|
11
|
-
it("insertProfileSchema rejects invalid
|
|
12
|
-
|
|
17
|
+
it("insertProfileSchema rejects invalid uuid", () => {
|
|
18
|
+
// Arrange + Act
|
|
19
|
+
const result = insertProfileSchema.safeParse({
|
|
20
|
+
id: "not-a-uuid",
|
|
21
|
+
email: "test@example.com",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
13
25
|
expect(result.success).toBe(false);
|
|
14
26
|
});
|
|
15
27
|
|
|
16
28
|
it("selectProfileSchema is defined", () => {
|
|
29
|
+
// Arrange + Act + Assert
|
|
17
30
|
expect(selectProfileSchema).toBeDefined();
|
|
18
31
|
});
|
|
19
32
|
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetUser = vi.fn();
|
|
4
|
+
const mockCreateServerClient = vi.fn();
|
|
5
|
+
const mockNextResponseNext = vi.fn();
|
|
6
|
+
const mockNextResponseRedirect = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock("@supabase/ssr", () => ({
|
|
9
|
+
createServerClient: (...args: unknown[]) => mockCreateServerClient(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("next/server", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("next/server")>("next/server");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
NextResponse: {
|
|
17
|
+
next: (...args: unknown[]) => mockNextResponseNext(...args),
|
|
18
|
+
redirect: (...args: unknown[]) => mockNextResponseRedirect(...args),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
type CookieOptions = { path?: string; maxAge?: number };
|
|
24
|
+
type CookieItem = { name: string; value: string; options: CookieOptions };
|
|
25
|
+
type CookieCallbacks = {
|
|
26
|
+
getAll: () => CookieItem[];
|
|
27
|
+
setAll: (cookies: CookieItem[]) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function makeRequest(pathname: string) {
|
|
31
|
+
return {
|
|
32
|
+
nextUrl: {
|
|
33
|
+
pathname,
|
|
34
|
+
clone: () => ({ pathname, toString: () => `http://localhost${pathname}` }),
|
|
35
|
+
},
|
|
36
|
+
cookies: { getAll: vi.fn(() => []), set: vi.fn() },
|
|
37
|
+
headers: new Headers(),
|
|
38
|
+
url: `http://localhost${pathname}`,
|
|
39
|
+
} as unknown as import("next/server").NextRequest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("supabase middleware (updateSession)", () => {
|
|
43
|
+
let capturedCookieCallbacks: CookieCallbacks | undefined;
|
|
44
|
+
const fakeResponse = {
|
|
45
|
+
cookies: { set: vi.fn(), getAll: vi.fn(() => []) },
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: new Headers(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
capturedCookieCallbacks = undefined;
|
|
53
|
+
mockNextResponseNext.mockReturnValue(fakeResponse);
|
|
54
|
+
mockNextResponseRedirect.mockImplementation((url: unknown) => ({
|
|
55
|
+
status: 307,
|
|
56
|
+
headers: new Headers({ location: String(url) }),
|
|
57
|
+
}));
|
|
58
|
+
mockCreateServerClient.mockImplementation(
|
|
59
|
+
(_u: unknown, _k: unknown, opts: { cookies: CookieCallbacks }) => {
|
|
60
|
+
capturedCookieCallbacks = opts.cookies;
|
|
61
|
+
return { auth: { getUser: mockGetUser } };
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns nextResponse for authenticated user on protected route", async () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "user-1" } } });
|
|
69
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
70
|
+
const req = makeRequest("/dashboard");
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
const response = await updateSession(req);
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(response).toBeDefined();
|
|
77
|
+
expect(mockNextResponseRedirect).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("invokes getAll cookie callback", async () => {
|
|
81
|
+
// Arrange
|
|
82
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "user-1" } } });
|
|
83
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
84
|
+
const req = makeRequest("/dashboard");
|
|
85
|
+
await updateSession(req);
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
capturedCookieCallbacks!.getAll();
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(req.cookies.getAll).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("invokes setAll cookie callback and updates request + response cookies", async () => {
|
|
95
|
+
// Arrange
|
|
96
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "user-1" } } });
|
|
97
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
98
|
+
const req = makeRequest("/dashboard");
|
|
99
|
+
await updateSession(req);
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
capturedCookieCallbacks!.setAll([
|
|
103
|
+
{ name: "sb-token", value: "abc", options: { path: "/" } },
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(req.cookies.set).toHaveBeenCalledWith("sb-token", "abc");
|
|
108
|
+
expect(fakeResponse.cookies.set).toHaveBeenCalledWith("sb-token", "abc", { path: "/" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("redirects unauthenticated user from protected route", async () => {
|
|
112
|
+
// Arrange
|
|
113
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
114
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
115
|
+
const req = makeRequest("/dashboard");
|
|
116
|
+
|
|
117
|
+
// Act
|
|
118
|
+
await updateSession(req);
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(mockNextResponseRedirect).toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not redirect unauthenticated user on login route", async () => {
|
|
125
|
+
// Arrange
|
|
126
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
127
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
128
|
+
const req = makeRequest("/login");
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
await updateSession(req);
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(mockNextResponseRedirect).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not redirect unauthenticated user on root path", async () => {
|
|
138
|
+
// Arrange
|
|
139
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
140
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
141
|
+
const req = makeRequest("/");
|
|
142
|
+
|
|
143
|
+
// Act
|
|
144
|
+
await updateSession(req);
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(mockNextResponseRedirect).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not redirect on /api/auth routes", async () => {
|
|
151
|
+
// Arrange
|
|
152
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
153
|
+
const { updateSession } = await import("../lib/supabase/middleware");
|
|
154
|
+
const req = makeRequest("/api/auth/callback");
|
|
155
|
+
|
|
156
|
+
// Act
|
|
157
|
+
await updateSession(req);
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(mockNextResponseRedirect).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|