nextjs-hackathon-stack 0.1.11 → 0.1.12
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 +7 -0
- package/package.json +1 -1
- package/template/.husky/pre-commit +1 -0
- package/template/_env.example +4 -4
- 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/features/tts/__tests__/route.test.ts +78 -3
- package/template/src/features/tts/__tests__/tts-player.test.tsx +65 -3
- package/template/src/features/video/__tests__/route.test.ts +78 -3
- package/template/src/features/video/__tests__/video-generator.test.tsx +65 -3
- 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/dist/index.js
CHANGED
|
@@ -112,6 +112,13 @@ async function scaffold(projectName, skipInstall) {
|
|
|
112
112
|
p3.log.error(stderr || "pnpm install failed");
|
|
113
113
|
p3.log.info("You can install manually: cd " + projectName + " && pnpm install");
|
|
114
114
|
}
|
|
115
|
+
spinner2.start("Setting up husky pre-commit hooks");
|
|
116
|
+
try {
|
|
117
|
+
execSync("pnpm exec husky", { cwd: targetDir, stdio: "pipe" });
|
|
118
|
+
spinner2.stop("Husky pre-commit hooks set up");
|
|
119
|
+
} catch {
|
|
120
|
+
spinner2.stop("Husky setup failed \u2014 run manually: pnpm exec husky");
|
|
121
|
+
}
|
|
115
122
|
spinner2.start("Initializing shadcn/ui");
|
|
116
123
|
try {
|
|
117
124
|
execSync("npx shadcn@latest init --yes --defaults --force --silent", {
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vitest run --coverage
|
package/template/_env.example
CHANGED
|
@@ -11,11 +11,11 @@ DATABASE_URL=postgresql://postgres:[password]@db.your-project-id.supabase.co:543
|
|
|
11
11
|
|
|
12
12
|
# AI Provider — defaults to MiniMax direct API
|
|
13
13
|
AI_API_KEY=your-api-key
|
|
14
|
-
# Optional: override base URL (default: https://api.
|
|
14
|
+
# Optional: override base URL (default: https://api.minimax.io/v1)
|
|
15
15
|
# For Vercel Gateway: https://ai-gateway.vercel.sh/v1
|
|
16
|
-
# AI_BASE_URL=https://api.
|
|
17
|
-
# Optional: override model (default: MiniMax-
|
|
18
|
-
# AI_MODEL=MiniMax-
|
|
16
|
+
# AI_BASE_URL=https://api.minimax.io/v1
|
|
17
|
+
# Optional: override model (default: MiniMax-M2.7)
|
|
18
|
+
# AI_MODEL=MiniMax-M2.7
|
|
19
19
|
|
|
20
20
|
# =============================================================================
|
|
21
21
|
# OPTIONAL
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"test:e2e": "playwright test",
|
|
17
17
|
"db:generate": "drizzle-kit generate",
|
|
18
18
|
"db:migrate": "drizzle-kit migrate",
|
|
19
|
-
"db:studio": "drizzle-kit studio"
|
|
19
|
+
"db:studio": "drizzle-kit studio",
|
|
20
|
+
"prepare": "husky"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"next": "^15",
|
|
@@ -64,6 +65,11 @@
|
|
|
64
65
|
"eslint-plugin-react-hooks": "^5",
|
|
65
66
|
"eslint-plugin-import-x": "^4",
|
|
66
67
|
"eslint-plugin-vitest": "^0.5",
|
|
67
|
-
"eslint-plugin-playwright": "^2"
|
|
68
|
+
"eslint-plugin-playwright": "^2",
|
|
69
|
+
"husky": "^9",
|
|
70
|
+
"lint-staged": "^15"
|
|
71
|
+
},
|
|
72
|
+
"lint-staged": {
|
|
73
|
+
"*.{ts,tsx}": ["vitest related --run"]
|
|
68
74
|
}
|
|
69
75
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockExchangeCodeForSession = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
6
|
+
createClient: vi.fn(() =>
|
|
7
|
+
Promise.resolve({
|
|
8
|
+
auth: { exchangeCodeForSession: mockExchangeCodeForSession },
|
|
9
|
+
})
|
|
10
|
+
),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("auth callback route", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("redirects to /login with error when code is missing", async () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
21
|
+
const req = new Request("http://localhost/api/auth/callback");
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const response = await GET(req);
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(response.status).toBe(307);
|
|
28
|
+
expect(response.headers.get("location")).toContain("/login?error=no_code");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("redirects to /login with error when exchange fails", async () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: { message: "bad code" } });
|
|
34
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
35
|
+
const req = new Request("http://localhost/api/auth/callback?code=bad-code");
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
const response = await GET(req);
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(response.status).toBe(307);
|
|
42
|
+
expect(response.headers.get("location")).toContain("/login?error=auth_error");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("redirects to / on successful exchange", async () => {
|
|
46
|
+
// Arrange
|
|
47
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
|
|
48
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
49
|
+
const req = new Request("http://localhost/api/auth/callback?code=valid-code");
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
const response = await GET(req);
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(response.status).toBe(307);
|
|
56
|
+
expect(response.headers.get("location")).toBe("http://localhost/");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("redirects to next param on successful exchange", async () => {
|
|
60
|
+
// Arrange
|
|
61
|
+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
|
|
62
|
+
const { GET } = await import("../api/auth/callback/route");
|
|
63
|
+
const req = new Request("http://localhost/api/auth/callback?code=valid-code&next=/dashboard");
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const response = await GET(req);
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(response.status).toBe(307);
|
|
70
|
+
expect(response.headers.get("location")).toContain("/dashboard");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import ErrorPage from "../error";
|
|
6
|
+
|
|
7
|
+
describe("ErrorPage", () => {
|
|
8
|
+
it("renders error heading", () => {
|
|
9
|
+
// Arrange
|
|
10
|
+
const error = new Error("Something failed");
|
|
11
|
+
const reset = vi.fn();
|
|
12
|
+
|
|
13
|
+
// Act
|
|
14
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders error message", () => {
|
|
21
|
+
// Arrange
|
|
22
|
+
const error = new Error("Specific error message");
|
|
23
|
+
const reset = vi.fn();
|
|
24
|
+
|
|
25
|
+
// Act
|
|
26
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(screen.getByText("Specific error message")).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders Try again button", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const error = new Error("test");
|
|
35
|
+
const reset = vi.fn();
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("calls reset when Try again is clicked", async () => {
|
|
45
|
+
// Arrange
|
|
46
|
+
const error = new Error("test");
|
|
47
|
+
const reset = vi.fn();
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
await user.click(screen.getByRole("button", { name: /try again/i }));
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(reset).toHaveBeenCalledOnce();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("logs error to console on mount", () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const error = new Error("logged error");
|
|
61
|
+
const reset = vi.fn();
|
|
62
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
render(<ErrorPage error={error} reset={reset} />);
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(consoleSpy).toHaveBeenCalledWith(error);
|
|
69
|
+
consoleSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import RootLayout from "../layout";
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/components/providers", () => ({
|
|
7
|
+
Providers: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("RootLayout", () => {
|
|
11
|
+
it("renders children inside body", () => {
|
|
12
|
+
// Arrange + Act
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<RootLayout>
|
|
15
|
+
<p data-testid="child-content">Hello</p>
|
|
16
|
+
</RootLayout>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(container.querySelector("[data-testid='child-content']")).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import LoginPage from "../(auth)/login/page";
|
|
5
|
+
|
|
6
|
+
vi.mock("@/features/auth/components/login-form", () => ({
|
|
7
|
+
LoginForm: () => <div data-testid="login-form" />,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("LoginPage", () => {
|
|
11
|
+
it("renders the welcome heading", () => {
|
|
12
|
+
// Arrange + Act
|
|
13
|
+
render(<LoginPage />);
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(screen.getByText(/welcome back/i)).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the LoginForm component", () => {
|
|
20
|
+
// Arrange + Act
|
|
21
|
+
render(<LoginPage />);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders the sign-in description", () => {
|
|
28
|
+
// Arrange + Act
|
|
29
|
+
render(<LoginPage />);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(screen.getByText(/sign in to your account/i)).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetUser = vi.fn();
|
|
4
|
+
const mockRedirect = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
7
|
+
createClient: vi.fn(() =>
|
|
8
|
+
Promise.resolve({
|
|
9
|
+
auth: { getUser: mockGetUser },
|
|
10
|
+
})
|
|
11
|
+
),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
redirect: (url: string) => mockRedirect(url),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("ProtectedLayout", () => {
|
|
19
|
+
it("renders children when user is authenticated", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
mockGetUser.mockResolvedValue({ data: { user: { email: "user@example.com" } } });
|
|
22
|
+
const { default: ProtectedLayout } = await import("../(protected)/layout");
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const result = await ProtectedLayout({ children: <div data-testid="child" /> });
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(result).toBeTruthy();
|
|
29
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("redirects to /login when user is not authenticated", async () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
35
|
+
const { default: ProtectedLayout } = await import("../(protected)/layout");
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
await ProtectedLayout({ children: <div /> });
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetUser = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("@/shared/lib/supabase/server", () => ({
|
|
6
|
+
createClient: vi.fn(() =>
|
|
7
|
+
Promise.resolve({
|
|
8
|
+
auth: { getUser: mockGetUser },
|
|
9
|
+
})
|
|
10
|
+
),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@/features/chat/components/chat-ui", () => ({
|
|
14
|
+
ChatUi: () => <div data-testid="chat-ui" />,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe("HomePage (protected)", () => {
|
|
18
|
+
it("renders welcome message with user email", async () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
mockGetUser.mockResolvedValue({ data: { user: { email: "user@example.com" } } });
|
|
21
|
+
const { default: HomePage } = await import("../(protected)/page");
|
|
22
|
+
|
|
23
|
+
// Act
|
|
24
|
+
const result = await HomePage();
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(result).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders with null user gracefully", async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
33
|
+
const { default: HomePage } = await import("../(protected)/page");
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const result = await HomePage();
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(result).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -16,7 +16,9 @@ export async function POST(req: Request) {
|
|
|
16
16
|
return result.toDataStreamResponse({
|
|
17
17
|
getErrorMessage: (error) => {
|
|
18
18
|
console.error("[chat] stream error:", error);
|
|
19
|
-
|
|
19
|
+
if (error instanceof Error) return error.message;
|
|
20
|
+
if (typeof error === "object" && error !== null) return JSON.stringify(error);
|
|
21
|
+
return String(error);
|
|
20
22
|
},
|
|
21
23
|
});
|
|
22
24
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { LoginForm } from "../components/login-form";
|
|
6
6
|
|
|
7
|
+
const mockLoginAction = vi.fn().mockResolvedValue({ status: "idle" });
|
|
8
|
+
|
|
7
9
|
vi.mock("../actions/login.action", () => ({
|
|
8
|
-
loginAction:
|
|
10
|
+
loginAction: (...args: unknown[]) => mockLoginAction(...args),
|
|
9
11
|
}));
|
|
10
12
|
|
|
11
13
|
describe("LoginForm", () => {
|
|
12
14
|
beforeEach(() => {
|
|
13
15
|
vi.clearAllMocks();
|
|
16
|
+
mockLoginAction.mockResolvedValue({ status: "idle" });
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
it("renders email and password fields", () => {
|
|
@@ -41,4 +44,52 @@ describe("LoginForm", () => {
|
|
|
41
44
|
// Assert
|
|
42
45
|
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
|
|
43
46
|
});
|
|
47
|
+
|
|
48
|
+
it("shows validation error for short password", async () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
render(<LoginForm />);
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
55
|
+
await user.type(screen.getByLabelText(/password/i), "short");
|
|
56
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("submits with valid credentials and calls loginAction", async () => {
|
|
63
|
+
// Arrange
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(<LoginForm />);
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
69
|
+
await user.type(screen.getByLabelText(/password/i), "password123");
|
|
70
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(mockLoginAction).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("displays server-side error message when action returns error", async () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
mockLoginAction.mockResolvedValue({ status: "error", message: "Invalid credentials" });
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
render(<LoginForm />);
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
await user.type(screen.getByLabelText(/email/i), "user@example.com");
|
|
86
|
+
await user.type(screen.getByLabelText(/password/i), "password123");
|
|
87
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
44
95
|
});
|
|
@@ -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 (
|