@react-spa-scaffold/mcp 2.1.1 → 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.
- package/README.md +2 -1
- 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/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.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/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- 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/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.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.test.js +6 -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/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 +3 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +132 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.js +1 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +2 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +64 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +44 -10
- package/templates/.github/workflows/ci.yml +12 -4
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +251 -2
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +133 -11
- 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 +24 -2
- 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/gitignore +5 -0
- package/templates/package.json +15 -3
- package/templates/playwright.config.ts +39 -4
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +13 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- 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 +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +5 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +40 -2
- 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/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -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/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +5 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +58 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +19 -31
- 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/stores/preferencesStore.ts +35 -9
- package/templates/src/test/clerkMock.tsx +137 -0
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +51 -2
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +10 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +42 -2
- package/templates/src/types/database.ts +46 -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/vitest.config.ts +9 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Supabase Integration
|
|
2
|
+
|
|
3
|
+
Clerk-Supabase integration architecture, RLS patterns, and database operations.
|
|
4
|
+
For quick-start usage and hooks, see [CLAUDE.md](../CLAUDE.md#database-supabase).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Architecture
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
ClerkProvider
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
useSession().getToken() ──► Clerk JWT (role: authenticated)
|
|
15
|
+
│
|
|
16
|
+
▼
|
|
17
|
+
SupabaseProvider
|
|
18
|
+
│
|
|
19
|
+
▼
|
|
20
|
+
createClient({ accessToken: getToken }) ──► Supabase API
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
PostgreSQL + Row Level Security (RLS)
|
|
24
|
+
└── auth.uid() = Clerk user_id (JWT sub claim)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Key Design Decisions:**
|
|
28
|
+
|
|
29
|
+
- **No Supabase Auth** - Clerk handles authentication; Supabase validates JWTs via third-party provider
|
|
30
|
+
- **Modern `accessToken` pattern** - No JWT templates needed (post April 2025)
|
|
31
|
+
- **Automatic `role` claim** - Clerk integration adds `"role": "authenticated"` to JWTs, enabling RLS `TO authenticated` policies
|
|
32
|
+
- **Session-based client** - Client recreates only on `session?.id` change, not every render
|
|
33
|
+
- **TanStack Query integration** - All data fetching uses React Query for caching/invalidation
|
|
34
|
+
- **RLS enforcement** - Security at database level via `auth.uid()` = Clerk `user_id`
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
### 1. Enable Clerk as Third-Party Auth Provider
|
|
41
|
+
|
|
42
|
+
1. **Clerk Dashboard** → Integrations → Supabase → Activate
|
|
43
|
+
2. Copy your **Clerk domain** (e.g., `your-app.clerk.accounts.dev`)
|
|
44
|
+
3. **Supabase Dashboard** → Authentication → Sign In/Up → Add provider → Clerk
|
|
45
|
+
4. Paste your Clerk domain
|
|
46
|
+
|
|
47
|
+
### 2. Environment Variables
|
|
48
|
+
|
|
49
|
+
| Variable | Description |
|
|
50
|
+
| ---------------------------- | ------------------------------------------- |
|
|
51
|
+
| `VITE_SUPABASE_DATABASE_URL` | `https://your-project.supabase.co` |
|
|
52
|
+
| `VITE_SUPABASE_ANON_KEY` | Client-side API key (respects RLS) |
|
|
53
|
+
| `SUPABASE_PROJECT_ID` | For `npm run db:types` (subdomain from URL) |
|
|
54
|
+
|
|
55
|
+
Both URL and key must be set together. Find them in Supabase Dashboard → Project Settings → Data API.
|
|
56
|
+
|
|
57
|
+
### 3. Provider Hierarchy
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// main.tsx - SupabaseProvider MUST be inside ClerkProvider
|
|
61
|
+
<ClerkProvider>
|
|
62
|
+
<SupabaseProvider>
|
|
63
|
+
<App />
|
|
64
|
+
</SupabaseProvider>
|
|
65
|
+
</ClerkProvider>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## File Structure
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
src/
|
|
74
|
+
├── lib/supabase/
|
|
75
|
+
│ └── client.ts # createSupabaseClient(getToken)
|
|
76
|
+
├── contexts/
|
|
77
|
+
│ └── supabaseContext.tsx # SupabaseProvider + useSupabase hook
|
|
78
|
+
├── hooks/supabase/
|
|
79
|
+
│ ├── useSupabaseQuery.ts # Generic SELECT with TanStack Query
|
|
80
|
+
│ └── useProfiles.ts # Profile CRUD hooks
|
|
81
|
+
├── types/
|
|
82
|
+
│ ├── supabase.ts # Auto-generated (npm run db:types)
|
|
83
|
+
│ └── database.ts # Convenience aliases (Profile, etc.)
|
|
84
|
+
└── components/shared/
|
|
85
|
+
└── ProfileSync/ # Auto-sync Clerk user to Supabase
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Database & RLS
|
|
91
|
+
|
|
92
|
+
### Complete Table Example
|
|
93
|
+
|
|
94
|
+
```sql
|
|
95
|
+
-- Create table with user_id linked to Clerk
|
|
96
|
+
CREATE TABLE profiles (
|
|
97
|
+
id TEXT PRIMARY KEY, -- Clerk user_id (auth.uid())
|
|
98
|
+
email TEXT NOT NULL,
|
|
99
|
+
full_name TEXT,
|
|
100
|
+
avatar_url TEXT,
|
|
101
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
102
|
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- Enable Row Level Security
|
|
106
|
+
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
107
|
+
|
|
108
|
+
-- RLS Policies (all four CRUD operations)
|
|
109
|
+
CREATE POLICY "Users can view own profile"
|
|
110
|
+
ON profiles FOR SELECT TO authenticated
|
|
111
|
+
USING (id = (auth.uid())::text);
|
|
112
|
+
|
|
113
|
+
CREATE POLICY "Users can insert own profile"
|
|
114
|
+
ON profiles FOR INSERT TO authenticated
|
|
115
|
+
WITH CHECK (id = (auth.uid())::text);
|
|
116
|
+
|
|
117
|
+
CREATE POLICY "Users can update own profile"
|
|
118
|
+
ON profiles FOR UPDATE TO authenticated
|
|
119
|
+
USING (id = (auth.uid())::text)
|
|
120
|
+
WITH CHECK (id = (auth.uid())::text);
|
|
121
|
+
|
|
122
|
+
CREATE POLICY "Users can delete own profile"
|
|
123
|
+
ON profiles FOR DELETE TO authenticated
|
|
124
|
+
USING (id = (auth.uid())::text);
|
|
125
|
+
|
|
126
|
+
-- Auto-update timestamp trigger
|
|
127
|
+
CREATE OR REPLACE FUNCTION update_updated_at()
|
|
128
|
+
RETURNS TRIGGER AS $$
|
|
129
|
+
BEGIN
|
|
130
|
+
NEW.updated_at = NOW();
|
|
131
|
+
RETURN NEW;
|
|
132
|
+
END;
|
|
133
|
+
$$ LANGUAGE plpgsql;
|
|
134
|
+
|
|
135
|
+
CREATE TRIGGER profiles_updated_at
|
|
136
|
+
BEFORE UPDATE ON profiles
|
|
137
|
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### RLS Policy Template
|
|
141
|
+
|
|
142
|
+
For tables where `user_id` is NOT the primary key:
|
|
143
|
+
|
|
144
|
+
```sql
|
|
145
|
+
CREATE TABLE tasks (
|
|
146
|
+
id SERIAL PRIMARY KEY,
|
|
147
|
+
name TEXT NOT NULL,
|
|
148
|
+
user_id TEXT NOT NULL DEFAULT auth.jwt()->>'sub', -- Auto-set from Clerk
|
|
149
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
|
153
|
+
|
|
154
|
+
-- Apply this pattern for each operation (SELECT, INSERT, UPDATE, DELETE)
|
|
155
|
+
CREATE POLICY "Users can select own tasks"
|
|
156
|
+
ON tasks FOR SELECT TO authenticated
|
|
157
|
+
USING (user_id = (auth.uid())::text);
|
|
158
|
+
|
|
159
|
+
CREATE POLICY "Users can insert own tasks"
|
|
160
|
+
ON tasks FOR INSERT TO authenticated
|
|
161
|
+
WITH CHECK (user_id = (auth.uid())::text);
|
|
162
|
+
|
|
163
|
+
-- UPDATE needs both USING (existing rows) and WITH CHECK (new values)
|
|
164
|
+
-- DELETE only needs USING
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### SQL Functions Reference
|
|
168
|
+
|
|
169
|
+
| Function | Returns | Use Case |
|
|
170
|
+
| --------------------- | ------- | ---------------------------------------------- |
|
|
171
|
+
| `auth.uid()` | UUID | Primary comparison (`::text` for TEXT columns) |
|
|
172
|
+
| `auth.jwt()` | JSON | Access full JWT claims |
|
|
173
|
+
| `auth.jwt()->>'sub'` | TEXT | User ID directly as text |
|
|
174
|
+
| `(select auth.uid())` | UUID | Performance optimization in RLS |
|
|
175
|
+
|
|
176
|
+
### MCP Tools for Database Operations
|
|
177
|
+
|
|
178
|
+
| Tool | Purpose |
|
|
179
|
+
| ------------------------------------------ | ------------------------------------ |
|
|
180
|
+
| `mcp__supabase__apply_migration` | Apply DDL (CREATE, ALTER, DROP) |
|
|
181
|
+
| `mcp__supabase__execute_sql` | Run queries (SELECT, INSERT, UPDATE) |
|
|
182
|
+
| `mcp__supabase__list_tables` | List all tables in schemas |
|
|
183
|
+
| `mcp__supabase__list_migrations` | View applied migrations |
|
|
184
|
+
| `mcp__supabase__get_advisors` | Check security/performance issues |
|
|
185
|
+
| `mcp__supabase__generate_typescript_types` | Generate types from schema |
|
|
186
|
+
|
|
187
|
+
### Type Generation
|
|
188
|
+
|
|
189
|
+
After schema changes:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run db:types # Regenerates src/types/supabase.ts
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Add convenience aliases in `src/types/database.ts`:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
export type Profile = Tables<'profiles'>;
|
|
199
|
+
export type ProfileInsert = TablesInsert<'profiles'>;
|
|
200
|
+
export type ProfileUpdate = TablesUpdate<'profiles'>;
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## React Integration
|
|
206
|
+
|
|
207
|
+
### Hooks Reference
|
|
208
|
+
|
|
209
|
+
| Hook | Purpose | Returns |
|
|
210
|
+
| --------------------------- | --------------------------- | -------------------------------- |
|
|
211
|
+
| `useSupabase()` | Direct client access | `TypedSupabaseClient` |
|
|
212
|
+
| `useSupabaseQuery(options)` | Generic SELECT with caching | `UseQueryResult<T[]>` |
|
|
213
|
+
| `useProfile()` | Current user's profile | `{ profile, exists, isLoading }` |
|
|
214
|
+
| `useCurrentProfile()` | Raw profile query | `UseQueryResult<Profile[]>` |
|
|
215
|
+
| `useUpsertProfile()` | Create/update profile | `UseMutationResult` |
|
|
216
|
+
| `useUpdateProfile()` | Update current profile | `UseMutationResult` |
|
|
217
|
+
| `useDeleteProfile()` | Delete current profile | `UseMutationResult` |
|
|
218
|
+
|
|
219
|
+
**`useSupabaseQuery` options:** `{ table, queryKey, select?, filter?, queryOptions? }`
|
|
220
|
+
|
|
221
|
+
**Query key pattern:** All queries use `['supabase', table, ...queryKey]` for cache invalidation.
|
|
222
|
+
|
|
223
|
+
For usage examples, see [CLAUDE.md](../CLAUDE.md#database-supabase).
|
|
224
|
+
|
|
225
|
+
### ProfileSync Component
|
|
226
|
+
|
|
227
|
+
Automatically syncs Clerk user data to Supabase on sign-in:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
import { ProfileSync } from '@/components/shared';
|
|
231
|
+
|
|
232
|
+
function App() {
|
|
233
|
+
return (
|
|
234
|
+
<>
|
|
235
|
+
<ProfileSync /> {/* Optional: onSyncComplete, onSyncError callbacks */}
|
|
236
|
+
<Routes>...</Routes>
|
|
237
|
+
</>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Behavior:** Creates profile on first login, updates if email changed, runs once per mount.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Testing
|
|
247
|
+
|
|
248
|
+
### Mock Utilities
|
|
249
|
+
|
|
250
|
+
Import from `@/test`:
|
|
251
|
+
|
|
252
|
+
| Utility | Purpose |
|
|
253
|
+
| ----------------------------------------- | ---------------------------------------- |
|
|
254
|
+
| `setMockSupabaseData(data[])` | Set data to return |
|
|
255
|
+
| `setMockSupabaseError({ message, code })` | Simulate error |
|
|
256
|
+
| `resetSupabaseMocks()` | Reset to defaults (call in `beforeEach`) |
|
|
257
|
+
| `createProfile(overrides?)` | Create mock profile |
|
|
258
|
+
| `mockProfiles` | Pre-built profile array |
|
|
259
|
+
|
|
260
|
+
For test examples, see [CLAUDE.md](../CLAUDE.md#database-supabase).
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Security
|
|
265
|
+
|
|
266
|
+
- **Always enable RLS** on every table
|
|
267
|
+
- **Use `anon` key** in client code (respects RLS)
|
|
268
|
+
- **Never expose `service_role` key** (bypasses RLS)
|
|
269
|
+
- **Cast `auth.uid()` to text** when comparing with TEXT columns: `(auth.uid())::text`
|
|
270
|
+
- **Use `(select auth.uid())`** in policies for performance (prevents re-evaluation per row)
|
|
271
|
+
- **Test with multiple accounts** to verify users can't access each other's data
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Deployment
|
|
276
|
+
|
|
277
|
+
For Netlify deployment with Supabase integration, see [DEPLOYMENT.md](./DEPLOYMENT.md).
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Troubleshooting
|
|
282
|
+
|
|
283
|
+
| Issue | Cause | Fix |
|
|
284
|
+
| -------------------------------------------------- | --------------------------------- | ---------------------------------------------------- |
|
|
285
|
+
| "useSupabase must be used within SupabaseProvider" | Component outside provider | Check `main.tsx` provider order |
|
|
286
|
+
| 403 RLS Policy Violation | JWT missing `role: authenticated` | Verify Clerk-Supabase integration enabled |
|
|
287
|
+
| Empty arrays returned | RLS blocking access | Check policies use `auth.uid()` correctly |
|
|
288
|
+
| Stale data after mutations | Missing invalidation | Ensure `invalidateQueries(['supabase', 'profiles'])` |
|
|
289
|
+
| Missing env vars error | Incomplete config | Both URL and key must be set |
|
|
290
|
+
|
|
291
|
+
### Debug Token Claims
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
const { session } = useSession();
|
|
295
|
+
const token = await session?.getToken();
|
|
296
|
+
if (token) {
|
|
297
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
298
|
+
console.log(payload); // { sub: "user_xxx", role: "authenticated", ... }
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Resources
|
|
305
|
+
|
|
306
|
+
- [AUTHENTICATION.md](./AUTHENTICATION.md) - Clerk authentication setup and configuration
|
|
307
|
+
- [Supabase + Clerk Integration Guide](https://supabase.com/docs/guides/auth/third-party/clerk)
|
|
308
|
+
- [Row Level Security Docs](https://supabase.com/docs/guides/auth/row-level-security)
|
|
309
|
+
- [Clerk Supabase Docs](https://clerk.com/docs/integrations/databases/supabase)
|
|
310
|
+
- [Supabase MCP Server](https://supabase.com/docs/guides/getting-started/mcp)
|
|
@@ -30,7 +30,180 @@ import { screen, renderHook, act, waitFor, fireEvent } from '@testing-library/re
|
|
|
30
30
|
import userEvent from '@testing-library/user-event';
|
|
31
31
|
|
|
32
32
|
// Custom utilities from @/test
|
|
33
|
-
import { render,
|
|
33
|
+
import { render, server, mockMatchMedia, silenceConsoleError } from '@/test';
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Mock Infrastructure
|
|
37
|
+
|
|
38
|
+
The codebase uses a unified mocking approach with shared constants and reusable utilities.
|
|
39
|
+
|
|
40
|
+
### Directory Structure
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
src/test/ # Test utilities
|
|
44
|
+
├── index.ts # Public API - import from '@/test'
|
|
45
|
+
├── clerkMock.tsx # Clerk auth mocks
|
|
46
|
+
├── supabaseMock.ts # Supabase mocks
|
|
47
|
+
├── mocks.ts # Browser API mocks
|
|
48
|
+
└── providers.tsx # Test render wrapper
|
|
49
|
+
|
|
50
|
+
src/mocks/ # MSW infrastructure
|
|
51
|
+
├── constants.ts # Shared test values (MOCK_USER, etc.)
|
|
52
|
+
├── fixtures/ # Test data factories
|
|
53
|
+
│ ├── profiles.ts # createProfile(), mockProfiles
|
|
54
|
+
│ └── todos.ts # createTodo(), mockTodos
|
|
55
|
+
├── handlers/ # MSW request handlers
|
|
56
|
+
│ ├── supabase.ts # Supabase API mocks
|
|
57
|
+
│ └── todos.ts # JSONPlaceholder mocks
|
|
58
|
+
└── node.ts # MSW server setup
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Shared Constants
|
|
62
|
+
|
|
63
|
+
All mocks use shared constants from `@/mocks/constants.ts` to ensure consistency:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { MOCK_USER, MOCK_SESSION_ID } from '@/test';
|
|
67
|
+
|
|
68
|
+
// MOCK_USER.id = 'user_123'
|
|
69
|
+
// MOCK_USER.email = 'test@example.com'
|
|
70
|
+
// MOCK_USER.fullName = 'Test User'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Clerk Mocks
|
|
74
|
+
|
|
75
|
+
Control authentication state in tests:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
|
|
79
|
+
|
|
80
|
+
beforeEach(() => resetClerkMocks());
|
|
81
|
+
|
|
82
|
+
it('shows sign-in when not authenticated', () => {
|
|
83
|
+
setMockClerkSignedIn(false);
|
|
84
|
+
render(<ProtectedRoute />);
|
|
85
|
+
expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Supabase Mocks
|
|
90
|
+
|
|
91
|
+
Control Supabase query responses:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { setMockSupabaseData, setMockSupabaseError, createProfile, resetSupabaseMocks } from '@/test';
|
|
95
|
+
|
|
96
|
+
beforeEach(() => resetSupabaseMocks());
|
|
97
|
+
|
|
98
|
+
it('displays profile data', async () => {
|
|
99
|
+
setMockSupabaseData([createProfile({ full_name: 'John Doe' })]);
|
|
100
|
+
render(<ProfileCard />);
|
|
101
|
+
await waitFor(() => expect(screen.getByText('John Doe')).toBeInTheDocument());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles database error', async () => {
|
|
105
|
+
setMockSupabaseError({ message: 'Connection failed', code: 'NETWORK_ERROR' });
|
|
106
|
+
render(<ProfileCard />);
|
|
107
|
+
await waitFor(() => expect(screen.getByText(/error/i)).toBeInTheDocument());
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Browser API Mocks
|
|
112
|
+
|
|
113
|
+
Reusable mocks for common browser APIs:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { mockMatchMedia, mockScrollTo, mockAnimationFrame, silenceConsoleError, silenceConsoleWarn } from '@/test';
|
|
117
|
+
|
|
118
|
+
// Media queries
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
window.matchMedia = mockMatchMedia(true); // matches
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Scroll behavior
|
|
124
|
+
it('scrolls to top', () => {
|
|
125
|
+
const scrollSpy = mockScrollTo();
|
|
126
|
+
triggerScroll();
|
|
127
|
+
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Animation frames
|
|
131
|
+
let getCallback: () => FrameRequestCallback | null;
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
getCallback = mockAnimationFrame();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('updates on animation frame', () => {
|
|
137
|
+
const callback = getCallback();
|
|
138
|
+
act(() => callback?.(0));
|
|
139
|
+
// assert state change
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Silence console during error tests
|
|
143
|
+
it('handles error gracefully', () => {
|
|
144
|
+
const spy = silenceConsoleError();
|
|
145
|
+
triggerError();
|
|
146
|
+
expect(handleError).toHaveBeenCalled();
|
|
147
|
+
spy.mockRestore();
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Fetch Mocking
|
|
152
|
+
|
|
153
|
+
Use `vi.spyOn` for type-safe fetch mocking:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
vi.spyOn(global, 'fetch');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
vi.restoreAllMocks();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('handles API response', async () => {
|
|
165
|
+
vi.mocked(global.fetch).mockResolvedValueOnce({
|
|
166
|
+
ok: true,
|
|
167
|
+
status: 200,
|
|
168
|
+
json: () => Promise.resolve({ data: 'test' }),
|
|
169
|
+
} as Response);
|
|
170
|
+
|
|
171
|
+
const result = await fetchData();
|
|
172
|
+
expect(result.data).toBe('test');
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### MSW (Mock Service Worker)
|
|
177
|
+
|
|
178
|
+
MSW handlers intercept HTTP requests. Override per-test:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { http, HttpResponse } from 'msw';
|
|
182
|
+
import { server } from '@/test';
|
|
183
|
+
|
|
184
|
+
it('handles API error', async () => {
|
|
185
|
+
server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 })));
|
|
186
|
+
// Test error handling...
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
MSW handlers auto-reset after each test via `src/test-setup.ts`.
|
|
191
|
+
|
|
192
|
+
### Test Data Factories
|
|
193
|
+
|
|
194
|
+
Use factories to create test data with sensible defaults:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { createProfile, createTodo } from '@/test';
|
|
198
|
+
|
|
199
|
+
// Override specific fields
|
|
200
|
+
const profile = createProfile({ full_name: 'Custom Name' });
|
|
201
|
+
const todo = createTodo({ completed: true });
|
|
202
|
+
|
|
203
|
+
// Create multiple
|
|
204
|
+
import { createProfiles, createTodos } from '@/mocks/fixtures';
|
|
205
|
+
const profiles = createProfiles(5);
|
|
206
|
+
const todos = createTodos(10, { userId: 1 });
|
|
34
207
|
```
|
|
35
208
|
|
|
36
209
|
## Core Patterns
|
|
@@ -50,25 +223,6 @@ it.each([
|
|
|
50
223
|
// ❌ Avoid - repetitive individual tests
|
|
51
224
|
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 Bytes'));
|
|
52
225
|
it('formats 1024 bytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
|
53
|
-
it('formats 1MB', () => expect(formatBytes(1024 * 1024)).toBe('1 MB'));
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### Use Shared Mocks
|
|
57
|
-
|
|
58
|
-
```typescript
|
|
59
|
-
import { mockMatchMedia } from '@/test';
|
|
60
|
-
|
|
61
|
-
describe('useMediaQuery', () => {
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
window.matchMedia = mockMatchMedia(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('detects desktop viewport', () => {
|
|
67
|
-
window.matchMedia = mockMatchMedia(true); // matches min-width query
|
|
68
|
-
const { result } = renderHook(() => useIsDesktop());
|
|
69
|
-
expect(result.current).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
226
|
```
|
|
73
227
|
|
|
74
228
|
### Component Testing
|
|
@@ -120,61 +274,6 @@ it('updates after async action', async () => {
|
|
|
120
274
|
});
|
|
121
275
|
```
|
|
122
276
|
|
|
123
|
-
### Mocking
|
|
124
|
-
|
|
125
|
-
```typescript
|
|
126
|
-
import { mockMatchMedia } from '@/test';
|
|
127
|
-
|
|
128
|
-
// Mock modules at top of file
|
|
129
|
-
vi.mock('@/lib/storage', () => ({
|
|
130
|
-
setStorageItem: vi.fn(() => true),
|
|
131
|
-
}));
|
|
132
|
-
|
|
133
|
-
// Mock browser APIs using shared utilities
|
|
134
|
-
beforeEach(() => {
|
|
135
|
-
window.matchMedia = mockMatchMedia(false);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
afterEach(() => {
|
|
139
|
-
vi.restoreAllMocks();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Mock fetch for API tests
|
|
143
|
-
const mockFetch = vi.fn();
|
|
144
|
-
beforeEach(() => {
|
|
145
|
-
global.fetch = mockFetch;
|
|
146
|
-
});
|
|
147
|
-
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### MSW (Mock Service Worker)
|
|
151
|
-
|
|
152
|
-
MSW handlers are organized in `src/mocks/`:
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
src/mocks/
|
|
156
|
-
├── handlers/
|
|
157
|
-
│ ├── index.ts # Combines all handlers
|
|
158
|
-
│ └── todos.ts # Example domain handlers
|
|
159
|
-
├── fixtures/
|
|
160
|
-
│ └── todos.ts # Response data
|
|
161
|
-
├── browser.ts # Browser worker setup
|
|
162
|
-
└── node.ts # Node server for tests
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
Override handlers per-test:
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
import { http, HttpResponse, server } from '@/test';
|
|
169
|
-
|
|
170
|
-
it('handles API error', async () => {
|
|
171
|
-
server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 })));
|
|
172
|
-
// Test error handling...
|
|
173
|
-
});
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
MSW handlers auto-reset after each test via `src/test-setup.ts`.
|
|
177
|
-
|
|
178
277
|
### Store Testing (Zustand)
|
|
179
278
|
|
|
180
279
|
```typescript
|
|
@@ -192,6 +291,24 @@ describe('preferencesStore', () => {
|
|
|
192
291
|
});
|
|
193
292
|
```
|
|
194
293
|
|
|
294
|
+
## Mock Best Practices
|
|
295
|
+
|
|
296
|
+
### DO
|
|
297
|
+
|
|
298
|
+
- ✅ Use shared constants (`MOCK_USER`, etc.) for consistency
|
|
299
|
+
- ✅ Use `vi.spyOn()` for type-safe mocking
|
|
300
|
+
- ✅ Use `vi.mocked()` wrapper for TypeScript support
|
|
301
|
+
- ✅ Reset mocks in `beforeEach`/`afterEach`
|
|
302
|
+
- ✅ Restore mocks after silencing console
|
|
303
|
+
- ✅ Use factory functions for test data
|
|
304
|
+
|
|
305
|
+
### DON'T
|
|
306
|
+
|
|
307
|
+
- ❌ Hardcode user IDs or emails across files
|
|
308
|
+
- ❌ Use `global.fetch = mockFn` (use `vi.spyOn` instead)
|
|
309
|
+
- ❌ Create duplicate mock utilities in test files
|
|
310
|
+
- ❌ Forget to reset stateful mocks between tests
|
|
311
|
+
|
|
195
312
|
## Test Organization
|
|
196
313
|
|
|
197
314
|
### Structure Within Test Files
|
|
@@ -239,9 +356,10 @@ npm run test:ui # Visual UI
|
|
|
239
356
|
## Checklist for New Tests
|
|
240
357
|
|
|
241
358
|
- [ ] File follows `[name].test.ts(x)` naming
|
|
359
|
+
- [ ] Uses shared mocks from `@/test`
|
|
360
|
+
- [ ] Uses shared constants (`MOCK_USER`, etc.)
|
|
242
361
|
- [ ] Uses `it.each` for parameterized cases
|
|
243
|
-
- [ ] Mocks are
|
|
244
|
-
- [ ] No duplicate helper functions
|
|
362
|
+
- [ ] Mocks are reset in `beforeEach`/`afterEach`
|
|
245
363
|
- [ ] Tests behavior, not implementation
|
|
246
364
|
- [ ] Async tests use `await`/`waitFor` properly
|
|
247
365
|
- [ ] Coverage threshold maintained (80%)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { clerk, clerkSetup } from '@clerk/testing/playwright';
|
|
2
|
+
import { expect, test as setup } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
import { ASYNC_CONTENT_TIMEOUT, AUTH_STATE_FILE } from '../fixtures';
|
|
5
|
+
|
|
6
|
+
// Setup must be run serially for Clerk initialization
|
|
7
|
+
setup.describe.configure({ mode: 'serial' });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configure Playwright with Clerk testing token.
|
|
11
|
+
* This obtains a Testing Token when the test suite starts.
|
|
12
|
+
*
|
|
13
|
+
* Required: CLERK_SECRET_KEY environment variable
|
|
14
|
+
*/
|
|
15
|
+
setup('global setup', async () => {
|
|
16
|
+
// Skip if CLERK_SECRET_KEY is not set
|
|
17
|
+
if (!process.env.CLERK_SECRET_KEY) {
|
|
18
|
+
setup.skip(true, 'CLERK_SECRET_KEY required for authenticated tests');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await clerkSetup();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Authenticate and save state to storage.
|
|
26
|
+
* This creates a reusable auth state for authenticated tests.
|
|
27
|
+
*
|
|
28
|
+
* Required environment variables:
|
|
29
|
+
* - E2E_CLERK_USER_USERNAME: Test user email
|
|
30
|
+
* - E2E_CLERK_USER_PASSWORD: Test user password
|
|
31
|
+
*/
|
|
32
|
+
setup('authenticate and save state', async ({ page }) => {
|
|
33
|
+
const username = process.env.E2E_CLERK_USER_USERNAME;
|
|
34
|
+
const password = process.env.E2E_CLERK_USER_PASSWORD;
|
|
35
|
+
|
|
36
|
+
// Skip auth setup if credentials are not provided
|
|
37
|
+
if (!username || !password) {
|
|
38
|
+
setup.skip(true, 'E2E_CLERK_USER_USERNAME and E2E_CLERK_USER_PASSWORD required for authenticated tests');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Navigate to app and sign in
|
|
43
|
+
await page.goto('/');
|
|
44
|
+
|
|
45
|
+
await clerk.signIn({
|
|
46
|
+
page,
|
|
47
|
+
signInParams: {
|
|
48
|
+
strategy: 'password',
|
|
49
|
+
identifier: username,
|
|
50
|
+
password: password,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Verify authentication succeeded by checking for authenticated UI
|
|
55
|
+
await page.goto('/profile');
|
|
56
|
+
await expect(page.getByText('Your Profile')).toBeVisible({ timeout: ASYNC_CONTENT_TIMEOUT });
|
|
57
|
+
|
|
58
|
+
// Save authentication state for reuse in other tests
|
|
59
|
+
await page.context().storageState({ path: AUTH_STATE_FILE });
|
|
60
|
+
});
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import type { Page } from '@playwright/test';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
// ESM-compatible path resolution
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
/** Path to Clerk auth state file for authenticated tests */
|
|
9
|
+
export const AUTH_STATE_FILE = join(__dirname, '../.clerk/user.json');
|
|
10
|
+
|
|
11
|
+
/** Default timeout for waiting on profile/async content to load */
|
|
12
|
+
export const ASYNC_CONTENT_TIMEOUT = 10000;
|
|
2
13
|
|
|
3
14
|
/**
|
|
4
|
-
* Navigate to page with clean state (clears localStorage)
|
|
15
|
+
* Navigate to page with clean state (clears localStorage).
|
|
5
16
|
*/
|
|
6
|
-
export async function setupPage(page: Page, path = '/') {
|
|
17
|
+
export async function setupPage(page: Page, path = '/'): Promise<void> {
|
|
7
18
|
await page.goto(path);
|
|
8
19
|
await page.evaluate(() => localStorage.clear());
|
|
9
20
|
await page.reload();
|
|
10
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Setup page with completely fresh state (cookies + localStorage).
|
|
25
|
+
* Use when tests need isolation from previous test state.
|
|
26
|
+
*/
|
|
27
|
+
export async function setupCleanPage(page: Page): Promise<void> {
|
|
28
|
+
await page.context().clearCookies();
|
|
29
|
+
await page.goto('/');
|
|
30
|
+
await page.evaluate(() => localStorage.clear());
|
|
31
|
+
await page.reload();
|
|
32
|
+
}
|