Skip to content

Commit 7673714

Browse files
authored
Merge pull request #828 from anonfedora/fix/clean-issues-785-771-751-769
features
2 parents eebe7e6 + 79f82fb commit 7673714

15 files changed

Lines changed: 470 additions & 101 deletions

File tree

__mocks__/app/store/api.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Jest mock for app/store/api.ts
3+
*
4+
* This mock is automatically used by Jest when tests import from ./api.
5+
* It provides predictable, deterministic data for testing without making real API calls.
6+
*/
7+
8+
import type {
9+
DashboardStats,
10+
RevenuePoint,
11+
Project,
12+
UserProfile,
13+
EarningsBreakdownItem,
14+
} from "../../../app/store/types";
15+
16+
export async function fetchDashboardFromAPI(): Promise<{
17+
stats: DashboardStats;
18+
revenueTrend: RevenuePoint[];
19+
recentProjects: Project[];
20+
}> {
21+
return {
22+
stats: {
23+
earnings: {
24+
total: "$1,234.56",
25+
trend: 12.5,
26+
trendLabel: "up",
27+
},
28+
clips: {
29+
total: 42,
30+
trend: 8.3,
31+
trendLabel: "up",
32+
},
33+
platforms: {
34+
total: 3,
35+
trend: 0,
36+
trendLabel: "same",
37+
},
38+
},
39+
revenueTrend: [
40+
{ date: "2024-01-01", amount: 100 },
41+
{ date: "2024-01-02", amount: 150 },
42+
{ date: "2024-01-03", amount: 200 },
43+
],
44+
recentProjects: [
45+
{
46+
id: "mock-project-1",
47+
title: "Test Project 1",
48+
clipsGenerated: 10,
49+
status: "completed",
50+
},
51+
{
52+
id: "mock-project-2",
53+
title: "Test Project 2",
54+
clipsGenerated: 5,
55+
status: "processing",
56+
},
57+
],
58+
};
59+
}
60+
61+
export async function fetchUserFromAPI(): Promise<UserProfile> {
62+
return {
63+
id: "mock-user-123",
64+
name: "Test User",
65+
email: "test@example.com",
66+
avatarUrl: null,
67+
plan: "free",
68+
planUsagePercent: 25,
69+
transformQuotaRemaining: 10,
70+
};
71+
}
72+
73+
export async function fetchEarningsFromAPI(): Promise<{
74+
totalEarnings: string;
75+
totalTrend: number;
76+
trendLabel: string;
77+
totalFiat: { value: string; change: number };
78+
cryptoRevenue: { value: string; change: number };
79+
pendingPayouts: { value: string; change: number };
80+
breakdown: EarningsBreakdownItem[];
81+
}> {
82+
return {
83+
totalEarnings: "$1,234.56",
84+
totalTrend: 12.5,
85+
trendLabel: "up",
86+
totalFiat: { value: "$1,000.00", change: 10 },
87+
cryptoRevenue: { value: "50 XLM", change: 15 },
88+
pendingPayouts: { value: "$234.56", change: 5 },
89+
breakdown: [
90+
{
91+
id: "earnings-1",
92+
label: "TikTok",
93+
amount: 500,
94+
date: "2024-01-01",
95+
platform: "tiktok",
96+
},
97+
{
98+
id: "earnings-2",
99+
label: "Instagram",
100+
amount: 300,
101+
date: "2024-01-02",
102+
platform: "instagram",
103+
},
104+
{
105+
id: "earnings-3",
106+
label: "YouTube",
107+
amount: 434.56,
108+
date: "2024-01-03",
109+
platform: "youtube",
110+
},
111+
],
112+
};
113+
}

app/api/clips/post/route.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
3-
const ALLOWED_PLATFORMS = ["youtube", "instagram", "tiktok", "twitter"] as const;
4-
type Platform = (typeof ALLOWED_PLATFORMS)[number];
5-
6-
interface PostClipRequest {
7-
clipIds: string[];
8-
platforms: string[];
9-
}
2+
import { postClipBodySchema } from "../../schemas/index";
103

114
function mockUpload(platform: string, clipId: string): { ok: boolean; postId?: string; error?: string } {
125
const success = Math.random() > 0.2; // 80% success rate for mock
@@ -17,25 +10,24 @@ function mockUpload(platform: string, clipId: string): { ok: boolean; postId?: s
1710
}
1811

1912
export async function POST(req: NextRequest) {
20-
const body: PostClipRequest = await req.json().catch(() => ({ clipIds: [], platforms: [] }));
21-
22-
if (!Array.isArray(body.clipIds) || body.clipIds.length === 0) {
23-
return NextResponse.json({ error: "clipIds must be a non-empty array" }, { status: 400 });
24-
}
25-
if (!Array.isArray(body.platforms) || body.platforms.length === 0) {
26-
return NextResponse.json({ error: "platforms must be a non-empty array" }, { status: 400 });
13+
const rawBody = await req.json().catch(() => ({}));
14+
15+
// Validate request body with Zod
16+
const bodyValidation = postClipBodySchema.safeParse(rawBody);
17+
if (!bodyValidation.success) {
18+
return NextResponse.json(
19+
{ error: "Validation failed", issues: bodyValidation.error.issues },
20+
{ status: 400 }
21+
);
2722
}
2823

29-
const invalid = body.platforms.filter((p) => !ALLOWED_PLATFORMS.includes(p as Platform));
30-
if (invalid.length > 0) {
31-
return NextResponse.json({ error: `Invalid platforms: ${invalid.join(", ")}` }, { status: 400 });
32-
}
24+
const { clipIds, platforms } = bodyValidation.data;
3325

3426
const posted: { clipId: string; platform: string; postId: string; url: string }[] = [];
3527
const failed: { clipId: string; platform: string; error: string }[] = [];
3628

37-
for (const clipId of body.clipIds) {
38-
for (const platform of body.platforms) {
29+
for (const clipId of clipIds) {
30+
for (const platform of platforms) {
3931
const result = mockUpload(platform, clipId);
4032
if (result.ok && result.postId) {
4133
posted.push({

app/api/clips/route.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { auth } from "@/app/lib/auth";
33
import { clipsStore } from "./clipsStore";
44
import type { ApiResponse } from "../types";
5+
import { getClipsQuerySchema } from "../schemas/index";
56

67
export async function GET(request: NextRequest) {
78
const session = await auth();
@@ -10,13 +11,24 @@ export async function GET(request: NextRequest) {
1011
}
1112

1213
const { searchParams } = new URL(request.url);
13-
const page = parseInt(searchParams.get("page") || "1", 10);
14-
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
15-
const status = searchParams.get("status") || "";
16-
const style = searchParams.get("style") || "";
17-
// Optional filters
18-
const viralityParams = searchParams.getAll("virality");
19-
const virality = viralityParams.length > 0 ? viralityParams : ["high", "medium", "low"];
14+
15+
// Validate query parameters with Zod
16+
const queryValidation = getClipsQuerySchema.safeParse({
17+
page: searchParams.get("page"),
18+
pageSize: searchParams.get("pageSize"),
19+
status: searchParams.get("status"),
20+
style: searchParams.get("style"),
21+
virality: searchParams.getAll("virality"),
22+
});
23+
24+
if (!queryValidation.success) {
25+
return NextResponse.json(
26+
{ error: "Validation failed", issues: queryValidation.error.issues },
27+
{ status: 400 }
28+
);
29+
}
30+
31+
const { page, pageSize, status, style, virality } = queryValidation.data;
2032

2133
// 1. Fetch user's clips
2234
let userClips = clipsStore.getClipsForUser(session.user.id);

app/api/schemas/billing.schema.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Request body for POST /api/billing/checkout
5+
*/
6+
export const checkoutBodySchema = z.object({
7+
planId: z.enum(["pro", "enterprise"]),
8+
});
9+
10+
/**
11+
* Query parameters for GET /api/billing/plans
12+
*/
13+
export const getPlansQuerySchema = z.object({
14+
annual: z.string().optional().transform((val) => val === "true"),
15+
});
16+
17+
export type CheckoutBody = z.infer<typeof checkoutBodySchema>;
18+
export type GetPlansQuery = z.infer<typeof getPlansQuerySchema>;

app/api/schemas/clips.schema.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Query parameters for GET /api/clips
5+
*/
6+
export const getClipsQuerySchema = z.object({
7+
page: z.string().optional().default("1").transform((val) => parseInt(val, 10)),
8+
pageSize: z.string().optional().default("20").transform((val) => parseInt(val, 10)),
9+
status: z.string().optional().default(""),
10+
style: z.string().optional().default(""),
11+
virality: z.array(z.string()).optional().default(["high", "medium", "low"]),
12+
});
13+
14+
/**
15+
* Request body for POST /api/clips/post (post clips to platforms)
16+
*/
17+
export const postClipBodySchema = z.object({
18+
clipIds: z.array(z.string().min(1)).min(1, "At least one clip ID is required"),
19+
platforms: z.array(z.enum(["youtube", "instagram", "tiktok", "twitter"])).min(1, "At least one platform is required"),
20+
});
21+
22+
/**
23+
* Request body for POST /api/clips/mint (mint clip as NFT)
24+
*/
25+
export const mintClipBodySchema = z.object({
26+
clipId: z.string().min(1, "Clip ID is required"),
27+
});
28+
29+
/**
30+
* Request body for POST /api/clips (create clip)
31+
*/
32+
export const createClipBodySchema = z.object({
33+
jobId: z.string().min(1, "Job ID is required"),
34+
title: z.string().min(1, "Title is required"),
35+
style: z.string().optional(),
36+
virality: z.enum(["high", "medium", "low"]).optional(),
37+
});
38+
39+
export type GetClipsQuery = z.infer<typeof getClipsQuerySchema>;
40+
export type PostClipBody = z.infer<typeof postClipBodySchema>;
41+
export type MintClipBody = z.infer<typeof mintClipBodySchema>;
42+
export type CreateClipBody = z.infer<typeof createClipBodySchema>;

app/api/schemas/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Central export point for all API validation schemas
3+
*
4+
* This barrel file exports all Zod schemas for use in API routes and tests.
5+
* Import from here to get type-safe validation for all API endpoints.
6+
*/
7+
8+
export * from "./jobs.schema";
9+
export * from "./clips.schema";
10+
export * from "./user.schema";
11+
export * from "./transform.schema";
12+
export * from "./billing.schema";

app/api/schemas/jobs.schema.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Job ID validation schema
5+
* Accepts UUID (with or without hyphens) or alphanumeric slugs up to 64 chars
6+
*/
7+
export const jobIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, {
8+
message: "Invalid job id format. Must be 1-64 alphanumeric characters, underscores, or hyphens.",
9+
});
10+
11+
/**
12+
* Query parameters for GET /api/jobs/[id]
13+
*/
14+
export const getJobQuerySchema = z.object({
15+
id: jobIdSchema,
16+
});
17+
18+
/**
19+
* Request body for POST /api/jobs/[id] (restart job)
20+
*/
21+
export const restartJobBodySchema = z.object({
22+
// No body required for restart, just the job ID in the path
23+
}).optional();
24+
25+
/**
26+
* Request body for POST /api/jobs (create job)
27+
*/
28+
export const createJobBodySchema = z.object({
29+
filename: z.string().min(1, "Filename is required"),
30+
contentType: z.string().default("video/mp4"),
31+
objectKey: z.string().min(1, "Object key is required"),
32+
});
33+
34+
export type JobId = z.infer<typeof jobIdSchema>;
35+
export type GetJobQuery = z.infer<typeof getJobQuerySchema>;
36+
export type CreateJobBody = z.infer<typeof createJobBodySchema>;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Allowed transform styles (sourced from env at runtime)
5+
*/
6+
const ALLOWED_STYLES = ["anime", "cinematic", "sketch", "watercolor"] as const;
7+
8+
/**
9+
* Anime sub-styles
10+
*/
11+
const ANIME_SUB_STYLES = ["shonen", "shojo", "chibi", "mecha", "ghibli-inspired"] as const;
12+
13+
/**
14+
* Outline thickness options
15+
*/
16+
const OUTLINE_THICKNESSES = ["thin", "medium", "bold"] as const;
17+
18+
/**
19+
* Background style options
20+
*/
21+
const BACKGROUND_STYLES = ["original", "painted", "cel-shaded"] as const;
22+
23+
/**
24+
* Anime transform options - matches the interface in app/lib/animeTransform.ts
25+
*/
26+
export const animeTransformOptionsSchema = z.object({
27+
subStyle: z.enum(ANIME_SUB_STYLES),
28+
colorIntensity: z.number().min(0).max(100),
29+
outlineThickness: z.enum(OUTLINE_THICKNESSES),
30+
backgroundStyle: z.enum(BACKGROUND_STYLES),
31+
});
32+
33+
/**
34+
* Request body for POST /api/transform
35+
*/
36+
export const transformBodySchema = z.object({
37+
clipId: z.string().min(1, "Clip ID is required"),
38+
style: z.enum(ALLOWED_STYLES),
39+
userId: z.string().optional(),
40+
transformOptions: animeTransformOptionsSchema.optional(),
41+
});
42+
43+
/**
44+
* Request body for POST /api/transform/batch
45+
*/
46+
export const transformBatchBodySchema = z.object({
47+
clipIds: z.array(z.string().min(1)).min(1, "At least one clip ID is required"),
48+
style: z.enum(ALLOWED_STYLES),
49+
transformOptions: animeTransformOptionsSchema.optional(),
50+
});
51+
52+
/**
53+
* Request body for POST /api/transform/preview
54+
*/
55+
export const transformPreviewBodySchema = z.object({
56+
clipId: z.string().min(1, "Clip ID is required"),
57+
style: z.enum(ALLOWED_STYLES),
58+
transformOptions: animeTransformOptionsSchema.optional(),
59+
});
60+
61+
export type AnimeTransformOptions = z.infer<typeof animeTransformOptionsSchema>;
62+
export type TransformBody = z.infer<typeof transformBodySchema>;
63+
export type TransformBatchBody = z.infer<typeof transformBatchBodySchema>;
64+
export type TransformPreviewBody = z.infer<typeof transformPreviewBodySchema>;

0 commit comments

Comments
 (0)