diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2ce79f9..4475e90 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -2,10 +2,10 @@ name: CI/CD Pipeline on: push: - branches: [main] + branches: [main, development] tags: ['v*'] pull_request: - branches: [main] + branches: [main, development] workflow_dispatch: env: @@ -93,8 +93,10 @@ jobs: export CONVEX_PREVIEW_NAME="pr-${{ github.event.pull_request.number }}" export REVIEW_SITE_URL="https://review-${{ github.event.pull_request.number }}.${{ secrets.DOMAIN_NAME }}" ansible-playbook -i ansible/inventory ansible/site.yml --tags "preview" -e "CONVEX_PREVIEW_NAME=$CONVEX_PREVIEW_NAME review_site_url=$REVIEW_SITE_URL" - # Assuming the playbook or a script sets this up and we can extract the URL - echo "convex_url=https://$CONVEX_PREVIEW_NAME.convex.site" >> $GITHUB_OUTPUT + # Extract the actual Convex URL from the generated environment file. + # The playbook uses 'npx convex deploy --preview-create' which provides the correct .cloud URL. + VITE_CONVEX_URL=$(grep CONVEX_PREVIEW_VITE_CONVEX_URL convex-preview.env | cut -d= -f2) + echo "convex_url=$VITE_CONVEX_URL" >> $GITHUB_OUTPUT e2e-test: needs: convex-preview @@ -112,7 +114,10 @@ jobs: env: VITE_CONVEX_URL: ${{ needs.convex-preview.outputs.convex_url }} CONVEX_URL: ${{ needs.convex-preview.outputs.convex_url }} + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PREVIEW_DEPLOY_KEY }} CONVEX_PREVIEW_DEPLOY_KEY: ${{ secrets.CONVEX_PREVIEW_DEPLOY_KEY }} + CONVEX_PREVIEW_NAME: pr-${{ github.event.pull_request.number }} + PLAYWRIGHT_USE_PREVIEW: 'true' run: npx playwright test --workers=4 - uses: actions/upload-artifact@v4 if: always() @@ -121,7 +126,8 @@ jobs: path: playwright-report/ build-image: - needs: [test] + needs: [test, convex-preview] + if: always() && needs.test.result == 'success' && (needs.convex-preview.result == 'success' || needs.convex-preview.result == 'skipped') runs-on: ubuntu-latest permissions: contents: read @@ -205,7 +211,7 @@ jobs: deploy-review: if: github.event_name == 'pull_request' - needs: [e2e-test, build-image] + needs: [e2e-test, build-image, convex-preview] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/convex/tests/events.test.ts b/convex/tests/events.test.ts index 2a085ec..63f93a2 100644 --- a/convex/tests/events.test.ts +++ b/convex/tests/events.test.ts @@ -432,10 +432,10 @@ describe('Convex Events Tests', () => { visibility: 'logged-in', }); - const unauthEvents = await t.query(api.events.get); + const unauthEvents = await t.query(api.events.get, {}); expect(unauthEvents.map((e) => e._id)).not.toContain(pendingEventId); - const authEvents = await t.withIdentity({ subject: viewerId }).query(api.events.get); + const authEvents = await t.withIdentity({ subject: viewerId }).query(api.events.get, {}); expect(authEvents.map((e) => e._id)).not.toContain(pendingEventId); expect(authEvents.map((e) => e._id)).toContain(privateEventId); }); @@ -662,18 +662,18 @@ describe('Convex Events Tests', () => { visibility: 'public', }); - const myEventsA = await t.withIdentity({ subject: userAId }).query(api.events.getMyEvents); + const myEventsA = await t.withIdentity({ subject: userAId }).query(api.events.getMyEvents, {}); expect(myEventsA).toHaveLength(1); expect(myEventsA[0].title).toBe('Event A'); - const myEventsB = await t.withIdentity({ subject: userBId }).query(api.events.getMyEvents); + const myEventsB = await t.withIdentity({ subject: userBId }).query(api.events.getMyEvents, {}); expect(myEventsB).toHaveLength(1); expect(myEventsB[0].title).toBe('Event B'); }); test('getMyEvents throws error if unauthenticated', async () => { const t = convexTest(schema); - await expect(t.query(api.events.getMyEvents)).rejects.toThrow('Not authenticated'); + await expect(t.query(api.events.getMyEvents, {})).rejects.toThrow('Not authenticated'); }); test('get returns only public events for unauthenticated users', async () => { @@ -716,7 +716,7 @@ describe('Convex Events Tests', () => { eventId: publicEventId, }); - const events = await t.query(api.events.get); + const events = await t.query(api.events.get, {}); expect(events).toHaveLength(1); expect(events[0].title).toBe('Public Event'); }); @@ -762,7 +762,7 @@ describe('Convex Events Tests', () => { visibility: 'logged-in', }); - const events = await t.withIdentity({ subject: viewerId }).query(api.events.get); + const events = await t.withIdentity({ subject: viewerId }).query(api.events.get, {}); expect(events).toHaveLength(2); expect(events.map((e) => e.title)).toContain('Public Event'); expect(events.map((e) => e.title)).toContain('Private Event'); @@ -1041,7 +1041,7 @@ describe('Convex Events Tests', () => { const myEvents = await t .withIdentity({ subject: userId }) - .query(api.events.getMyRegisteredEvents); + .query(api.events.getMyRegisteredEvents, {}); expect(myEvents).toHaveLength(1); expect(myEvents[0].title).toBe('Registered Event'); }); @@ -1109,7 +1109,7 @@ describe('Convex Events Tests', () => { // Verify removed const myEvents = await t .withIdentity({ subject: userId }) - .query(api.events.getMyRegisteredEvents); + .query(api.events.getMyRegisteredEvents, {}); expect(myEvents).toHaveLength(0); const remainingEventProducts = await t.run(async (ctx) => { @@ -1419,7 +1419,7 @@ describe('Convex Events Tests', () => { ctx.db.insert('eventRole', { event: fakeEventId, user: userId, roles: 'organizer' }) ); - const events = await t.withIdentity({ subject: userId }).query(api.events.getMyEvents); + const events = await t.withIdentity({ subject: userId }).query(api.events.getMyEvents, {}); expect(events).toHaveLength(1); expect(events[0]._id).toBe(eventId); }); diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 0000000..41bfbb9 --- /dev/null +++ b/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/src/tests/auth/profile.spec.ts b/src/tests/auth/profile.spec.ts index a3def78..9221723 100644 --- a/src/tests/auth/profile.spec.ts +++ b/src/tests/auth/profile.spec.ts @@ -1,6 +1,14 @@ import { test, expect, type Page } from '@playwright/test'; +import { runConvexCommand, runConvexCommandWithOutput } from '../utils'; test.describe('profile page', () => { + test.beforeAll('enable e2e mail bypass', async () => { + runConvexCommand( + 'run featureFlags:set', + `"{ 'key':'is_e2e_auth_skip_email', 'value': true, 'description': 'Skips SMTP delivery in E2E' }"` + ); + }); + test('updates profile and validates invalid inputs', async ({ page }, testInfo) => { const uniqueTag = testInfo.project.name.replace(/\s+/g, '-'); const firstName = `E2E-${uniqueTag}`; @@ -137,14 +145,12 @@ test.describe('profile page', () => { page.getByText( 'Account kann nicht gelöscht werden, da du Organisator einer Veranstaltung bist.' ) - ).toBeVisible(); + ).toBeVisible({ timeout: 10000 }); await page.goto('/my-events'); await page.getByRole('button', { name: 'Zur Veranstaltung' }).click(); - await page.waitForURL('**/events/view/*'); - // Navigate to Settings - currently we go directly or via card. - // Based on App.tsx, the path is /events/view/:eventId/settings + await page.waitForURL('**/events/view/*', { timeout: 10000 }); await page.goto(page.url() + '/settings'); await expect(page.getByRole('heading', { name: 'Event-Einstellungen' })).toBeVisible(); @@ -152,22 +158,23 @@ test.describe('profile page', () => { await page.getByRole('button', { name: 'Löschen' }).click(); await expect( page.getByRole('heading', { name: 'Keine Veranstaltungen gefunden' }) - ).toBeVisible(); + ).toBeVisible({ timeout: 10000 }); }); test('deletes account and cannot login afterwards', async ({ page }) => { - await login(page, 'user4@bazarpro.de', '123456'); + const email = 'user4@bazarpro.de'; + await login(page, email, '123456'); await page.goto('/account'); await page.getByRole('button', { name: 'Konto löschen' }).click(); await page.getByRole('button', { name: 'Ja, Konto löschen' }).click(); await expect(page.getByText('Konto erfolgreich gelöscht.')).toBeVisible(); await page.goto('/login'); - await page.getByRole('textbox', { name: 'E-Mail-Adresse' }).fill('user4@bazarpro.de'); + await page.getByRole('textbox', { name: 'E-Mail-Adresse' }).fill(email); await page.getByRole('textbox', { name: 'Passwort' }).fill('123456'); await page.getByTestId('button-login-submit').click(); await expect(page.getByRole('alert')).toContainText( - 'Dein Account wurde gelöscht. Du kannst deinen Account durch den Button unten reaktivieren. Nach Reaktivierung musst du deine Email-Adresse erneut verifizieren.' + 'Dein Account wurde gelöscht. Du kannst deinen Account durch den Button unten reaktivieren.' ); await expect(page.getByTestId('button-login-submit')).toContainText('Account reaktivieren'); await page.getByTestId('button-login-submit').click(); @@ -175,9 +182,15 @@ test.describe('profile page', () => { 'Account erfolgreich reaktiviert. Du kannst dich jetzt anmelden.' ); await page.getByTestId('button-login-submit').click(); - await expect( - page.locator('div').filter({ hasText: 'Prüfe dein PostfachWir haben' }).nth(4) - ).toBeVisible(); + + // Now on verification screen - complete it to leave account clean + await expect(page.getByRole('heading', { name: 'Prüfe dein Postfach' })).toBeVisible({ + timeout: 10000, + }); + const code = await getVerificationCodeForEmail(email); + await page.getByLabel('Verifizierungscode').fill(code); + await page.getByRole('button', { name: 'E-Mail bestätigen' }).click(); + await page.waitForURL('**/browse-events', { timeout: 20000 }); }); }); }); @@ -188,18 +201,19 @@ async function login(page: Page, email: string, password: string) { await page.getByRole('textbox', { name: 'Passwort' }).fill(password); await page.getByTestId('button-login-submit').click(); - // Handle potential states: Success, Reactivation required, or Error const reactivationButton = page.getByRole('button', { name: 'Account reaktivieren' }); const errorAlert = page.getByTestId('alert-wrong-login'); + const verificationHeader = page.getByRole('heading', { name: 'Prüfe dein Postfach' }); try { await Promise.race([ - page.waitForURL('**/browse-events', { timeout: 10000 }), - reactivationButton.waitFor({ timeout: 10000 }), - errorAlert.waitFor({ timeout: 10000 }), + page.waitForURL('**/browse-events', { timeout: 15000 }), + reactivationButton.waitFor({ state: 'visible', timeout: 15000 }), + errorAlert.waitFor({ state: 'visible', timeout: 15000 }), + verificationHeader.waitFor({ state: 'visible', timeout: 15000 }), ]); } catch { - // Proceed to visibility checks + // Ignore timeout error from race } if (await errorAlert.isVisible()) { @@ -211,11 +225,21 @@ async function login(page: Page, email: string, password: string) { await reactivationButton.click(); await expect( page.getByText('Account erfolgreich reaktiviert. Du kannst dich jetzt anmelden.') - ).toBeVisible(); + ).toBeVisible({ timeout: 10000 }); await page.getByTestId('button-login-submit').click(); + // After clicking login again, we might hit the verification screen + await expect(page.getByRole('heading', { name: 'Prüfe dein Postfach' })).toBeVisible({ + timeout: 10000, + }); + } + + if (await verificationHeader.isVisible()) { + const code = await getVerificationCodeForEmail(email); + await page.getByLabel('Verifizierungscode').fill(code); + await page.getByRole('button', { name: 'E-Mail bestätigen' }).click(); } - await page.waitForURL('**/browse-events', { timeout: 15000 }); + await page.waitForURL('**/browse-events', { timeout: 20000 }); } async function createDefaultEvent(page: Page) { @@ -232,4 +256,24 @@ async function createDefaultEvent(page: Page) { .getByRole('textbox', { name: 'Kontaktdaten / Veranstalter-' }) .fill('Kontaktdaten Veranstalter 1'); await page.getByRole('button', { name: 'Veranstaltung erstellen' }).click(); + await page.waitForURL('**/my-events', { timeout: 15000 }); +} + +async function getVerificationCodeForEmail(email: string) { + for (let attempt = 1; attempt <= 10; attempt++) { + try { + const output = runConvexCommandWithOutput( + 'run users:getE2eVerificationCode', + `"{ \\"email\\": \\"${email}\\" }"` + ); + const token = output.replace(/^"|"$/g, '').trim(); + if (token && token !== 'null') { + return token; + } + } catch { + // Wait for async persistence. + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Konnte keinen E2E-Verifizierungscode für ${email} abrufen.`); }