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
|
@@ -1,22 +1,95 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
const mockSet = vi.fn();
|
|
4
|
+
const mockGetAll = vi.fn().mockReturnValue([]);
|
|
5
|
+
const mockCreateServerClient = vi.fn();
|
|
6
|
+
|
|
3
7
|
vi.mock("@supabase/ssr", () => ({
|
|
4
|
-
createServerClient:
|
|
8
|
+
createServerClient: (...args: unknown[]) => mockCreateServerClient(...args),
|
|
5
9
|
}));
|
|
6
10
|
|
|
7
11
|
vi.mock("next/headers", () => ({
|
|
8
12
|
cookies: vi.fn(() =>
|
|
9
13
|
Promise.resolve({
|
|
10
|
-
getAll:
|
|
11
|
-
set:
|
|
14
|
+
getAll: mockGetAll,
|
|
15
|
+
set: mockSet,
|
|
12
16
|
})
|
|
13
17
|
),
|
|
14
18
|
}));
|
|
15
19
|
|
|
16
20
|
describe("supabase server", () => {
|
|
17
21
|
it("createClient returns a supabase client", async () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
mockCreateServerClient.mockReturnValue({ auth: {} });
|
|
18
24
|
const { createClient } = await import("../lib/supabase/server");
|
|
25
|
+
|
|
26
|
+
// Act
|
|
19
27
|
const client = await createClient();
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
20
30
|
expect(client).toBeDefined();
|
|
21
31
|
});
|
|
32
|
+
|
|
33
|
+
it("passes cookie getAll to server client config", async () => {
|
|
34
|
+
// Arrange
|
|
35
|
+
let capturedGetAll: (() => unknown) | undefined;
|
|
36
|
+
mockCreateServerClient.mockImplementation(
|
|
37
|
+
(_u: unknown, _k: unknown, opts: { cookies: { getAll: () => unknown } }) => {
|
|
38
|
+
capturedGetAll = opts.cookies.getAll;
|
|
39
|
+
return { auth: {} };
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
const { createClient } = await import("../lib/supabase/server");
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
await createClient();
|
|
46
|
+
capturedGetAll!();
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(mockGetAll).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("setAll sets cookies via cookieStore", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
let capturedSetAll:
|
|
55
|
+
| ((cookies: { name: string; value: string; options: object }[]) => void)
|
|
56
|
+
| undefined;
|
|
57
|
+
mockCreateServerClient.mockImplementation(
|
|
58
|
+
(_u: unknown, _k: unknown, opts: { cookies: { setAll: (c: { name: string; value: string; options: object }[]) => void } }) => {
|
|
59
|
+
capturedSetAll = opts.cookies.setAll;
|
|
60
|
+
return { auth: {} };
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
const { createClient } = await import("../lib/supabase/server");
|
|
64
|
+
await createClient();
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
capturedSetAll!([{ name: "token", value: "abc", options: { path: "/" } }]);
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(mockSet).toHaveBeenCalledWith("token", "abc", { path: "/" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("setAll silently swallows errors from server components", async () => {
|
|
74
|
+
// Arrange
|
|
75
|
+
mockSet.mockImplementation(() => {
|
|
76
|
+
throw new Error("Cannot set cookie");
|
|
77
|
+
});
|
|
78
|
+
let capturedSetAll:
|
|
79
|
+
| ((cookies: { name: string; value: string; options: object }[]) => void)
|
|
80
|
+
| undefined;
|
|
81
|
+
mockCreateServerClient.mockImplementation(
|
|
82
|
+
(_u: unknown, _k: unknown, opts: { cookies: { setAll: (c: { name: string; value: string; options: object }[]) => void } }) => {
|
|
83
|
+
capturedSetAll = opts.cookies.setAll;
|
|
84
|
+
return { auth: {} };
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
const { createClient } = await import("../lib/supabase/server");
|
|
88
|
+
await createClient();
|
|
89
|
+
|
|
90
|
+
// Act + Assert
|
|
91
|
+
expect(() =>
|
|
92
|
+
capturedSetAll!([{ name: "token", value: "abc", options: {} }])
|
|
93
|
+
).not.toThrow();
|
|
94
|
+
});
|
|
22
95
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { Button } from "../components/ui/button";
|
|
5
|
+
|
|
6
|
+
describe("Button", () => {
|
|
7
|
+
it("renders with default variant", () => {
|
|
8
|
+
// Arrange + Act
|
|
9
|
+
render(<Button>Click me</Button>);
|
|
10
|
+
|
|
11
|
+
// Assert
|
|
12
|
+
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders as disabled when disabled prop is set", () => {
|
|
16
|
+
// Arrange + Act
|
|
17
|
+
render(<Button disabled>Submit</Button>);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(screen.getByRole("button")).toBeDisabled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders with custom className", () => {
|
|
24
|
+
// Arrange + Act
|
|
25
|
+
render(<Button className="custom-class">Click</Button>);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(screen.getByRole("button")).toHaveClass("custom-class");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders as a Slot (asChild) when asChild is true", () => {
|
|
32
|
+
// Arrange + Act
|
|
33
|
+
render(
|
|
34
|
+
<Button asChild>
|
|
35
|
+
<a href="/home">Go home</a>
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(screen.getByRole("link", { name: /go home/i })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders with destructive variant", () => {
|
|
44
|
+
// Arrange + Act
|
|
45
|
+
render(<Button variant="destructive">Delete</Button>);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
const btn = screen.getByRole("button", { name: /delete/i });
|
|
49
|
+
expect(btn).toBeInTheDocument();
|
|
50
|
+
expect(btn.className).toContain("destructive");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardAction,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardFooter,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from "../components/ui/card";
|
|
13
|
+
|
|
14
|
+
describe("Card components", () => {
|
|
15
|
+
it("renders Card with children", () => {
|
|
16
|
+
// Arrange + Act
|
|
17
|
+
render(<Card data-testid="card">content</Card>);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(screen.getByTestId("card")).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders CardHeader", () => {
|
|
24
|
+
// Arrange + Act
|
|
25
|
+
render(<CardHeader data-testid="header">header</CardHeader>);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(screen.getByTestId("header")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders CardTitle", () => {
|
|
32
|
+
// Arrange + Act
|
|
33
|
+
render(<CardTitle>My Title</CardTitle>);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(screen.getByText("My Title")).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders CardDescription", () => {
|
|
40
|
+
// Arrange + Act
|
|
41
|
+
render(<CardDescription>My Description</CardDescription>);
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(screen.getByText("My Description")).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders CardContent", () => {
|
|
48
|
+
// Arrange + Act
|
|
49
|
+
render(<CardContent data-testid="content">body</CardContent>);
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
expect(screen.getByTestId("content")).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders CardAction", () => {
|
|
56
|
+
// Arrange + Act
|
|
57
|
+
render(<CardAction data-testid="action">action</CardAction>);
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
60
|
+
expect(screen.getByTestId("action")).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders CardFooter", () => {
|
|
64
|
+
// Arrange + Act
|
|
65
|
+
render(<CardFooter data-testid="footer">footer</CardFooter>);
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(screen.getByTestId("footer")).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("applies custom className to Card", () => {
|
|
72
|
+
// Arrange + Act
|
|
73
|
+
render(<Card className="custom-class" data-testid="card">content</Card>);
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(screen.getByTestId("card")).toHaveClass("custom-class");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
describe("cn", () => {
|
|
6
|
+
it("merges multiple class names", () => {
|
|
7
|
+
// Arrange + Act + Assert
|
|
8
|
+
expect(cn("foo", "bar")).toBe("foo bar");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("omits falsy conditional classes", () => {
|
|
12
|
+
// Arrange + Act + Assert
|
|
13
|
+
expect(cn("foo", false && "bar", "baz")).toBe("foo baz");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("handles undefined values", () => {
|
|
17
|
+
// Arrange + Act + Assert
|
|
18
|
+
expect(cn("foo", undefined, "bar")).toBe("foo bar");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("deduplicates conflicting tailwind classes (last one wins)", () => {
|
|
22
|
+
// Arrange + Act + Assert
|
|
23
|
+
expect(cn("p-2", "p-4")).toBe("p-4");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles empty call", () => {
|
|
27
|
+
// Arrange + Act + Assert
|
|
28
|
+
expect(cn()).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
2
2
|
|
|
3
3
|
const provider = createOpenAI({
|
|
4
|
-
baseURL: process.env.
|
|
4
|
+
baseURL: process.env.AI_GATEWAY_URL ?? "https://gateway.ai.vercel.app/v1",
|
|
5
5
|
apiKey: process.env.AI_API_KEY ?? "",
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
-
export const aiModel = provider(process.env.AI_MODEL ?? "
|
|
8
|
+
export const aiModel = provider(process.env.AI_MODEL ?? "google/gemini-2.0-flash");
|
|
@@ -5,8 +5,10 @@ import { resolve } from "path";
|
|
|
5
5
|
export default defineConfig({
|
|
6
6
|
plugins: [react()],
|
|
7
7
|
test: {
|
|
8
|
+
globals: true,
|
|
8
9
|
environment: "jsdom",
|
|
9
10
|
setupFiles: ["./src/test-setup.ts"],
|
|
11
|
+
exclude: ["node_modules/**", "src/e2e/**"],
|
|
10
12
|
coverage: {
|
|
11
13
|
provider: "v8",
|
|
12
14
|
thresholds: {
|
|
@@ -22,6 +24,9 @@ export default defineConfig({
|
|
|
22
24
|
"**/*.config.*",
|
|
23
25
|
"**/index.ts",
|
|
24
26
|
"src/app/globals.css",
|
|
27
|
+
".next/**",
|
|
28
|
+
"next-env.d.ts",
|
|
29
|
+
"**/*.d.ts",
|
|
25
30
|
],
|
|
26
31
|
},
|
|
27
32
|
},
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("@/shared/lib/minimax-media", () => ({
|
|
4
|
-
synthesizeSpeech: vi.fn().mockResolvedValue("https://example.com/audio.mp3"),
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
describe("tts route", () => {
|
|
8
|
-
it("module is importable", async () => {
|
|
9
|
-
const mod = await import("../api/route");
|
|
10
|
-
expect(mod.runtime).toBe("edge");
|
|
11
|
-
expect(typeof mod.POST).toBe("function");
|
|
12
|
-
});
|
|
13
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { TtsPlayer } from "../components/tts-player";
|
|
5
|
-
|
|
6
|
-
describe("TtsPlayer", () => {
|
|
7
|
-
it("renders text input", () => {
|
|
8
|
-
render(<TtsPlayer />);
|
|
9
|
-
expect(screen.getByPlaceholderText(/enter text to speak/i)).toBeInTheDocument();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("renders speak button", () => {
|
|
13
|
-
render(<TtsPlayer />);
|
|
14
|
-
expect(screen.getByRole("button", { name: /speak/i })).toBeInTheDocument();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("button disabled when input empty", () => {
|
|
18
|
-
render(<TtsPlayer />);
|
|
19
|
-
expect(screen.getByRole("button", { name: /speak/i })).toBeDisabled();
|
|
20
|
-
});
|
|
21
|
-
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { synthesizeSpeech } from "@/shared/lib/minimax-media";
|
|
2
|
-
|
|
3
|
-
export const runtime = "edge";
|
|
4
|
-
|
|
5
|
-
export async function POST(req: Request) {
|
|
6
|
-
const { text, voiceId } = (await req.json()) as { text: string; voiceId?: string };
|
|
7
|
-
|
|
8
|
-
if (!text || typeof text !== "string") {
|
|
9
|
-
return Response.json({ error: "text is required" }, { status: 400 });
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const audioFile = await synthesizeSpeech(text, voiceId);
|
|
13
|
-
return Response.json({ audioFile });
|
|
14
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
|
|
5
|
-
export function TtsPlayer() {
|
|
6
|
-
const [text, setText] = useState("");
|
|
7
|
-
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
8
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
9
|
-
const [error, setError] = useState<string | null>(null);
|
|
10
|
-
|
|
11
|
-
const handleSubmit = async (e: React.SyntheticEvent) => {
|
|
12
|
-
e.preventDefault();
|
|
13
|
-
setIsLoading(true);
|
|
14
|
-
setError(null);
|
|
15
|
-
|
|
16
|
-
const response = await fetch("/features/tts/api", {
|
|
17
|
-
method: "POST",
|
|
18
|
-
headers: { "Content-Type": "application/json" },
|
|
19
|
-
body: JSON.stringify({ text }),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
if (!response.ok) {
|
|
23
|
-
setError("Failed to synthesize speech");
|
|
24
|
-
setIsLoading(false);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const data = (await response.json()) as { audioFile: string };
|
|
29
|
-
setAudioUrl(data.audioFile);
|
|
30
|
-
setIsLoading(false);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<div className="space-y-4" data-testid="tts-player">
|
|
35
|
-
<form onSubmit={(e) => { void handleSubmit(e); }} className="flex gap-2">
|
|
36
|
-
<textarea
|
|
37
|
-
value={text}
|
|
38
|
-
onChange={(e) => { setText(e.target.value); }}
|
|
39
|
-
placeholder="Enter text to speak..."
|
|
40
|
-
className="flex-1 rounded border px-3 py-2"
|
|
41
|
-
rows={3}
|
|
42
|
-
/>
|
|
43
|
-
<button
|
|
44
|
-
type="submit"
|
|
45
|
-
disabled={isLoading || !text.trim()}
|
|
46
|
-
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
47
|
-
>
|
|
48
|
-
{isLoading ? "Synthesizing..." : "Speak"}
|
|
49
|
-
</button>
|
|
50
|
-
</form>
|
|
51
|
-
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
52
|
-
{audioUrl && (
|
|
53
|
-
<audio controls src={audioUrl} className="w-full">
|
|
54
|
-
<track kind="captions" />
|
|
55
|
-
</audio>
|
|
56
|
-
)}
|
|
57
|
-
</div>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("@/shared/lib/minimax-media", () => ({
|
|
4
|
-
generateVideo: vi.fn().mockResolvedValue("task-abc"),
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
describe("video route", () => {
|
|
8
|
-
it("module is importable", async () => {
|
|
9
|
-
const mod = await import("../api/route");
|
|
10
|
-
expect(mod.runtime).toBe("edge");
|
|
11
|
-
expect(typeof mod.POST).toBe("function");
|
|
12
|
-
});
|
|
13
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { VideoGenerator } from "../components/video-generator";
|
|
5
|
-
|
|
6
|
-
describe("VideoGenerator", () => {
|
|
7
|
-
it("renders prompt input", () => {
|
|
8
|
-
render(<VideoGenerator />);
|
|
9
|
-
expect(screen.getByPlaceholderText(/describe a video/i)).toBeInTheDocument();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("renders generate button", () => {
|
|
13
|
-
render(<VideoGenerator />);
|
|
14
|
-
expect(screen.getByRole("button", { name: /generate/i })).toBeInTheDocument();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("button disabled when input empty", () => {
|
|
18
|
-
render(<VideoGenerator />);
|
|
19
|
-
expect(screen.getByRole("button", { name: /generate/i })).toBeDisabled();
|
|
20
|
-
});
|
|
21
|
-
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { generateVideo } from "@/shared/lib/minimax-media";
|
|
2
|
-
|
|
3
|
-
export const runtime = "edge";
|
|
4
|
-
|
|
5
|
-
export async function POST(req: Request) {
|
|
6
|
-
const { prompt } = (await req.json()) as { prompt: string };
|
|
7
|
-
|
|
8
|
-
if (!prompt || typeof prompt !== "string") {
|
|
9
|
-
return Response.json({ error: "prompt is required" }, { status: 400 });
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const taskId = await generateVideo(prompt);
|
|
13
|
-
return Response.json({ taskId });
|
|
14
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
|
|
5
|
-
export function VideoGenerator() {
|
|
6
|
-
const [prompt, setPrompt] = useState("");
|
|
7
|
-
const [taskId, setTaskId] = useState<string | null>(null);
|
|
8
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
9
|
-
const [error, setError] = useState<string | null>(null);
|
|
10
|
-
|
|
11
|
-
const handleSubmit = async (e: React.SyntheticEvent) => {
|
|
12
|
-
e.preventDefault();
|
|
13
|
-
setIsLoading(true);
|
|
14
|
-
setError(null);
|
|
15
|
-
|
|
16
|
-
const response = await fetch("/features/video/api", {
|
|
17
|
-
method: "POST",
|
|
18
|
-
headers: { "Content-Type": "application/json" },
|
|
19
|
-
body: JSON.stringify({ prompt }),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
if (!response.ok) {
|
|
23
|
-
setError("Failed to generate video");
|
|
24
|
-
setIsLoading(false);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const data = (await response.json()) as { taskId: string };
|
|
29
|
-
setTaskId(data.taskId);
|
|
30
|
-
setIsLoading(false);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<div className="space-y-4" data-testid="video-generator">
|
|
35
|
-
<form onSubmit={(e) => { void handleSubmit(e); }} className="flex gap-2">
|
|
36
|
-
<input
|
|
37
|
-
value={prompt}
|
|
38
|
-
onChange={(e) => { setPrompt(e.target.value); }}
|
|
39
|
-
placeholder="Describe a video..."
|
|
40
|
-
className="flex-1 rounded border px-3 py-2"
|
|
41
|
-
/>
|
|
42
|
-
<button
|
|
43
|
-
type="submit"
|
|
44
|
-
disabled={isLoading || !prompt.trim()}
|
|
45
|
-
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
46
|
-
>
|
|
47
|
-
{isLoading ? "Generating..." : "Generate"}
|
|
48
|
-
</button>
|
|
49
|
-
</form>
|
|
50
|
-
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
51
|
-
{taskId && (
|
|
52
|
-
<p className="text-sm text-muted-foreground">Task ID: {taskId}</p>
|
|
53
|
-
)}
|
|
54
|
-
</div>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { generateVideo, synthesizeSpeech } from "../lib/minimax-media";
|
|
4
|
-
|
|
5
|
-
const mockFetch = vi.fn();
|
|
6
|
-
global.fetch = mockFetch;
|
|
7
|
-
|
|
8
|
-
describe("generateVideo", () => {
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
vi.clearAllMocks();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("returns task_id when API responds successfully", async () => {
|
|
14
|
-
// Arrange
|
|
15
|
-
mockFetch.mockResolvedValue({
|
|
16
|
-
ok: true,
|
|
17
|
-
json: () => Promise.resolve({ task_id: "abc123" }),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
// Act
|
|
21
|
-
const taskId = await generateVideo("A cat on the moon");
|
|
22
|
-
|
|
23
|
-
// Assert
|
|
24
|
-
expect(taskId).toBe("abc123");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("throws when API returns non-ok response", async () => {
|
|
28
|
-
// Arrange
|
|
29
|
-
mockFetch.mockResolvedValue({ ok: false, statusText: "Bad Request" });
|
|
30
|
-
|
|
31
|
-
// Act + Assert
|
|
32
|
-
await expect(generateVideo("test")).rejects.toThrow("Video generation failed");
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("synthesizeSpeech", () => {
|
|
37
|
-
it("returns audio_file URL when API responds successfully", async () => {
|
|
38
|
-
// Arrange
|
|
39
|
-
mockFetch.mockResolvedValue({
|
|
40
|
-
ok: true,
|
|
41
|
-
json: () => Promise.resolve({ audio_file: "https://example.com/audio.mp3" }),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// Act
|
|
45
|
-
const url = await synthesizeSpeech("Hello world");
|
|
46
|
-
|
|
47
|
-
// Assert
|
|
48
|
-
expect(url).toBe("https://example.com/audio.mp3");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("throws when API returns non-ok response", async () => {
|
|
52
|
-
// Arrange
|
|
53
|
-
mockFetch.mockResolvedValue({ ok: false, statusText: "Server Error" });
|
|
54
|
-
|
|
55
|
-
// Act + Assert
|
|
56
|
-
await expect(synthesizeSpeech("test")).rejects.toThrow("TTS failed");
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
const MINIMAX_API_BASE = "https://api.minimaxi.chat/v1";
|
|
2
|
-
|
|
3
|
-
interface VideoGenerationRequest {
|
|
4
|
-
model: string;
|
|
5
|
-
prompt: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface VideoGenerationResponse {
|
|
9
|
-
task_id: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface TtsRequest {
|
|
13
|
-
model: string;
|
|
14
|
-
text: string;
|
|
15
|
-
voice_id: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface TtsResponse {
|
|
19
|
-
audio_file: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function generateVideo(prompt: string): Promise<string> {
|
|
23
|
-
const response = await fetch(`${MINIMAX_API_BASE}/video_generation`, {
|
|
24
|
-
method: "POST",
|
|
25
|
-
headers: {
|
|
26
|
-
Authorization: `Bearer ${process.env.MINIMAX_API_KEY ?? ""}`,
|
|
27
|
-
"Content-Type": "application/json",
|
|
28
|
-
},
|
|
29
|
-
body: JSON.stringify({
|
|
30
|
-
model: "video-01",
|
|
31
|
-
prompt,
|
|
32
|
-
} satisfies VideoGenerationRequest),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
if (!response.ok) {
|
|
36
|
-
throw new Error(`Video generation failed: ${response.statusText}`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const data = (await response.json()) as VideoGenerationResponse;
|
|
40
|
-
return data.task_id;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function synthesizeSpeech(text: string, voiceId = "female-shaonv"): Promise<string> {
|
|
44
|
-
const response = await fetch(`${MINIMAX_API_BASE}/t2a_v2`, {
|
|
45
|
-
method: "POST",
|
|
46
|
-
headers: {
|
|
47
|
-
Authorization: `Bearer ${process.env.MINIMAX_API_KEY ?? ""}`,
|
|
48
|
-
"Content-Type": "application/json",
|
|
49
|
-
},
|
|
50
|
-
body: JSON.stringify({
|
|
51
|
-
model: "speech-02-hd",
|
|
52
|
-
text,
|
|
53
|
-
voice_id: voiceId,
|
|
54
|
-
} satisfies TtsRequest),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
if (!response.ok) {
|
|
58
|
-
throw new Error(`TTS failed: ${response.statusText}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const data = (await response.json()) as TtsResponse;
|
|
62
|
-
return data.audio_file;
|
|
63
|
-
}
|