@react-spa-scaffold/mcp 2.2.0 → 2.4.0
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/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/api.d.ts.map +1 -1
- package/dist/features/definitions/api.js +2 -1
- package/dist/features/definitions/api.js.map +1 -1
- package/dist/features/definitions/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- package/dist/features/definitions/electron.d.ts +3 -0
- package/dist/features/definitions/electron.d.ts.map +1 -0
- package/dist/features/definitions/electron.js +23 -0
- package/dist/features/definitions/electron.js.map +1 -0
- package/dist/features/definitions/index.d.ts +3 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +3 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +4 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.d.ts +1 -0
- package/dist/features/types.d.ts.map +1 -1
- package/dist/features/types.test.js +5 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/tools/get-features.test.js +7 -0
- package/dist/tools/get-features.test.js.map +1 -1
- package/dist/tools/get-scaffold.d.ts +1 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -1
- package/dist/tools/get-scaffold.js +4 -1
- package/dist/tools/get-scaffold.js.map +1 -1
- package/dist/tools/get-scaffold.test.js +50 -0
- package/dist/tools/get-scaffold.test.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +4 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +3 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +174 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.d.ts.map +1 -1
- package/dist/utils/scaffold/compute.js +4 -2
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +7 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +100 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +40 -12
- package/templates/.github/workflows/ci.yml +49 -2
- package/templates/.github/workflows/deploy.yml +46 -0
- package/templates/CLAUDE.md +180 -1
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/DEPLOYMENT.md +296 -0
- package/templates/docs/E2E_TESTING.md +81 -4
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +11 -0
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/forge.config.js +53 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +13 -1
- package/templates/playwright.config.ts +33 -3
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +11 -0
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
- package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
- package/templates/src/components/shared/index.ts +1 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/queryContext.tsx +9 -8
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +17 -0
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- package/templates/src/lib/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +4 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +13 -10
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.ts +227 -0
- package/templates/src/main.tsx +32 -42
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/preload.ts +26 -0
- package/templates/src/stores/preferencesStore.ts +2 -1
- package/templates/src/test/clerkMock.tsx +49 -9
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +49 -3
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +26 -0
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/global.d.ts +28 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
- package/templates/vite.main.config.mjs +20 -0
- package/templates/vite.preload.config.mjs +17 -0
- package/templates/vite.renderer.config.mjs +52 -0
package/templates/src/main.tsx
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { I18nProvider } from '@lingui/react';
|
|
2
2
|
import { StrictMode } from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
|
-
import { BrowserRouter } from 'react-router';
|
|
4
|
+
import { BrowserRouter, HashRouter } from 'react-router';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect if running in Electron environment.
|
|
8
|
+
* Uses the electronAPI exposed by preload script.
|
|
9
|
+
*/
|
|
10
|
+
const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectron === true;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Router selection: HashRouter for Electron (file:// protocol compatibility),
|
|
14
|
+
* BrowserRouter for web.
|
|
15
|
+
*/
|
|
16
|
+
const Router = isElectron ? HashRouter : BrowserRouter;
|
|
5
17
|
|
|
6
18
|
import './index.css';
|
|
7
19
|
import { ErrorBoundary } from '@/components/shared';
|
|
@@ -10,46 +22,22 @@ import { ClerkThemeProvider } from '@/contexts/clerkContext';
|
|
|
10
22
|
import { MobileProvider } from '@/contexts/mobileContext';
|
|
11
23
|
import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
|
|
12
24
|
import { QueryProvider } from '@/contexts/queryContext';
|
|
25
|
+
import { SupabaseProvider } from '@/contexts/supabaseContext';
|
|
13
26
|
import { i18n, initI18n } from '@/i18n';
|
|
14
|
-
import {
|
|
27
|
+
import { CLERK_CONFIG } from '@/lib/config';
|
|
28
|
+
import { initSentry } from '@/lib/sentry';
|
|
15
29
|
import { initPreferencesSync } from '@/stores/preferencesStore';
|
|
16
30
|
|
|
17
31
|
import App from './App';
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!PUBLISHABLE_KEY) {
|
|
23
|
-
throw new Error('Add your Clerk Publishable Key to the .env.local file');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Lazy load Sentry after initial render to avoid blocking web vitals.
|
|
28
|
-
* Returns the Sentry module for use in global error handlers.
|
|
29
|
-
*/
|
|
30
|
-
async function initSentry() {
|
|
31
|
-
if (import.meta.env.PROD && SENTRY_CONFIG.enabled && SENTRY_CONFIG.dsn) {
|
|
32
|
-
try {
|
|
33
|
-
const Sentry = await import('@sentry/react');
|
|
34
|
-
Sentry.init({
|
|
35
|
-
dsn: SENTRY_CONFIG.dsn,
|
|
36
|
-
sendDefaultPii: true,
|
|
37
|
-
integrations: [Sentry.browserTracingIntegration()],
|
|
38
|
-
tracesSampleRate: SENTRY_CONFIG.tracesSampleRate,
|
|
39
|
-
});
|
|
40
|
-
return Sentry;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
console.error('Failed to initialize Sentry:', error);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
33
|
+
/** Sentry module type for error handlers */
|
|
34
|
+
type SentryModule = Awaited<ReturnType<typeof initSentry>>;
|
|
47
35
|
|
|
48
36
|
/**
|
|
49
37
|
* Setup global error handlers for uncaught errors and promise rejections.
|
|
50
38
|
* @param Sentry - The Sentry module or null if not available
|
|
51
39
|
*/
|
|
52
|
-
function setupGlobalErrorHandlers(Sentry:
|
|
40
|
+
function setupGlobalErrorHandlers(Sentry: SentryModule) {
|
|
53
41
|
// Handle uncaught errors
|
|
54
42
|
window.onerror = (message, source, lineno, colno, error) => {
|
|
55
43
|
console.error('Uncaught error:', { message, source, lineno, colno, error });
|
|
@@ -81,18 +69,20 @@ initI18n().then(() => {
|
|
|
81
69
|
<StrictMode>
|
|
82
70
|
<QueryProvider>
|
|
83
71
|
<I18nProvider i18n={i18n}>
|
|
84
|
-
<
|
|
85
|
-
<ClerkThemeProvider publishableKey={
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
72
|
+
<Router>
|
|
73
|
+
<ClerkThemeProvider publishableKey={CLERK_CONFIG.publishableKey!}>
|
|
74
|
+
<SupabaseProvider>
|
|
75
|
+
<MobileProvider>
|
|
76
|
+
<ErrorBoundary>
|
|
77
|
+
<PerformanceProviderWrapper>
|
|
78
|
+
<App />
|
|
79
|
+
<Toaster />
|
|
80
|
+
</PerformanceProviderWrapper>
|
|
81
|
+
</ErrorBoundary>
|
|
82
|
+
</MobileProvider>
|
|
83
|
+
</SupabaseProvider>
|
|
94
84
|
</ClerkThemeProvider>
|
|
95
|
-
</
|
|
85
|
+
</Router>
|
|
96
86
|
</I18nProvider>
|
|
97
87
|
</QueryProvider>
|
|
98
88
|
</StrictMode>,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test constants for mocks and fixtures.
|
|
3
|
+
*
|
|
4
|
+
* Use these values across all mocks to ensure consistency
|
|
5
|
+
* between Clerk, Supabase, and other test utilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Mock user data - consistent across all auth/database mocks */
|
|
9
|
+
export const MOCK_USER = {
|
|
10
|
+
id: 'user_123',
|
|
11
|
+
email: 'test@example.com',
|
|
12
|
+
firstName: 'Test',
|
|
13
|
+
lastName: 'User',
|
|
14
|
+
fullName: 'Test User',
|
|
15
|
+
avatarUrl: 'https://example.com/avatar.jpg',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** Mock session ID for auth mocks */
|
|
19
|
+
export const MOCK_SESSION_ID = 'sess_123';
|
|
20
|
+
|
|
21
|
+
/** Mock auth token for API requests */
|
|
22
|
+
export const MOCK_AUTH_TOKEN = 'mock-auth-token';
|
|
23
|
+
|
|
24
|
+
/** Mock Supabase URL for MSW handlers */
|
|
25
|
+
export const MOCK_SUPABASE_URL = 'https://mock.supabase.co';
|
|
26
|
+
|
|
27
|
+
/** Default timestamps for fixtures */
|
|
28
|
+
export const MOCK_TIMESTAMPS = {
|
|
29
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
30
|
+
updated: '2024-01-01T00:00:00.000Z',
|
|
31
|
+
} as const;
|
|
@@ -1 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { createTodo, createTodos, mockTodos, type Todo } from './todos';
|
|
2
|
+
export { createProfile, createProfiles, mockProfiles } from './profiles';
|
|
3
|
+
export { createUser, createUsers, defaultUser, mockUsers, type MockUser } from './users';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock profile fixtures for testing.
|
|
3
|
+
* Used by MSW handlers to simulate Supabase responses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Profile } from '@/types/database';
|
|
7
|
+
|
|
8
|
+
import { MOCK_TIMESTAMPS, MOCK_USER } from '../constants';
|
|
9
|
+
|
|
10
|
+
/** Counter for unique ID generation */
|
|
11
|
+
let idCounter = 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sample profiles for MSW handlers.
|
|
15
|
+
*/
|
|
16
|
+
export const mockProfiles: Profile[] = [
|
|
17
|
+
{
|
|
18
|
+
id: MOCK_USER.id,
|
|
19
|
+
email: MOCK_USER.email,
|
|
20
|
+
full_name: MOCK_USER.fullName,
|
|
21
|
+
avatar_url: null,
|
|
22
|
+
created_at: MOCK_TIMESTAMPS.created,
|
|
23
|
+
updated_at: MOCK_TIMESTAMPS.updated,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a profile with optional overrides.
|
|
29
|
+
*/
|
|
30
|
+
export function createProfile(overrides: Partial<Profile> = {}): Profile {
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
return {
|
|
33
|
+
id: `user_${Date.now()}_${idCounter++}`,
|
|
34
|
+
email: MOCK_USER.email,
|
|
35
|
+
full_name: MOCK_USER.fullName,
|
|
36
|
+
avatar_url: null,
|
|
37
|
+
created_at: now,
|
|
38
|
+
updated_at: now,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create multiple profiles.
|
|
45
|
+
*/
|
|
46
|
+
export function createProfiles(count: number, overrides: Partial<Profile> = {}): Profile[] {
|
|
47
|
+
return Array.from({ length: count }, (_, i) =>
|
|
48
|
+
createProfile({
|
|
49
|
+
id: `user_${i + 1}`,
|
|
50
|
+
email: `user${i + 1}@example.com`,
|
|
51
|
+
full_name: `User ${i + 1}`,
|
|
52
|
+
...overrides,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock user fixtures for Clerk authentication testing.
|
|
3
|
+
* Used by clerkMock.tsx to simulate authenticated users.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MOCK_USER } from '../constants';
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
export interface MockUser {
|
|
13
|
+
id: string;
|
|
14
|
+
firstName: string;
|
|
15
|
+
lastName: string;
|
|
16
|
+
fullName?: string;
|
|
17
|
+
primaryEmailAddress?: { emailAddress: string };
|
|
18
|
+
imageUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Default User
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/** Default mock user based on MOCK_USER constants */
|
|
26
|
+
export const defaultUser: MockUser = {
|
|
27
|
+
id: MOCK_USER.id,
|
|
28
|
+
firstName: MOCK_USER.firstName,
|
|
29
|
+
lastName: MOCK_USER.lastName,
|
|
30
|
+
fullName: MOCK_USER.fullName,
|
|
31
|
+
primaryEmailAddress: { emailAddress: MOCK_USER.email },
|
|
32
|
+
imageUrl: MOCK_USER.avatarUrl,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Static Fixtures
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/** Sample users for MSW handlers */
|
|
40
|
+
export const mockUsers: MockUser[] = [{ ...defaultUser }];
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Factory Functions
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
/** Counter for unique ID generation */
|
|
47
|
+
let idCounter = 0;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a mock user with optional overrides.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const user = createUser({ firstName: 'Jane' });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function createUser(overrides: Partial<MockUser> = {}): MockUser {
|
|
58
|
+
const id = overrides.id ?? `user_${Date.now()}_${idCounter++}`;
|
|
59
|
+
const firstName = overrides.firstName ?? MOCK_USER.firstName;
|
|
60
|
+
const lastName = overrides.lastName ?? MOCK_USER.lastName;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
firstName,
|
|
65
|
+
lastName,
|
|
66
|
+
fullName: `${firstName} ${lastName}`,
|
|
67
|
+
primaryEmailAddress: { emailAddress: MOCK_USER.email },
|
|
68
|
+
imageUrl: MOCK_USER.avatarUrl,
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create multiple mock users.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* const users = createUsers(3);
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function createUsers(count: number, overrides: Partial<MockUser> = {}): MockUser[] {
|
|
82
|
+
return Array.from({ length: count }, (_, i) =>
|
|
83
|
+
createUser({
|
|
84
|
+
id: `user_${i + 1}`,
|
|
85
|
+
firstName: `User${i + 1}`,
|
|
86
|
+
lastName: 'Test',
|
|
87
|
+
primaryEmailAddress: { emailAddress: `user${i + 1}@example.com` },
|
|
88
|
+
...overrides,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { supabaseHandlers } from './supabase';
|
|
1
2
|
import { todosHandlers } from './todos';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* All MSW request handlers.
|
|
5
6
|
* Add new feature handlers here as the application grows.
|
|
6
7
|
*/
|
|
7
|
-
export const handlers = [...todosHandlers];
|
|
8
|
+
export const handlers = [...todosHandlers, ...supabaseHandlers];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW handlers for Supabase PostgREST API.
|
|
3
|
+
*
|
|
4
|
+
* Minimal handlers for testing. Extend as needed.
|
|
5
|
+
* @see https://postgrest.org/en/stable/references/api.html
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { http, HttpResponse } from 'msw';
|
|
9
|
+
|
|
10
|
+
import { MOCK_SUPABASE_URL, MOCK_USER } from '../constants';
|
|
11
|
+
import { createProfile, mockProfiles } from '../fixtures/profiles';
|
|
12
|
+
|
|
13
|
+
export const supabaseHandlers = [
|
|
14
|
+
// GET /rest/v1/profiles
|
|
15
|
+
http.get(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, ({ request }) => {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const idFilter = url.searchParams.get('id');
|
|
18
|
+
const isSingle = request.headers.get('Accept')?.includes('vnd.pgrst.object');
|
|
19
|
+
|
|
20
|
+
let profiles = [...mockProfiles];
|
|
21
|
+
|
|
22
|
+
// Filter by ID if specified (eq.user_123 format)
|
|
23
|
+
if (idFilter?.startsWith('eq.')) {
|
|
24
|
+
const id = idFilter.replace('eq.', '');
|
|
25
|
+
profiles = profiles.filter((p) => p.id === id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Simulate RLS: only return current user's data
|
|
29
|
+
profiles = profiles.filter((p) => p.id === MOCK_USER.id);
|
|
30
|
+
|
|
31
|
+
if (isSingle) {
|
|
32
|
+
return profiles.length > 0
|
|
33
|
+
? HttpResponse.json(profiles[0])
|
|
34
|
+
: HttpResponse.json({ message: 'No rows found', code: 'PGRST116' }, { status: 406 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return HttpResponse.json(profiles);
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
// POST /rest/v1/profiles
|
|
41
|
+
http.post(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
|
|
42
|
+
const body = await request.json();
|
|
43
|
+
const profile = createProfile(body as Record<string, unknown>);
|
|
44
|
+
|
|
45
|
+
return request.headers.get('Prefer')?.includes('return=representation')
|
|
46
|
+
? HttpResponse.json(profile, { status: 201 })
|
|
47
|
+
: new HttpResponse(null, { status: 201 });
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
// PATCH /rest/v1/profiles
|
|
51
|
+
http.patch(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
|
|
52
|
+
const body = await request.json();
|
|
53
|
+
const profile = { ...mockProfiles[0], ...(body as Record<string, unknown>), updated_at: new Date().toISOString() };
|
|
54
|
+
|
|
55
|
+
return request.headers.get('Prefer')?.includes('return=representation')
|
|
56
|
+
? HttpResponse.json(profile)
|
|
57
|
+
: new HttpResponse(null, { status: 204 });
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
// DELETE /rest/v1/profiles
|
|
61
|
+
http.delete(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, () => {
|
|
62
|
+
return new HttpResponse(null, { status: 204 });
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { render, resetClerkMocks, createProfile } from '@/test';
|
|
6
|
+
|
|
7
|
+
import { ProfilePage } from './Profile';
|
|
8
|
+
|
|
9
|
+
// Mock the hooks
|
|
10
|
+
const mockMutate = vi.fn();
|
|
11
|
+
const mockRefetch = vi.fn();
|
|
12
|
+
|
|
13
|
+
// Default mock profile
|
|
14
|
+
const defaultProfile = createProfile({
|
|
15
|
+
full_name: 'Test User',
|
|
16
|
+
email: 'test@example.com',
|
|
17
|
+
avatar_url: 'https://example.com/avatar.jpg',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Hook mock state
|
|
21
|
+
let mockProfileState: {
|
|
22
|
+
profile: ReturnType<typeof createProfile> | null;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
error: Error | null;
|
|
25
|
+
exists: boolean;
|
|
26
|
+
isFetching: boolean;
|
|
27
|
+
refetch: typeof mockRefetch;
|
|
28
|
+
} = {
|
|
29
|
+
profile: defaultProfile,
|
|
30
|
+
isLoading: false,
|
|
31
|
+
error: null,
|
|
32
|
+
exists: true,
|
|
33
|
+
isFetching: false,
|
|
34
|
+
refetch: mockRefetch,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let mockUpdateState = {
|
|
38
|
+
mutate: mockMutate,
|
|
39
|
+
isPending: false,
|
|
40
|
+
error: null as Error | null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
vi.mock('@/hooks', async () => {
|
|
44
|
+
const actual = await vi.importActual('@/hooks');
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
useProfile: vi.fn(() => mockProfileState),
|
|
48
|
+
useUpdateProfile: vi.fn(() => mockUpdateState),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ProfilePage', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
resetClerkMocks();
|
|
55
|
+
mockMutate.mockClear();
|
|
56
|
+
mockRefetch.mockClear();
|
|
57
|
+
|
|
58
|
+
// Reset mock state to defaults
|
|
59
|
+
mockProfileState = {
|
|
60
|
+
profile: defaultProfile,
|
|
61
|
+
isLoading: false,
|
|
62
|
+
error: null,
|
|
63
|
+
exists: true,
|
|
64
|
+
isFetching: false,
|
|
65
|
+
refetch: mockRefetch,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
mockUpdateState = {
|
|
69
|
+
mutate: mockMutate,
|
|
70
|
+
isPending: false,
|
|
71
|
+
error: null,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders profile page with title and description', () => {
|
|
76
|
+
render(<ProfilePage />);
|
|
77
|
+
// Verify both card title and description are present
|
|
78
|
+
expect(screen.getByText('Your Profile')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('shows loading skeleton when profile is loading', () => {
|
|
83
|
+
mockProfileState.isLoading = true;
|
|
84
|
+
mockProfileState.profile = null;
|
|
85
|
+
|
|
86
|
+
const { container } = render(<ProfilePage />);
|
|
87
|
+
|
|
88
|
+
// Should show skeleton elements
|
|
89
|
+
const skeletons = container.querySelectorAll('[class*="animate-pulse"]');
|
|
90
|
+
expect(skeletons.length).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('displays profile data when loaded', () => {
|
|
94
|
+
render(<ProfilePage />);
|
|
95
|
+
|
|
96
|
+
// Name appears in header and name field - use getAllByText
|
|
97
|
+
expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
|
|
98
|
+
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByAltText(/profile avatar/i)).toHaveAttribute('src', 'https://example.com/avatar.jpg');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('shows avatar fallback when no avatar_url', () => {
|
|
103
|
+
mockProfileState.profile = createProfile({
|
|
104
|
+
full_name: 'John Doe',
|
|
105
|
+
avatar_url: null,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
render(<ProfilePage />);
|
|
109
|
+
|
|
110
|
+
// Should show first letter of name as fallback
|
|
111
|
+
expect(screen.getByText('J')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('shows email initial when no name or avatar', () => {
|
|
115
|
+
mockProfileState.profile = createProfile({
|
|
116
|
+
full_name: null,
|
|
117
|
+
email: 'user@example.com',
|
|
118
|
+
avatar_url: null,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
render(<ProfilePage />);
|
|
122
|
+
|
|
123
|
+
// Should show first letter of email as fallback
|
|
124
|
+
expect(screen.getByText('U')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('shows error state and retry button on fetch failure', async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
mockProfileState.error = new Error('Network error');
|
|
130
|
+
mockProfileState.profile = null;
|
|
131
|
+
|
|
132
|
+
render(<ProfilePage />);
|
|
133
|
+
|
|
134
|
+
expect(screen.getByText(/failed to load profile/i)).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
|
136
|
+
|
|
137
|
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
|
138
|
+
await user.click(retryButton);
|
|
139
|
+
|
|
140
|
+
expect(mockRefetch).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('allows editing name field', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
render(<ProfilePage />);
|
|
146
|
+
|
|
147
|
+
// Click edit button
|
|
148
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
149
|
+
await user.click(editButton);
|
|
150
|
+
|
|
151
|
+
// Should show input field
|
|
152
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
153
|
+
expect(input).toBeInTheDocument();
|
|
154
|
+
expect(input).toHaveValue('Test User');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('saves updated name via useUpdateProfile', async () => {
|
|
158
|
+
const user = userEvent.setup();
|
|
159
|
+
|
|
160
|
+
// Mock successful mutation
|
|
161
|
+
mockMutate.mockImplementation((_data, options) => {
|
|
162
|
+
options?.onSuccess?.();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
render(<ProfilePage />);
|
|
166
|
+
|
|
167
|
+
// Click edit
|
|
168
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
169
|
+
|
|
170
|
+
// Change name
|
|
171
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
172
|
+
await user.clear(input);
|
|
173
|
+
await user.type(input, 'New Name');
|
|
174
|
+
|
|
175
|
+
// Click save
|
|
176
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(mockMutate).toHaveBeenCalledWith({ full_name: 'New Name' }, expect.any(Object));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('submits form on Enter key', async () => {
|
|
184
|
+
const user = userEvent.setup();
|
|
185
|
+
|
|
186
|
+
// Mock successful mutation
|
|
187
|
+
mockMutate.mockImplementation((_data, options) => {
|
|
188
|
+
options?.onSuccess?.();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
render(<ProfilePage />);
|
|
192
|
+
|
|
193
|
+
// Enter edit mode
|
|
194
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
195
|
+
|
|
196
|
+
// Change name and press Enter
|
|
197
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
198
|
+
await user.clear(input);
|
|
199
|
+
await user.type(input, 'Entered Name{Enter}');
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(mockMutate).toHaveBeenCalledWith({ full_name: 'Entered Name' }, expect.any(Object));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('shows saving state on button while updating', async () => {
|
|
207
|
+
const user = userEvent.setup();
|
|
208
|
+
mockUpdateState.isPending = true;
|
|
209
|
+
|
|
210
|
+
render(<ProfilePage />);
|
|
211
|
+
|
|
212
|
+
// Enter edit mode
|
|
213
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
214
|
+
|
|
215
|
+
// Should show saving state
|
|
216
|
+
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('cancel button reverts changes', async () => {
|
|
220
|
+
const user = userEvent.setup();
|
|
221
|
+
render(<ProfilePage />);
|
|
222
|
+
|
|
223
|
+
// Click edit
|
|
224
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
225
|
+
|
|
226
|
+
// Change name
|
|
227
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
228
|
+
await user.clear(input);
|
|
229
|
+
await user.type(input, 'Changed Name');
|
|
230
|
+
|
|
231
|
+
// Click cancel
|
|
232
|
+
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
|
233
|
+
|
|
234
|
+
// Should exit edit mode and show original name (appears multiple times)
|
|
235
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
236
|
+
expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('shows error message when update fails', async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
mockUpdateState.error = new Error('Update failed');
|
|
242
|
+
|
|
243
|
+
render(<ProfilePage />);
|
|
244
|
+
|
|
245
|
+
// Enter edit mode
|
|
246
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
247
|
+
|
|
248
|
+
// Should show error message
|
|
249
|
+
expect(screen.getByText(/failed to update/i)).toBeInTheDocument();
|
|
250
|
+
expect(screen.getByText(/update failed/i)).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('shows "Not set" when name is empty', () => {
|
|
254
|
+
mockProfileState.profile = createProfile({
|
|
255
|
+
full_name: null,
|
|
256
|
+
email: 'test@example.com',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
render(<ProfilePage />);
|
|
260
|
+
|
|
261
|
+
expect(screen.getByText(/not set/i)).toBeInTheDocument();
|
|
262
|
+
});
|
|
263
|
+
});
|