Skip to content

Commit cee04a1

Browse files
test: add E2E tests for workspace member management (#110)
- 7 E2E tests covering invite, pending list, revoke, accept, role change, remove, and member role restrictions - Fix ambiguous Supabase join in members page and invite form (members has two FKs to profiles; PostgREST requires explicit constraint name) - Add supabase-admin test helper for creating/deleting temporary test users - Update conventions.md with Supabase join disambiguation pattern - Update quality.md to reflect new test coverage Co-authored-by: Ona <no-reply@ona.com>
1 parent aed3cef commit cee04a1

6 files changed

Lines changed: 490 additions & 6 deletions

File tree

.agents/conventions.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,28 @@ The helper accepts `PostgrestError` (from query results) and generic `Error` (fr
141141
catch blocks). It tags the Sentry event with the operation name, error code, and
142142
message so errors are filterable in the Sentry dashboard.
143143

144+
### Disambiguating Supabase joins
145+
146+
When a table has multiple foreign keys to the same target table, PostgREST cannot
147+
infer which relationship to use. The query silently returns `null` data (no error
148+
thrown). Disambiguate by specifying the FK constraint name:
149+
150+
```typescript
151+
// BAD — ambiguous: members has both user_id and invited_by referencing profiles
152+
const { data } = await supabase
153+
.from("members")
154+
.select("*, profiles(email, display_name)");
155+
// data will be null with a PGRST201 error
156+
157+
// GOOD — specify the FK constraint
158+
const { data } = await supabase
159+
.from("members")
160+
.select("*, profiles!members_user_id_fkey(email, display_name)");
161+
```
162+
163+
Check `supabase/migrations/` for constraint names. The naming convention is
164+
`{table}_{column}_fkey`.
165+
144166
### API route catch blocks
145167

146168
All catch blocks in API routes must call `Sentry.captureException`:

.agents/quality.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Updated weekly by the Automation Auditor. Tracks code quality per domain.
2121
| Editor | A | Full Lexical editor: slash commands, floating toolbar, floating link editor, drag-and-drop blocks, code highlighting, image upload, callouts, collapsible/toggle blocks. Markdown import/export. 4 unit test files (24 tests): theme mapping, markdown utils, design spec compliance, Node.contains safety. 4 E2E specs (editor-drag, editor-link, editor-slash-commands, editor-toolbar). Auto-save with debounce. Sentry error capture on save failures and image uploads. |
2222
| Search | B | Full-text search via PostgreSQL tsvector + tsquery. API route with integration tests (8 tests). Sidebar search component with debounced input (300ms) and results dropdown. Sentry error capture. No E2E test for search interaction. |
2323
| Import/Export | B | Markdown export (download .md) and import (parse .md, create page) via page menu. Markdown utils with unit tests (8 tests). No E2E test for import/export flow. |
24-
| Members | B | Member list with role badges, role change, remove. Invite form (email + role). Pending invite list with revoke. Invite accept page. Role select dropdown. Settings members page with server-side data fetching. No unit or E2E tests for member management. |
24+
| Members | A | Member list with role badges, role change, remove. Invite form (email + role). Pending invite list with revoke. Invite accept page. Role select dropdown. Settings members page with server-side data fetching. Full E2E coverage: invite, pending list, revoke, accept, role change, remove, member role restrictions. |
2525
| App Shell | B | Collapsible sidebar (desktop: aside, mobile: Sheet), sidebar context with ⌘+\ shortcut, workspace switcher, page tree, user menu with sign-out. Clean component decomposition. No unit tests for sidebar context or app shell layout. |
2626
| API Routes | A | Health endpoint (DB connectivity check, 6 tests) and search endpoint (full-text search, 8 tests). Both routes have Sentry error capture. Both have integration tests with mocked Supabase. |
2727
| UI Components | A | 13 shadcn/ui components (base-nova style): alert-dialog, badge, button, card, dialog, dropdown-menu, input, label, select, separator, sheet, table, tooltip. Overlay opacity regression test (2 tests). Toast error duration regression test (1 test). Design tokens use oklch color space, --radius: 0 for sharp corners. |
@@ -48,7 +48,7 @@ Updated weekly by the Automation Auditor. Tracks code quality per domain.
4848

4949
## Known Gaps
5050

51-
- **Members**: No unit or E2E tests for member list, invite form, invite accept, or role changes.
51+
- **Members**: Full E2E coverage added (7 tests). Disambiguated Supabase join bug fixed in members page and invite form.
5252
- **Search UI**: No E2E test for the sidebar search interaction (typing, results display, navigation).
5353
- **Import/Export**: No E2E test for markdown export/import flow via page menu.
5454
- **Page tree**: No unit tests for the tree manipulation logic (nest, unnest, reorder). The component is 836 lines — extracting tree logic into a utility would improve testability.

e2e/fixtures/supabase-admin.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
3+
/**
4+
* Creates a Supabase admin client using the secret (service role) key.
5+
* Used in E2E tests to create/delete temporary test users and query data
6+
* that bypasses RLS.
7+
*/
8+
function getAdminClient() {
9+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
10+
const secretKey = process.env.SUPABASE_SECRET_KEY;
11+
12+
if (!url || !secretKey) {
13+
throw new Error(
14+
"NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY must be set for admin E2E helpers"
15+
);
16+
}
17+
18+
return createClient(url, secretKey, {
19+
auth: { autoRefreshToken: false, persistSession: false },
20+
});
21+
}
22+
23+
interface TestUser {
24+
id: string;
25+
email: string;
26+
password: string;
27+
}
28+
29+
/**
30+
* Creates a temporary test user via the Supabase Admin API.
31+
* The user is auto-confirmed so they can sign in immediately.
32+
*/
33+
export async function createTestUser(
34+
email: string,
35+
password: string,
36+
displayName: string
37+
): Promise<TestUser> {
38+
const admin = getAdminClient();
39+
40+
const { data, error } = await admin.auth.admin.createUser({
41+
email,
42+
password,
43+
email_confirm: true,
44+
user_metadata: { display_name: displayName },
45+
});
46+
47+
if (error) {
48+
throw new Error(`Failed to create test user ${email}: ${error.message}`);
49+
}
50+
51+
return { id: data.user.id, email, password };
52+
}
53+
54+
/**
55+
* Deletes a test user and all their data via the Supabase Admin API.
56+
*/
57+
export async function deleteTestUser(userId: string): Promise<void> {
58+
const admin = getAdminClient();
59+
60+
// Delete memberships first (the user's personal workspace will cascade)
61+
await admin.from("members").delete().eq("user_id", userId);
62+
63+
// Delete workspaces created by this user
64+
await admin.from("workspaces").delete().eq("created_by", userId);
65+
66+
// Delete profile
67+
await admin.from("profiles").delete().eq("id", userId);
68+
69+
// Delete the auth user
70+
const { error } = await admin.auth.admin.deleteUser(userId);
71+
if (error) {
72+
console.warn(`Failed to delete test user ${userId}: ${error.message}`);
73+
}
74+
}
75+
76+
/**
77+
* Fetches the invite token for a given email and workspace from the database.
78+
* Bypasses RLS via the admin client.
79+
*/
80+
export async function getInviteToken(
81+
workspaceId: string,
82+
email: string
83+
): Promise<string> {
84+
const admin = getAdminClient();
85+
86+
const { data, error } = await admin
87+
.from("workspace_invites")
88+
.select("token")
89+
.eq("workspace_id", workspaceId)
90+
.ilike("email", email)
91+
.is("accepted_at", null)
92+
.order("created_at", { ascending: false })
93+
.limit(1)
94+
.maybeSingle();
95+
96+
if (error || !data) {
97+
throw new Error(
98+
`No pending invite found for ${email}: ${error?.message ?? "not found"}`
99+
);
100+
}
101+
102+
return data.token;
103+
}
104+
105+
/**
106+
* Fetches the workspace ID and slug for a user's first non-personal workspace,
107+
* or their personal workspace if no team workspaces exist.
108+
*/
109+
export async function getWorkspaceForUser(
110+
userId: string
111+
): Promise<{ id: string; slug: string }> {
112+
const admin = getAdminClient();
113+
114+
const { data, error } = await admin
115+
.from("members")
116+
.select("workspace_id, workspaces(id, slug, is_personal)")
117+
.eq("user_id", userId)
118+
.limit(10);
119+
120+
if (error || !data || data.length === 0) {
121+
throw new Error(
122+
`No workspace found for user ${userId}: ${error?.message ?? "no memberships"}`
123+
);
124+
}
125+
126+
// Prefer non-personal workspace, fall back to personal
127+
for (const row of data) {
128+
const ws = row.workspaces as unknown as {
129+
id: string;
130+
slug: string;
131+
is_personal: boolean;
132+
};
133+
if (!ws.is_personal) {
134+
return { id: ws.id, slug: ws.slug };
135+
}
136+
}
137+
138+
const ws = data[0].workspaces as unknown as { id: string; slug: string };
139+
return { id: ws.id, slug: ws.slug };
140+
}
141+
142+
/**
143+
* Cleans up any pending invites for a given email across all workspaces.
144+
*/
145+
export async function cleanupInvitesForEmail(email: string): Promise<void> {
146+
const admin = getAdminClient();
147+
await admin
148+
.from("workspace_invites")
149+
.delete()
150+
.ilike("email", email)
151+
.is("accepted_at", null);
152+
}

0 commit comments

Comments
 (0)