Repository navigation
e2e test refactor with playwright #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b54be0c
9e21315
d02d44e
34ad163
ff23000
e79ad09
80f5a1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { test, expect } from './test'; | ||
| import { createVerifiedUser } from './helpers/auth-setup'; | ||
|
|
||
|
|
||
| test.describe('Authentication Flows', () => { | ||
| test.describe.configure({ mode: 'serial' }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| test('Sign Up with Email/Password', async ({ page, mailpit, db }) => { | ||
| // Ensure fresh email for this run | ||
| const userEmail = `auth-test-${Date.now()}@example.com`; | ||
| const userPassword = 'password123'; | ||
| const userFirstName = 'Playwright'; | ||
| const userLastName = 'TestUser'; | ||
|
|
||
| console.log(`Starting Sign Up test for: ${userEmail}`); | ||
|
|
||
| // Cleanup before test (just in case) | ||
| await db.cleanupUser(userEmail); | ||
|
|
||
| try { | ||
| await page.goto('/signup'); | ||
|
|
||
| // Fill signup form | ||
| await page.fill('input#firstName', userFirstName); | ||
| await page.fill('input#lastName', userLastName); | ||
| await page.fill('input[type="email"]', userEmail); | ||
|
|
||
| // Password fields | ||
| await page.locator('input[type="password"]').first().fill(userPassword); | ||
| await page.locator('input[type="password"]').nth(1).fill(userPassword); | ||
|
|
||
| // Click submit | ||
| await page.getByRole('button', { name: 'Sign Up', exact: true }).click(); | ||
|
|
||
| // Verify redirect - expect to go to verify-email page | ||
| await expect(page).toHaveURL(/\/verify-email/); | ||
| await expect(page.getByText('Check your email')).toBeVisible({ timeout: 15000 }); | ||
|
|
||
| // --- AUTOMATED VERIFICATION (Mailpit) --- | ||
| // Wait for email in Mailpit | ||
| const email = await mailpit.waitForEmail(userEmail, 'Verify your email'); | ||
|
|
||
| // Extract link - dynamically derive host to support CI/Docker | ||
| const authUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3002/api/auth'; | ||
| const cleanAuthUrl = authUrl.replace(/\/$/, ''); | ||
| const escapedAuthUrl = cleanAuthUrl.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const verificationUrl = mailpit.extractLink(email, new RegExp(`(${escapedAuthUrl}\\/verify-email\\?token=[^"\\s]+)`)); | ||
|
|
||
| console.log(`[Mailpit] Found URL: ${verificationUrl}`); | ||
|
|
||
| // Verify user state in DB BEFORE verification (should be unverified) | ||
| const exists = await db.userExists(userEmail); | ||
| expect(exists).toBe(true); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const verifiedBefore = await db.isEmailVerified(userEmail); | ||
| expect(verifiedBefore).toBe(false); | ||
|
|
||
| // Navigate to verification link | ||
| await page.goto(verificationUrl); | ||
|
|
||
| // Assert we were redirected away from the verification URL | ||
| await expect(page).not.toHaveURL(/\/verify-email\?/, { timeout: 10000 }); | ||
|
Comment on lines
+60
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Negative URL assertion passes on error pages — consider a positive destination check.
♻️ Proposed refinement // Assert we were redirected away from the verification URL
await expect(page).not.toHaveURL(/\/verify-email\?/, { timeout: 10000 });
+ // Verify landing on a known post-verification destination
+ await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 10000 });🤖 Prompt for AI Agents |
||
| // Verify landing on a known post-verification destination | ||
| await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 10000 }); | ||
|
|
||
| // Verify user state in DB AFTER verification (should be verified) | ||
| // Add retry/wait logic as DB update might be slightly async | ||
| await expect.poll(async () => { | ||
| return await db.isEmailVerified(userEmail); | ||
| }, { | ||
| message: 'User email should be verified in DB', | ||
| timeout: 5000 | ||
| }).toBe(true); | ||
| } finally { | ||
| // Enterprise Grade: Always clean up data, even if test fails | ||
| console.log(`[Teardown] Cleaning up user: ${userEmail}`); | ||
| await db.cleanupUser(userEmail); | ||
| } | ||
| }); | ||
|
|
||
| test('Sign In with Email/Password', async ({ page, mailpit, db }) => { | ||
| let authUser: { email: string; password: string } | undefined; | ||
|
|
||
| try { | ||
| // --- SETUP: Create a verified user --- | ||
| console.log('[Setup] Creating verified user...'); | ||
|
|
||
| // Use helper to create and verify user | ||
| // This helper also handles logging them in initially | ||
| authUser = await createVerifiedUser(page, mailpit, db, { | ||
| emailPrefix: 'auth-signin' | ||
| }); | ||
|
|
||
| console.log(`Starting Sign In test for: ${authUser.email}`); | ||
|
|
||
| // The helper leaves the user logged in. We need to log out to test explicit Sign In. | ||
| await page.context().clearCookies(); | ||
| await page.goto('/login'); | ||
|
|
||
| // --- TEST: Sign In --- | ||
| await page.fill('input[type="email"]', authUser.email); | ||
| // Re-type the password (helper returns it) | ||
| await page.fill('input[type="password"]', authUser.password); | ||
| await page.getByRole('button', { name: /login/i }).click(); | ||
|
|
||
| // Should now see Dashboard | ||
| await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 15000 }); | ||
|
|
||
| } finally { | ||
| if (authUser?.email) { | ||
| await db.cleanupUser(authUser.email); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { Pool } from 'pg'; | ||
| import { randomUUID } from 'node:crypto'; | ||
|
|
||
| export class DbFixture { | ||
| private pool: Pool; | ||
|
|
||
| constructor() { | ||
| // DB connection string from docker-compose or environment | ||
| // Assuming default local dev credentials | ||
| const connectionString = process.env.DATABASE_URL || 'postgres://user:password@localhost:5432/nexiom_local'; | ||
|
|
||
| this.pool = new Pool({ | ||
| connectionString, | ||
| }); | ||
| } | ||
|
|
||
| async close(): Promise<void> { | ||
| await this.pool.end(); | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a user's email is marked as verified in the database. | ||
| */ | ||
| async isEmailVerified(email: string): Promise<boolean> { | ||
| const query = ` | ||
| SELECT "emailVerified" | ||
| FROM "user" | ||
| WHERE email = $1 | ||
| `; | ||
| const result = await this.pool.query(query, [email]); | ||
|
|
||
| if (result.rows.length === 0) { | ||
| throw new Error(`[DB] User not found: ${email}`); | ||
| } | ||
|
|
||
| return result.rows[0].emailVerified; | ||
| } | ||
|
|
||
| /** | ||
| * Verify user exists | ||
| */ | ||
| async userExists(email: string): Promise<boolean> { | ||
| const query = 'SELECT 1 FROM "user" WHERE email = $1'; | ||
| const result = await this.pool.query(query, [email]); | ||
| return result.rowCount ? result.rowCount > 0 : false; | ||
| } | ||
|
|
||
| /** | ||
| * Promotes a user to System Owner (Super Admin) | ||
| */ | ||
| async makeSystemAdmin(email: string): Promise<void> { | ||
| // IDs from apps/api/src/constants.ts / admin-bootstrap.ts | ||
| const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'; | ||
| const OWNER_ROLE_ID = 'owner'; | ||
| console.log(`[DB] Promoting user to System Admin: ${email}`); | ||
| const client = await this.pool.connect(); | ||
| try { | ||
| await client.query('BEGIN'); | ||
| // 1. Get User ID | ||
| const userRes = await client.query('SELECT id FROM "user" WHERE email = $1', [email]); | ||
| if (userRes.rows.length === 0) throw new Error(`User not found: ${email}`); | ||
| const userId = userRes.rows[0].id; | ||
| // 2. Check if System Tenant exists (create if not) | ||
| await client.query(` | ||
| INSERT INTO "organization" (id, name, slug, status, "isSystem") | ||
| VALUES ($1, 'Nexiom Platform', 'system', 'active', true) | ||
| ON CONFLICT (id) DO NOTHING | ||
| `, [SYSTEM_TENANT_ID]); | ||
| // 3. Ensure Owner Role exists | ||
| await client.query(` | ||
| INSERT INTO "role" (id, name, "isSystem", description) | ||
| VALUES ($1, 'Owner', true, 'Full access') | ||
| ON CONFLICT (id) DO NOTHING | ||
| `, [OWNER_ROLE_ID]); | ||
| // 4. Enforce Single Tenant Policy: Remove existing memberships | ||
| // The app is strict single-tenant, and signup likely created a default tenant/member. | ||
| await client.query('DELETE FROM "member" WHERE "userId" = $1', [userId]); | ||
| // 5. Insert Member Record | ||
| const memberId = randomUUID(); | ||
| await client.query(` | ||
| INSERT INTO "member" (id, "userId", "organizationId", role) | ||
| VALUES ($1, $2, $3, $4) | ||
| `, [memberId, userId, SYSTEM_TENANT_ID, OWNER_ROLE_ID]); | ||
| await client.query('COMMIT'); | ||
| } catch (e) { | ||
| await client.query('ROLLBACK'); | ||
| throw e; | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Hard delete a user for cleanup | ||
| */ | ||
| async cleanupUser(email: string): Promise<void> { | ||
| console.log(`[DB] Cleaning up user: ${email}`); | ||
|
|
||
| const client = await this.pool.connect(); | ||
| try { | ||
| await client.query('BEGIN'); | ||
|
|
||
| // 1. Delete verification tokens (No FK cascade) | ||
| await client.query('DELETE FROM "verification" WHERE identifier = $1', [email]); | ||
|
|
||
| // 2. Delete user (Cascades to session, account, member, invitation) | ||
| await client.query('DELETE FROM "user" WHERE email = $1', [email]); | ||
|
|
||
| await client.query('COMMIT'); | ||
| } catch (error) { | ||
| await client.query('ROLLBACK'); | ||
| throw error; | ||
| } finally { | ||
| client.release(); | ||
| } | ||
|
|
||
| // Note: This leaves orphaned Organizations if the user was the sole member. | ||
| // For strict E2E, we might want to clean those too, but it requires more complex logic | ||
| // (checking member counts) which is skipped here for safety. | ||
| } | ||
| /** | ||
| * Delete an organization by slug for cleanup | ||
| */ | ||
| async cleanupOrganization(slug: string): Promise<void> { | ||
| console.log(`[DB] Cleaning up organization: ${slug}`); | ||
| const client = await this.pool.connect(); | ||
| try { | ||
| await client.query('BEGIN'); | ||
| // Delete the organization. Cascading rules usually handle members/invitations, | ||
| // but if strict FKs exist without cascade, we might need to delete members first. | ||
| // Assuming ON DELETE CASCADE is set up or we'll wrap in try/catch. | ||
| // For now, let's try direct delete. | ||
| await client.query('DELETE FROM "organization" WHERE slug = $1', [slug]); | ||
| await client.query('COMMIT'); | ||
| } catch (error) { | ||
| await client.query('ROLLBACK'); | ||
| throw error; | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heading hierarchy broken —
Low PriorityandCompleted Itemsdemoted to###.High Priority(line 7) andMedium Priority(line 70) use##, butLow PriorityandCompleted Itemswere changed to###. This makes them appear as subsections of "Medium Priority" rather than peer sections in the document outline.Proposed fix
🤖 Prompt for AI Agents