@react-spa-scaffold/mcp 2.2.0 → 2.3.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.
Files changed (112) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +3 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/features/definitions/database.d.ts +3 -0
  6. package/dist/features/definitions/database.d.ts.map +1 -0
  7. package/dist/features/definitions/database.js +45 -0
  8. package/dist/features/definitions/database.js.map +1 -0
  9. package/dist/features/definitions/deployment.d.ts +3 -0
  10. package/dist/features/definitions/deployment.d.ts.map +1 -0
  11. package/dist/features/definitions/deployment.js +14 -0
  12. package/dist/features/definitions/deployment.js.map +1 -0
  13. package/dist/features/definitions/index.d.ts +2 -0
  14. package/dist/features/definitions/index.d.ts.map +1 -1
  15. package/dist/features/definitions/index.js +2 -0
  16. package/dist/features/definitions/index.js.map +1 -1
  17. package/dist/features/registry.d.ts.map +1 -1
  18. package/dist/features/registry.js +3 -1
  19. package/dist/features/registry.js.map +1 -1
  20. package/dist/features/types.test.js +4 -2
  21. package/dist/features/types.test.js.map +1 -1
  22. package/dist/resources/docs.d.ts.map +1 -1
  23. package/dist/resources/docs.js +5 -0
  24. package/dist/resources/docs.js.map +1 -1
  25. package/dist/tools/add-features.js +1 -1
  26. package/dist/tools/add-features.js.map +1 -1
  27. package/dist/utils/docs.d.ts.map +1 -1
  28. package/dist/utils/docs.js +2 -0
  29. package/dist/utils/docs.js.map +1 -1
  30. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  31. package/dist/utils/scaffold/claude-md/index.js +3 -1
  32. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  33. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  34. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  35. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  36. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  37. package/dist/utils/scaffold/compute.js +1 -1
  38. package/dist/utils/scaffold/compute.js.map +1 -1
  39. package/dist/utils/scaffold/generators.d.ts +2 -2
  40. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  41. package/dist/utils/scaffold/generators.js +57 -22
  42. package/dist/utils/scaffold/generators.js.map +1 -1
  43. package/package.json +1 -1
  44. package/templates/.env.example +40 -12
  45. package/templates/.github/workflows/ci.yml +4 -1
  46. package/templates/.github/workflows/deploy.yml +59 -0
  47. package/templates/CLAUDE.md +177 -1
  48. package/templates/docs/AUTHENTICATION.md +325 -0
  49. package/templates/docs/DEPLOYMENT.md +268 -0
  50. package/templates/docs/E2E_TESTING.md +81 -4
  51. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  52. package/templates/docs/TESTING.md +195 -77
  53. package/templates/e2e/auth/auth.setup.ts +60 -0
  54. package/templates/e2e/fixtures/index.ts +11 -0
  55. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  56. package/templates/e2e/tests/profile.spec.ts +64 -0
  57. package/templates/e2e/tests/register-form.spec.ts +38 -0
  58. package/templates/gitignore +5 -0
  59. package/templates/package.json +8 -0
  60. package/templates/playwright.config.ts +33 -3
  61. package/templates/src/App.tsx +32 -19
  62. package/templates/src/components/layout/Header.test.tsx +17 -1
  63. package/templates/src/components/layout/Header.tsx +11 -0
  64. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
  65. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  66. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  67. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  68. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
  69. package/templates/src/components/shared/index.ts +1 -0
  70. package/templates/src/contexts/performanceContext.tsx +3 -3
  71. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  72. package/templates/src/contexts/supabaseContext.tsx +87 -0
  73. package/templates/src/hooks/index.ts +17 -0
  74. package/templates/src/hooks/supabase/index.ts +12 -0
  75. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  76. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  77. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  78. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  79. package/templates/src/lib/api.test.ts +30 -38
  80. package/templates/src/lib/api.ts +1 -7
  81. package/templates/src/lib/config.ts +54 -4
  82. package/templates/src/lib/env.ts +36 -14
  83. package/templates/src/lib/index.ts +4 -2
  84. package/templates/src/lib/routes.ts +1 -0
  85. package/templates/src/lib/sentry.ts +13 -10
  86. package/templates/src/lib/supabase/client.ts +58 -0
  87. package/templates/src/lib/supabase/index.ts +5 -0
  88. package/templates/src/main.tsx +17 -39
  89. package/templates/src/mocks/constants.ts +31 -0
  90. package/templates/src/mocks/fixtures/index.ts +3 -1
  91. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  92. package/templates/src/mocks/fixtures/users.ts +91 -0
  93. package/templates/src/mocks/handlers/index.ts +2 -1
  94. package/templates/src/mocks/handlers/supabase.ts +64 -0
  95. package/templates/src/mocks/handlers/todos.ts +1 -1
  96. package/templates/src/mocks/index.ts +6 -0
  97. package/templates/src/pages/Profile.test.tsx +263 -0
  98. package/templates/src/pages/Profile.tsx +171 -0
  99. package/templates/src/pages/index.ts +1 -0
  100. package/templates/src/stores/preferencesStore.ts +2 -1
  101. package/templates/src/test/clerkMock.tsx +49 -9
  102. package/templates/src/test/fetchMock.ts +58 -0
  103. package/templates/src/test/index.ts +49 -3
  104. package/templates/src/test/mocks.ts +128 -1
  105. package/templates/src/test/providers.tsx +7 -4
  106. package/templates/src/test/supabaseMock.ts +112 -0
  107. package/templates/src/test-setup.ts +26 -0
  108. package/templates/src/types/database.ts +46 -0
  109. package/templates/src/types/index.ts +1 -0
  110. package/templates/src/types/supabase.ts +167 -0
  111. package/templates/src/vite-env.d.ts +6 -0
  112. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
@@ -10,46 +10,22 @@ import { ClerkThemeProvider } from '@/contexts/clerkContext';
10
10
  import { MobileProvider } from '@/contexts/mobileContext';
11
11
  import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
12
12
  import { QueryProvider } from '@/contexts/queryContext';
13
+ import { SupabaseProvider } from '@/contexts/supabaseContext';
13
14
  import { i18n, initI18n } from '@/i18n';
14
- import { SENTRY_CONFIG } from '@/lib/config';
15
+ import { CLERK_CONFIG } from '@/lib/config';
16
+ import { initSentry } from '@/lib/sentry';
15
17
  import { initPreferencesSync } from '@/stores/preferencesStore';
16
18
 
17
19
  import App from './App';
18
20
 
19
- // Import Clerk Publishable Key (per official docs)
20
- const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
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
- }
21
+ /** Sentry module type for error handlers */
22
+ type SentryModule = Awaited<ReturnType<typeof initSentry>>;
47
23
 
48
24
  /**
49
25
  * Setup global error handlers for uncaught errors and promise rejections.
50
26
  * @param Sentry - The Sentry module or null if not available
51
27
  */
52
- function setupGlobalErrorHandlers(Sentry: Awaited<ReturnType<typeof initSentry>>) {
28
+ function setupGlobalErrorHandlers(Sentry: SentryModule) {
53
29
  // Handle uncaught errors
54
30
  window.onerror = (message, source, lineno, colno, error) => {
55
31
  console.error('Uncaught error:', { message, source, lineno, colno, error });
@@ -82,15 +58,17 @@ initI18n().then(() => {
82
58
  <QueryProvider>
83
59
  <I18nProvider i18n={i18n}>
84
60
  <BrowserRouter>
85
- <ClerkThemeProvider publishableKey={PUBLISHABLE_KEY}>
86
- <MobileProvider>
87
- <ErrorBoundary>
88
- <PerformanceProviderWrapper>
89
- <App />
90
- <Toaster />
91
- </PerformanceProviderWrapper>
92
- </ErrorBoundary>
93
- </MobileProvider>
61
+ <ClerkThemeProvider publishableKey={CLERK_CONFIG.publishableKey!}>
62
+ <SupabaseProvider>
63
+ <MobileProvider>
64
+ <ErrorBoundary>
65
+ <PerformanceProviderWrapper>
66
+ <App />
67
+ <Toaster />
68
+ </PerformanceProviderWrapper>
69
+ </ErrorBoundary>
70
+ </MobileProvider>
71
+ </SupabaseProvider>
94
72
  </ClerkThemeProvider>
95
73
  </BrowserRouter>
96
74
  </I18nProvider>
@@ -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 { mockTodos, createTodo, createTodos, type Todo } from './todos';
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
+ ];
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { delay, http, HttpResponse } from 'msw';
6
6
 
7
- import { API_CONFIG } from '@/lib/api';
7
+ import { API_CONFIG } from '@/lib/config';
8
8
  import type { Todo } from '@/types/api';
9
9
 
10
10
  import { mockTodos } from '../fixtures/todos';
@@ -1,3 +1,9 @@
1
+ // Shared test constants
2
+ export * from './constants';
3
+
4
+ // MSW handlers and server
1
5
  export { handlers } from './handlers';
2
6
  export { server } from './node';
7
+
8
+ // Fixtures
3
9
  export * from './fixtures';
@@ -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
+ });