Skip to content
Open
18 changes: 12 additions & 6 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions convex/tests/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
});
Expand Down
25 changes: 25 additions & 0 deletions convex/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
80 changes: 62 additions & 18 deletions src/tests/auth/profile.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand Down Expand Up @@ -137,47 +145,52 @@ 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();

await page.getByRole('button', { name: 'Event löschen' }).click();
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();
await expect(page.getByRole('listitem')).toContainText(
'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 });
});
});
});
Expand All @@ -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()) {
Expand All @@ -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) {
Expand All @@ -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.`);
}
Loading