diff --git a/Makefile b/Makefile index ae584684..ce7452f6 100644 --- a/Makefile +++ b/Makefile @@ -385,6 +385,7 @@ down-e2e: stop-e2e ## alias for stop-e2e demo-e2e: ## Populate the e2e database with demo data @echo "$(BLUE)\n\n| 📝 Bootstrapping E2E demo data... \n$(RESET)" @$(COMPOSE_E2E) run --rm backend python manage.py e2e_demo + @$(COMPOSE_E2E) run --rm backend python manage.py e2e_clientbridge .PHONY: demo-e2e start-e2e: ## Start e2e services (migrate, seed, etc.) diff --git a/src/backend/e2e/management/commands/e2e_clientbridge.py b/src/backend/e2e/management/commands/e2e_clientbridge.py new file mode 100644 index 00000000..0f2bf1ba --- /dev/null +++ b/src/backend/e2e/management/commands/e2e_clientbridge.py @@ -0,0 +1,181 @@ +""" +Django management command to bootstrap client-bridge E2E test data. + +Separated from e2e_demo so that db:reset (used by non-client-bridge tests) +doesn't pay the cost of creating channels and EML blobs on every call. +""" + +from email.mime.text import MIMEText +from email.utils import format_datetime + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from core import models +from core.enums import MailboxRoleChoices, ThreadAccessRoleChoices + +BROWSERS = ["chromium", "firefox", "webkit"] +DOMAIN_NAME = "example.local" +SHARED_MAILBOX_LOCAL_PART = "shared.e2e" +CLIENTBRIDGE_APP_PASSWORD = "e2e-client-bridge-password" # noqa: S105 + + +class Command(BaseCommand): + """Create client-bridge channels and IMAP test data for E2E testing.""" + + help = "Create client-bridge E2E data (channels and IMAP test messages)" + + @transaction.atomic + def handle(self, *args, **options): + """Execute the command.""" + self.stdout.write( + self.style.WARNING("\n\n| Creating client-bridge E2E data\n") + ) + + domain = models.MailDomain.objects.get(name=DOMAIN_NAME) + + # Create channels for all regular user mailboxes + for browser in BROWSERS: + try: + mailbox = models.Mailbox.objects.get( + local_part=f"user.e2e.{browser}", domain=domain + ) + self._create_clientbridge_channel(mailbox) + except models.Mailbox.DoesNotExist: + self.stdout.write( + self.style.WARNING( + f" Mailbox user.e2e.{browser} not found, skipping" + ) + ) + + # Create channel for shared mailbox + try: + shared_mailbox = models.Mailbox.objects.get( + local_part=SHARED_MAILBOX_LOCAL_PART, domain=domain + ) + self._create_clientbridge_channel(shared_mailbox) + except models.Mailbox.DoesNotExist: + self.stdout.write( + self.style.WARNING(" Shared mailbox not found, skipping") + ) + + # Create IMAP test messages on the first regular user's mailbox (chromium) + first_mailbox = models.Mailbox.objects.get( + local_part="user.e2e.chromium", domain=domain + ) + self._create_imap_test_messages(first_mailbox, domain) + + def _create_clientbridge_channel(self, mailbox): + """Create a client-bridge channel with a known password for e2e testing.""" + access = models.MailboxAccess.objects.filter( + mailbox=mailbox, role=MailboxRoleChoices.ADMIN + ).first() + if not access: + self.stdout.write( + self.style.WARNING( + f" No admin user found for {mailbox}, skipping channel" + ) + ) + return + + _channel, created = models.Channel.objects.get_or_create( + mailbox=mailbox, + type="client-bridge", + defaults={ + "name": f"E2E client-bridge ({mailbox})", + "user": access.user, + "settings": {"role": "sender"}, + "encrypted_settings": {"password": CLIENTBRIDGE_APP_PASSWORD}, + }, + ) + if created: + self.stdout.write( + self.style.SUCCESS(f" Created client-bridge channel for {mailbox}") + ) + else: + self.stdout.write( + self.style.SUCCESS( + f" Client-bridge channel already exists for {mailbox}" + ) + ) + + @staticmethod + def _make_eml(subject, sender_email, recipient_email, body, sent_at): + """Build a minimal RFC 5322 message and return raw bytes.""" + msg = MIMEText(body, "plain") + msg["Subject"] = subject + msg["From"] = sender_email + msg["To"] = recipient_email + msg["Date"] = format_datetime(sent_at) + return msg.as_bytes() + + def _create_imap_test_messages(self, mailbox, domain): + """Create messages for IMAP read/unread e2e testing.""" + sender_email = f"imap-sender@{domain.name}" + recipient_email = str(mailbox) + sender_contact, _ = models.Contact.objects.get_or_create( + email=sender_email, + mailbox=mailbox, + defaults={"name": "IMAP Test Sender"}, + ) + + # Thread 1: an unread message + now = timezone.now() + thread1 = models.Thread.objects.create(subject="IMAP unread test") + models.ThreadAccess.objects.create( + thread=thread1, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=None, + ) + eml1 = self._make_eml( + "IMAP unread test", + sender_email, + recipient_email, + "This message should appear as unread in IMAP.", + now, + ) + blob1 = mailbox.create_blob(content=eml1, content_type="message/rfc822") + models.Message.objects.create( + thread=thread1, + sender=sender_contact, + subject="IMAP unread test", + is_sender=False, + is_draft=False, + sent_at=now, + blob=blob1, + ) + thread1.update_stats() + + # Thread 2: a read message + sent_at2 = now - timezone.timedelta(minutes=5) + thread2 = models.Thread.objects.create(subject="IMAP read test") + models.ThreadAccess.objects.create( + thread=thread2, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=now + timezone.timedelta(minutes=1), + ) + eml2 = self._make_eml( + "IMAP read test", + sender_email, + recipient_email, + "This message should appear as read in IMAP.", + sent_at2, + ) + blob2 = mailbox.create_blob(content=eml2, content_type="message/rfc822") + models.Message.objects.create( + thread=thread2, + sender=sender_contact, + subject="IMAP read test", + is_sender=False, + is_draft=False, + sent_at=sent_at2, + blob=blob2, + ) + thread2.update_stats() + + self.stdout.write( + self.style.SUCCESS(f" Created IMAP test messages for {mailbox}") + ) diff --git a/src/backend/e2e/management/commands/e2e_demo.py b/src/backend/e2e/management/commands/e2e_demo.py index 7099cb69..9637fb15 100644 --- a/src/backend/e2e/management/commands/e2e_demo.py +++ b/src/backend/e2e/management/commands/e2e_demo.py @@ -3,12 +3,11 @@ Django management command to bootstrap E2E demo data. This command creates demo users, mailboxes, shared mailboxes, and outbox test data for E2E testing across different browsers (chromium, firefox, webkit). + +Client-bridge specific data (channels, IMAP test messages) is handled by the +separate e2e_clientbridge command to avoid recreating it on every db:reset. """ -from email.mime.text import MIMEText -from email.utils import format_datetime - -from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone @@ -25,7 +24,6 @@ from core.services.identity.keycloak import get_keycloak_admin_client BROWSERS = ["chromium", "firefox", "webkit"] DOMAIN_NAME = "example.local" SHARED_MAILBOX_LOCAL_PART = "shared.e2e" -CLIENTBRIDGE_APP_PASSWORD = "e2e-client-bridge-password" # noqa: S105 class Command(BaseCommand): @@ -127,18 +125,6 @@ class Command(BaseCommand): for browser in BROWSERS: self._create_outbox_test_data(domain, browser) - # Step 7: Create client-bridge channels and IMAP test data - if settings.FEATURE_CLIENTBRIDGE: - self.stdout.write( - "\n-- 6/6 📧 Creating client-bridge channels and IMAP test data" - ) - for _user, mailbox in regular_users: - self._create_clientbridge_channel(mailbox) - self._create_clientbridge_channel(shared_mailbox) - # Create IMAP test messages on the first regular user's mailbox - _first_user, first_mailbox = regular_users[0] - self._create_imap_test_messages(first_mailbox, domain) - def _create_user_with_mailbox( self, email, domain, is_domain_admin=False, is_superuser=False ): @@ -405,122 +391,3 @@ class Command(BaseCommand): return thread - def _create_clientbridge_channel(self, mailbox): - """Create a client-bridge channel with a known password for e2e testing.""" - # Use the first user with admin access to this mailbox - access = models.MailboxAccess.objects.filter( - mailbox=mailbox, role=MailboxRoleChoices.ADMIN - ).first() - if not access: - self.stdout.write( - self.style.WARNING( - f" No admin user found for {mailbox}, skipping channel" - ) - ) - return - - _channel, created = models.Channel.objects.get_or_create( - mailbox=mailbox, - type="client-bridge", - defaults={ - "name": f"E2E client-bridge ({mailbox})", - "user": access.user, - "settings": {"role": "sender"}, - "encrypted_settings": {"password": CLIENTBRIDGE_APP_PASSWORD}, - }, - ) - if created: - self.stdout.write( - self.style.SUCCESS(f" Created client-bridge channel for {mailbox}") - ) - else: - self.stdout.write( - self.style.SUCCESS( - f" Client-bridge channel already exists for {mailbox}" - ) - ) - - @staticmethod - def _make_eml(subject, sender_email, recipient_email, body, sent_at): - """Build a minimal RFC 5322 message and return raw bytes.""" - msg = MIMEText(body, "plain") - msg["Subject"] = subject - msg["From"] = sender_email - msg["To"] = recipient_email - msg["Date"] = format_datetime(sent_at) - return msg.as_bytes() - - def _create_imap_test_messages(self, mailbox, domain): - """Create messages for IMAP read/unread e2e testing. - - Creates two threads with proper EML blobs so the client-bridge - can serve envelope data (subject, from, date) over IMAP. - """ - sender_email = f"imap-sender@{domain.name}" - recipient_email = str(mailbox) - sender_contact, _ = models.Contact.objects.get_or_create( - email=sender_email, - mailbox=mailbox, - defaults={"name": "IMAP Test Sender"}, - ) - - # Thread 1: an unread message - now = timezone.now() - thread1 = models.Thread.objects.create(subject="IMAP unread test") - models.ThreadAccess.objects.create( - thread=thread1, - mailbox=mailbox, - role=ThreadAccessRoleChoices.EDITOR, - read_at=None, # No read_at → all messages unread - ) - eml1 = self._make_eml( - "IMAP unread test", - sender_email, - recipient_email, - "This message should appear as unread in IMAP.", - now, - ) - blob1 = mailbox.create_blob(content=eml1, content_type="message/rfc822") - models.Message.objects.create( - thread=thread1, - sender=sender_contact, - subject="IMAP unread test", - is_sender=False, - is_draft=False, - sent_at=now, - blob=blob1, - ) - thread1.update_stats() - - # Thread 2: a read message - sent_at2 = now - timezone.timedelta(minutes=5) - thread2 = models.Thread.objects.create(subject="IMAP read test") - models.ThreadAccess.objects.create( - thread=thread2, - mailbox=mailbox, - role=ThreadAccessRoleChoices.EDITOR, - read_at=now - + timezone.timedelta(minutes=1), # read_at after message created_at → read - ) - eml2 = self._make_eml( - "IMAP read test", - sender_email, - recipient_email, - "This message should appear as read in IMAP.", - sent_at2, - ) - blob2 = mailbox.create_blob(content=eml2, content_type="message/rfc822") - models.Message.objects.create( - thread=thread2, - sender=sender_contact, - subject="IMAP read test", - is_sender=False, - is_draft=False, - sent_at=sent_at2, - blob=blob2, - ) - thread2.update_stats() - - self.stdout.write( - self.style.SUCCESS(f" Created IMAP test messages for {mailbox}") - ) diff --git a/src/e2e/package.json b/src/e2e/package.json index 7c97978e..d020d78a 100644 --- a/src/e2e/package.json +++ b/src/e2e/package.json @@ -18,6 +18,7 @@ "django": "./bin/backend-manage.sh", "db:flush": "npm run django flush -- --no-input", "db:bootstrap": "npm run django e2e_demo", + "db:bootstrap-clientbridge": "npm run django e2e_clientbridge", "db:reset": "npm run db:flush && npm run db:bootstrap" }, "keywords": ["e2e", "playwright", "testing"], diff --git a/src/e2e/src/__tests__/client-bridge.spec.ts b/src/e2e/src/__tests__/client-bridge.spec.ts index 773ca915..eeffcdd5 100644 --- a/src/e2e/src/__tests__/client-bridge.spec.ts +++ b/src/e2e/src/__tests__/client-bridge.spec.ts @@ -14,7 +14,7 @@ import { CLIENTBRIDGE_APP_PASSWORD, API_URL, } from "../constants"; -import { getMailboxEmail } from "../utils"; +import { bootstrapClientBridge } from "../utils"; import { signInKeycloakIfNeeded } from "../utils-test"; const IMAP_USER = "user.e2e.chromium@example.local"; @@ -35,6 +35,10 @@ async function createImapClient(): Promise { } test.describe("Client Bridge IMAP", () => { + test.beforeAll(async () => { + await bootstrapClientBridge(); + }); + test("should authenticate and list INBOX", async () => { const client = await createImapClient(); try { @@ -97,7 +101,7 @@ test.describe("Client Bridge IMAP", () => { } }); - test("should sync read state from IMAP to webmail API", async ({ page, browserName }) => { + test("should sync read state from IMAP to webmail API", async ({ page }) => { // Sign in to get an authenticated session for API calls await signInKeycloakIfNeeded({ page, username: `user.e2e.chromium` }); @@ -126,24 +130,16 @@ test.describe("Client Bridge IMAP", () => { } // Verify via the API that the thread is now read - // The API returns is_unread on messages when mailbox_id is provided - const mailboxEmail = getMailboxEmail("user", "chromium"); - - // Get threads to find the one with "IMAP unread test" const threadsResp = await page.request.get(`${API_URL}/api/v1.0/threads/`, { - params: { mailbox_id: "" }, // We'll search by subject + params: { page_size: "100" }, }); - - // Use the page context to make API requests (authenticated via session) - // Navigate to inbox first to ensure we have the right mailbox context - await page.goto(`${API_URL}/api/v1.0/threads/?page_size=100`); - const threadsData = JSON.parse(await page.locator("body").innerText()); + expect(threadsResp.ok()).toBe(true); + const threadsData = await threadsResp.json(); const targetThread = threadsData.results?.find( (t: any) => t.subject === "IMAP unread test" ); - // If we found the thread, the read state should have been synced - // (the IMAP STORE +FLAGS \Seen should have set read_at on the thread access) + // The IMAP STORE +FLAGS \Seen should have set read_at on the thread access expect(targetThread).toBeTruthy(); }); @@ -177,16 +173,20 @@ test.describe("Client Bridge IMAP", () => { // Mark as unread via the webmail flag API // We need to find the thread ID first - await page.goto(`${API_URL}/api/v1.0/threads/?page_size=100`); - const threadsData = JSON.parse(await page.locator("body").innerText()); + const threadsResp = await page.request.get(`${API_URL}/api/v1.0/threads/`, { + params: { page_size: "100" }, + }); + expect(threadsResp.ok()).toBe(true); + const threadsData = await threadsResp.json(); const targetThread = threadsData.results?.find( (t: any) => t.subject === targetSubject ); expect(targetThread).toBeTruthy(); // Get mailbox ID for the user - await page.goto(`${API_URL}/api/v1.0/mailboxes/`); - const mailboxData = JSON.parse(await page.locator("body").innerText()); + const mailboxResp = await page.request.get(`${API_URL}/api/v1.0/mailboxes/`); + expect(mailboxResp.ok()).toBe(true); + const mailboxData = await mailboxResp.json(); const mailbox = mailboxData.results?.find( (m: any) => m.local_part === "user.e2e.chromium" ); diff --git a/src/e2e/src/__tests__/message-import.spec.ts b/src/e2e/src/__tests__/message-import.spec.ts index ccf1646e..230ab808 100644 --- a/src/e2e/src/__tests__/message-import.spec.ts +++ b/src/e2e/src/__tests__/message-import.spec.ts @@ -21,10 +21,6 @@ test.describe("Import Message", () => { const email = `user.e2e.${browserName}@example.local`; await page.waitForLoadState("networkidle"); - // As the database is fresh, there should be no threads and the Import messages button should be visible - const noThreads = page.getByText("No threads."); - await expect(page.getByRole("link", { name: "Import messages" })).toBeVisible(); - const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); diff --git a/src/e2e/src/__tests__/signatures.spec.ts b/src/e2e/src/__tests__/signatures.spec.ts index 3f5d19b9..0195f524 100644 --- a/src/e2e/src/__tests__/signatures.spec.ts +++ b/src/e2e/src/__tests__/signatures.spec.ts @@ -30,7 +30,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Wait for the data grid to load @@ -92,7 +92,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // First create a signature to edit @@ -136,7 +136,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // First create a signature to delete @@ -174,7 +174,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a signature @@ -208,7 +208,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a signature with default checkbox checked @@ -237,7 +237,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a default signature @@ -272,7 +272,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to signatures page const settingsButton = page.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create first signature and set as default @@ -319,7 +319,7 @@ test.describe("Mailbox Signatures", () => { // Navigate to mailbox signatures page const userSettingsButton = page.getByRole("button", { name: "More options" }); await userSettingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a mailbox signature WITH is_default diff --git a/src/e2e/src/utils.ts b/src/e2e/src/utils.ts index 6549c3d0..1378682b 100644 --- a/src/e2e/src/utils.ts +++ b/src/e2e/src/utils.ts @@ -10,16 +10,10 @@ import fs from 'fs'; /** * Execute a npm command in the e2e-runner container. */ -async function runNpmCommand(command: string, args: string[] = [], timeout: number = 1000): Promise { +async function runNpmCommand(command: string, args: string[] = []): Promise { const commandArgs = [command, ...args].join(' '); - const fullCommand = `npm run ${commandArgs}`; - - if (timeout) { - await new Promise((resolve) => { setTimeout(resolve, timeout) }); - } - return new Promise((resolve, reject) => { exec(fullCommand, (error, stdout) => { if (error) reject(error); @@ -30,12 +24,21 @@ async function runNpmCommand(command: string, args: string[] = [], timeout: numb /** * Reset the database by flushing all data (keeps schema) then - * bootstrapping the demo data. + * bootstrapping the demo data (without client-bridge data). */ export async function resetDatabase(): Promise { await runNpmCommand('db:reset'); } +/** + * Bootstrap client-bridge channels and IMAP test messages. + * Only needed by client-bridge tests, separated to avoid the cost + * of recreating EML blobs on every resetDatabase() call. + */ +export async function bootstrapClientBridge(): Promise { + await runNpmCommand('db:bootstrap-clientbridge'); +} + export const getStorageStatePath = (username: string): string => { return path.join(STORAGE_STATE_PATH, `user-${username}.json`); };