test fixes

This commit is contained in:
Sylvain Zimmer
2026-04-07 14:29:39 +02:00
parent 5a34fc1a78
commit 5d4e929104
8 changed files with 223 additions and 174 deletions
+1
View File
@@ -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.)
@@ -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}")
)
+3 -136
View File
@@ -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}")
)
+1
View File
@@ -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"],
+18 -18
View File
@@ -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<ImapFlow> {
}
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"
);
@@ -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();
+8 -8
View File
@@ -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
+11 -8
View File
@@ -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<string> {
async function runNpmCommand(command: string, args: string[] = []): Promise<string> {
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<void> {
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<void> {
await runNpmCommand('db:bootstrap-clientbridge');
}
export const getStorageStatePath = (username: string): string => {
return path.join(STORAGE_STATE_PATH, `user-${username}.json`);
};