Skip to content

Commit 510a935

Browse files
authored
Merge pull request #835 from Edoscoba/feat/tests-and-wallet-providers
feat: implement tasks 807, 808, 809, and 810
2 parents 9ea75a9 + 75ec295 commit 510a935

15 files changed

Lines changed: 771 additions & 72 deletions

PR_DESCRIPTION.md

Lines changed: 30 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,30 @@
1-
# Fix Dashboard AI Insights, User Profile Persistence, and Clips Page Components
2-
3-
This PR addresses three critical issues to improve the clips-frontend application:
4-
5-
## Changes
6-
7-
### Task 1: Fix AIInsightCard (#669)
8-
- **Issue**: AIInsightCard unconditionally returned null, rendering nothing in the dashboard
9-
- **Solution**: Implemented full component with:
10-
- Data fetching from GET /api/insights
11-
- Loading skeleton while data loads
12-
- Empty state ("No insights yet — upload a video to get started") when no data
13-
- Placeholder card with "Coming soon" if API doesn't exist (404)
14-
- Error handling with user-friendly error state
15-
- Proper sanitization of user content using DOMPurify
16-
17-
### Task 2: Implement User Profile Persistence (#675)
18-
- **Issue**: GET /api/user/profile and POST /api/user/onboarding returned hardcoded mock responses with no database integration
19-
- **Solution**:
20-
- Created Prisma schema with User model (id, email, name, avatarUrl, plan, planUsagePercent, onboardingStep, onboardingData)
21-
- Created Prisma client singleton at app/lib/prisma.ts
22-
- Updated GET /api/user/profile to fetch real user data from database
23-
- Updated PATCH /api/user/profile to update name and avatarUrl in database
24-
- Updated POST /api/user/onboarding to save onboarding step and data to database
25-
- All routes maintain auth checks (401 for unauthorized)
26-
27-
### Task 3: Implement Missing /clips Page Components (#670)
28-
- **Issue**: app/clips/page.tsx imported non-existent components, causing build failures
29-
- **Solution**: Created all four missing components with Storybook stories:
30-
- **ClipsNavbar.tsx**: Top navigation with logo, user avatar, and upload CTA
31-
- **Hero.tsx**: Page hero with tagline and sub-copy
32-
- **CreateClipsForm.tsx**: URL input + file upload form that calls POST /api/upload
33-
- **ClipsStats.tsx**: Stat chips showing total clips, avg virality, and total earnings
34-
- Added Storybook stories for all four components
35-
36-
## Files Changed
37-
38-
### Modified
39-
- components/dashboard/AIInsightCard.tsx
40-
- app/api/user/profile/route.ts
41-
- app/api/user/onboarding/route.ts
42-
43-
### Created
44-
- prisma/schema.prisma
45-
- app/lib/prisma.ts
46-
- components/clips/ClipsNavbar.tsx
47-
- components/clips/Hero.tsx
48-
- components/clips/CreateClipsForm.tsx
49-
- components/clips/ClipsStats.tsx
50-
- stories/ClipsNavbar.stories.tsx
51-
- stories/Hero.stories.tsx
52-
- stories/CreateClipsForm.stories.tsx
53-
- stories/ClipsStats.stories.tsx
54-
55-
## Dependencies Required
56-
57-
To run the database integration, you'll need to:
58-
1. Install Prisma: `npm install prisma @prisma/client`
59-
2. Set up DATABASE_URL in .env
60-
3. Run migrations: `npx prisma migrate dev --name init`
61-
4. Generate Prisma client: `npx prisma generate`
62-
63-
Closes #669, #675, #670
1+
# Pull Request: Feature and Test Enhancements
2+
3+
## Summary of Changes
4+
5+
### Task 1: Fuzz Test File Upload Validation
6+
- Closes #807 [TEST] Fuzz Test File Upload Validation
7+
- Added `fast-check` property-based fuzz testing suite in `tests/fuzz/upload.fuzz.test.ts`.
8+
- Validates random file names, extensions, declared MIME types, magic byte headers, and 500 MB boundary file sizes across 1000 generated cases.
9+
10+
### Task 2: Add Playwright Visual Regression Tests
11+
- Closes #808 [TEST] Add Playwright Visual Regression Tests
12+
- Added visual regression testing suite in `tests/e2e/visual.spec.ts`.
13+
- Configured snapshot baseline path `tests/visual-baselines/` and max diff pixel ratio threshold of 1% (`maxDiffPixelRatio: 0.01`).
14+
- Added NPM scripts `test:visual` and `test:visual:update`.
15+
- Captures visual baselines for dashboard, projects (with clips), wallet portfolio, and earnings table.
16+
17+
### Task 3: Add Clip Quality Score Breakdown Tooltip
18+
- Closes #809 [FEAT] Add Clip Quality Score Breakdown Tooltip
19+
- Extended clip data model with `scoreBreakdown: { hook: number, retention: number, emotional: number, trending: number }`.
20+
- Updated AI backend callback schema and job store payload processing.
21+
- Created `ScoreBreakdownTooltip` component showing mini bars for hook, retention, emotional, and trending sub-scores.
22+
- Made tooltip keyboard accessible (`tabIndex={0}`, focus/blur triggers, ARIA attributes).
23+
- Added Storybook story in `components/projects/ScoreBreakdownTooltip.stories.tsx`.
24+
25+
### Task 4: Add StellarWalletProvider and EmbeddedWalletProvider Components
26+
- Closes #810 [FEAT] Add StellarWalletProvider and EmbeddedWalletProvider Components
27+
- Created `EmbeddedWalletProvider` exposing `{ wallet, isLoading, error }` context and `useEmbeddedWallet` hook.
28+
- Updated `StellarWalletProvider` to call `CryptoSaltInitializer` on mount and wrap children in `EmbeddedWalletProvider`.
29+
- Ensured SSR safety on both providers.
30+
- Created comprehensive unit tests in `__tests__/components/StellarWalletProvider.test.tsx` verifying wallet creation and retrieval.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import React from "react";
5+
import { render, screen, waitFor } from "@testing-library/react";
6+
import { StellarWalletProvider } from "@/components/StellarWalletProvider";
7+
import { EmbeddedWalletProvider, useEmbeddedWallet } from "@/components/EmbeddedWalletProvider";
8+
import * as embeddedWalletModule from "@/app/lib/embeddedWallet";
9+
import { useSession } from "next-auth/react";
10+
11+
jest.mock("next-auth/react", () => ({
12+
useSession: jest.fn(),
13+
}));
14+
15+
jest.mock("@/app/lib/embeddedWallet", () => {
16+
const original = jest.requireActual("@/app/lib/embeddedWallet");
17+
return {
18+
...original,
19+
getEmbeddedWallet: jest.fn(),
20+
createEmbeddedWallet: jest.fn(),
21+
};
22+
});
23+
24+
jest.mock("@/app/lib/secureStorage", () => ({
25+
migrateCryptoSalt: jest.fn(),
26+
}));
27+
28+
const mockUseSession = useSession as jest.Mock;
29+
const mockGetEmbeddedWallet = embeddedWalletModule.getEmbeddedWallet as jest.Mock;
30+
const mockCreateEmbeddedWallet = embeddedWalletModule.createEmbeddedWallet as jest.Mock;
31+
32+
function TestConsumer() {
33+
const { wallet, isLoading, error } = useEmbeddedWallet();
34+
if (isLoading) return <div data-testid="loading">Loading...</div>;
35+
if (error) return <div data-testid="error">{error}</div>;
36+
if (!wallet) return <div data-testid="no-wallet">No Wallet</div>;
37+
return <div data-testid="wallet-public-key">{wallet.publicKey}</div>;
38+
}
39+
40+
describe("StellarWalletProvider & EmbeddedWalletProvider (#810)", () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
it("loads existing wallet when found for authenticated user", async () => {
46+
mockUseSession.mockReturnValue({
47+
data: { user: { id: "user-123", email: "test@example.com" } },
48+
status: "authenticated",
49+
});
50+
51+
const mockWallet = {
52+
publicKey: "GABCD1234567890WXYZ",
53+
network: "testnet",
54+
walletType: "embedded",
55+
isActivated: true,
56+
createdAt: new Date().toISOString(),
57+
};
58+
59+
mockGetEmbeddedWallet.mockResolvedValue(mockWallet);
60+
61+
render(
62+
<StellarWalletProvider>
63+
<TestConsumer />
64+
</StellarWalletProvider>
65+
);
66+
67+
expect(screen.getByTestId("loading")).toBeInTheDocument();
68+
69+
await waitFor(() => {
70+
expect(screen.getByTestId("wallet-public-key")).toHaveTextContent("GABCD1234567890WXYZ");
71+
});
72+
73+
expect(mockGetEmbeddedWallet).toHaveBeenCalledWith("user-123");
74+
expect(mockCreateEmbeddedWallet).not.toHaveBeenCalled();
75+
});
76+
77+
it("triggers wallet creation when wallet is not found on first login", async () => {
78+
mockUseSession.mockReturnValue({
79+
data: { user: { id: "user-456", email: "newuser@example.com" } },
80+
status: "authenticated",
81+
});
82+
83+
mockGetEmbeddedWallet.mockResolvedValue(null);
84+
85+
const newCreatedWallet = {
86+
wallet: {
87+
publicKey: "GNEWWALLET987654321",
88+
network: "testnet",
89+
walletType: "embedded",
90+
isActivated: true,
91+
createdAt: new Date().toISOString(),
92+
},
93+
alreadyExisted: false,
94+
};
95+
96+
mockCreateEmbeddedWallet.mockResolvedValue(newCreatedWallet);
97+
98+
render(
99+
<StellarWalletProvider>
100+
<TestConsumer />
101+
</StellarWalletProvider>
102+
);
103+
104+
await waitFor(() => {
105+
expect(screen.getByTestId("wallet-public-key")).toHaveTextContent("GNEWWALLET987654321");
106+
});
107+
108+
expect(mockGetEmbeddedWallet).toHaveBeenCalledWith("user-456");
109+
expect(mockCreateEmbeddedWallet).toHaveBeenCalledWith("user-456");
110+
});
111+
112+
it("renders children without throwing when user is unauthenticated", async () => {
113+
mockUseSession.mockReturnValue({
114+
data: null,
115+
status: "unauthenticated",
116+
});
117+
118+
render(
119+
<StellarWalletProvider>
120+
<TestConsumer />
121+
</StellarWalletProvider>
122+
);
123+
124+
await waitFor(() => {
125+
expect(screen.getByTestId("no-wallet")).toBeInTheDocument();
126+
});
127+
});
128+
129+
it("handles SSR safely without window access errors", () => {
130+
const originalWindow = global.window;
131+
// Simulate server side environment where window is undefined
132+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
133+
delete (global as any).window;
134+
135+
expect(() => {
136+
render(
137+
<EmbeddedWalletProvider>
138+
<div>SSR Test</div>
139+
</EmbeddedWalletProvider>
140+
);
141+
}).not.toThrow();
142+
143+
global.window = originalWindow;
144+
});
145+
});

app/api/clips/clipsStore.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
// Simple in-memory mock store for clips
22

3+
export interface ScoreBreakdown {
4+
hook: number;
5+
retention: number;
6+
emotional: number;
7+
trending: number;
8+
}
9+
310
export interface Clip {
411
id: string;
512
userId: string;
@@ -13,6 +20,7 @@ export interface Clip {
1320
resolution: string;
1421
videoUrl: string;
1522
createdAt: string;
23+
scoreBreakdown?: ScoreBreakdown;
1624
}
1725

1826
class ClipsStore {
@@ -25,12 +33,12 @@ class ClipsStore {
2533
private seed() {
2634
// Generate some mock clips to use as baseline
2735
const mockClips = [
28-
{ id: "1", title: "Clip #01 - The Big Reveal Hook", thumbnail: "/projects/thumb1.png", score: 94, scoreKey: "high", duration: "00:45", style: "Bold & Dynamic", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" },
29-
{ id: "2", title: "Clip #02 - Technical Deep Dive", thumbnail: "/projects/thumb2.png", score: 68, scoreKey: "medium", duration: "00:58", style: "Minimalist", status: "listed", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" },
30-
{ id: "3", title: "Clip #03 - Audience Reaction", thumbnail: "/projects/thumb3.png", score: 82, scoreKey: "high", duration: "00:32", style: "Emoji-Rich", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" },
31-
{ id: "4", title: "Clip #04 - Feature Walkthrough", thumbnail: "/projects/thumb1.png", score: 91, scoreKey: "high", duration: "00:52", style: "Subtitles Only", status: "history", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4" },
32-
{ id: "5", title: "Clip #05 - Closing Remarks", thumbnail: "/projects/thumb2.png", score: 42, scoreKey: "low", duration: "01:12", style: "Minimalist", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4" },
33-
{ id: "6", title: "Clip #06 - Product Detail B-Roll", thumbnail: "/projects/thumb3.png", score: 89, scoreKey: "high", duration: "00:44", style: "Bold & Dynamic", status: "listed", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4" },
36+
{ id: "1", title: "Clip #01 - The Big Reveal Hook", thumbnail: "/projects/thumb1.png", score: 94, scoreKey: "high", duration: "00:45", style: "Bold & Dynamic", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", scoreBreakdown: { hook: 96, retention: 92, emotional: 90, trending: 98 } },
37+
{ id: "2", title: "Clip #02 - Technical Deep Dive", thumbnail: "/projects/thumb2.png", score: 68, scoreKey: "medium", duration: "00:58", style: "Minimalist", status: "listed", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", scoreBreakdown: { hook: 70, retention: 65, emotional: 60, trending: 75 } },
38+
{ id: "3", title: "Clip #03 - Audience Reaction", thumbnail: "/projects/thumb3.png", score: 82, scoreKey: "high", duration: "00:32", style: "Emoji-Rich", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", scoreBreakdown: { hook: 85, retention: 80, emotional: 88, trending: 75 } },
39+
{ id: "4", title: "Clip #04 - Feature Walkthrough", thumbnail: "/projects/thumb1.png", score: 91, scoreKey: "high", duration: "00:52", style: "Subtitles Only", status: "history", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", scoreBreakdown: { hook: 92, retention: 90, emotional: 85, trending: 95 } },
40+
{ id: "5", title: "Clip #05 - Closing Remarks", thumbnail: "/projects/thumb2.png", score: 42, scoreKey: "low", duration: "01:12", style: "Minimalist", status: "pending", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4", scoreBreakdown: { hook: 45, retention: 40, emotional: 38, trending: 45 } },
41+
{ id: "6", title: "Clip #06 - Product Detail B-Roll", thumbnail: "/projects/thumb3.png", score: 89, scoreKey: "high", duration: "00:44", style: "Bold & Dynamic", status: "listed", resolution: "1080x1920", videoUrl: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4", scoreBreakdown: { hook: 90, retention: 88, emotional: 85, trending: 92 } },
3442
];
3543

3644
// Create base pool that users will pull from

app/api/jobs/[id]/callback/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ const AI_ERROR_CODES: [AiErrorCode, ...AiErrorCode[]] = [
4646
"INTERNAL_ERROR",
4747
];
4848

49+
const ScoreBreakdownSchema = z.object({
50+
hook: z.number().min(0).max(100),
51+
retention: z.number().min(0).max(100),
52+
emotional: z.number().min(0).max(100),
53+
trending: z.number().min(0).max(100),
54+
});
55+
4956
const CallbackBodySchema = z.object({
5057
status: z.enum(["queued", "processing", "complete", "error"]).optional(),
5158
progress: z.number().min(0).max(100).optional(),
5259
momentsFound: z.number().min(0).optional(),
5360
estimatedSecondsRemaining: z.number().min(0).optional(),
61+
scoreBreakdown: ScoreBreakdownSchema.optional(),
5462
errorCode: z.enum(AI_ERROR_CODES).optional(),
5563
errorMessage: z.string().max(500).optional(),
5664
});
@@ -114,6 +122,7 @@ export async function POST(
114122
momentsFound: update.momentsFound ?? job.momentsFound,
115123
estimatedSecondsRemaining:
116124
update.estimatedSecondsRemaining ?? job.estimatedSecondsRemaining,
125+
...(update.scoreBreakdown ? { scoreBreakdown: update.scoreBreakdown } : {}),
117126
...(update.errorCode ? { errorCode: update.errorCode } : {}),
118127
...(update.errorMessage ? { errorMessage: update.errorMessage } : {}),
119128
});

app/api/jobs/shared/jobStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export interface Job {
2222
momentsFound: number;
2323
estimatedSecondsRemaining: number;
2424
createdAt: number;
25+
scoreBreakdown?: {
26+
hook: number;
27+
retention: number;
28+
emotional: number;
29+
trending: number;
30+
};
2531
/** Human-readable error message set by the AI backend on failure. */
2632
errorCode?: AiErrorCode;
2733
errorMessage?: string;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
"use client";
22

3+
import { useEffect } from "react";
4+
import { migrateCryptoSalt } from "@/app/lib/secureStorage";
5+
6+
/**
7+
* Initializes and migrates cryptographic salt material upon app startup.
8+
* Ensured to run before any wallet operations execute.
9+
*/
310
export default function CryptoSaltInitializer() {
11+
useEffect(() => {
12+
if (typeof window !== "undefined") {
13+
migrateCryptoSalt();
14+
}
15+
}, []);
16+
417
return null;
518
}

0 commit comments

Comments
 (0)