feat: implement new feature for enhanced user experience

This commit is contained in:
2026-02-11 16:36:56 +00:00
parent 62f1906276
commit dc00d24899
75 changed files with 2456 additions and 35 deletions

View File

@@ -0,0 +1,76 @@
import { APIRequestContext, request } from "@playwright/test"
import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
let adminContext: APIRequestContext | null = null
/**
* Get or create a singleton admin API context with the ADMIN_TOKEN.
*/
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
if (!adminContext) {
adminContext = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Token: ADMIN_TOKEN,
},
})
}
return adminContext
}
/**
* Delete a user by ID via admin API.
*/
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
return res.ok()
}
/**
* Cleanup test users matching a pattern in their email.
*/
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.get(`${API_BASE}/user`)
if (!res.ok()) return 0
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
if (!Array.isArray(users)) return 0
let deleted = 0
for (const user of users) {
const email = user.email || ""
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
if (matches) {
const userId = user.id || user._id
if (userId) {
const ok = await deleteUser(baseURL, userId)
if (ok) deleted++
}
}
}
return deleted
}
/**
* Cleanup all test data (users and tokens matching @test.example.com).
*/
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
const testPattern = "@test.example.com"
const users = await cleanupTestUsers(baseURL, testPattern)
return { users }
}
/**
* Dispose the admin API context. Call in globalTeardown.
*/
export async function disposeAdminApi(): Promise<void> {
if (adminContext) {
await adminContext.dispose()
adminContext = null
}
}

View File

@@ -0,0 +1,162 @@
import { APIRequestContext, request } from "@playwright/test"
/**
* MailDev API helper for testing email flows.
*
* Configure via environment variables:
* - MAILDEV_URL: MailDev web UI URL (default: https://${PROJECT_NAME}-maildev.code.testversion.online)
* - MAILDEV_USER: Basic auth username (default: code)
* - MAILDEV_PASS: Basic auth password
*/
const MAILDEV_URL = process.env.MAILDEV_URL || "http://localhost:1080"
const MAILDEV_USER = process.env.MAILDEV_USER || "code"
const MAILDEV_PASS = process.env.MAILDEV_PASS || ""
export interface MailDevEmail {
id: string
subject: string
html: string
to: { address: string; name: string }[]
from: { address: string; name: string }[]
date: string
time: string
read: boolean
headers: Record<string, string>
}
let maildevContext: APIRequestContext | null = null
async function getContext(): Promise<APIRequestContext> {
if (!maildevContext) {
const opts: any = {
baseURL: MAILDEV_URL,
ignoreHTTPSErrors: true,
}
if (MAILDEV_PASS) {
opts.httpCredentials = {
username: MAILDEV_USER,
password: MAILDEV_PASS,
}
}
maildevContext = await request.newContext(opts)
}
return maildevContext
}
/**
* Get all emails from MailDev.
*/
export async function getAllEmails(): Promise<MailDevEmail[]> {
const ctx = await getContext()
const res = await ctx.get("/email")
if (!res.ok()) {
throw new Error(`MailDev API error: ${res.status()} ${res.statusText()}`)
}
return res.json()
}
/**
* Delete all emails in MailDev.
*/
export async function deleteAllEmails(): Promise<void> {
const ctx = await getContext()
await ctx.delete("/email/all")
}
/**
* Delete a specific email by ID.
*/
export async function deleteEmail(id: string): Promise<void> {
const ctx = await getContext()
await ctx.delete(`/email/${id}`)
}
/**
* Wait for an email to arrive for a specific recipient.
*/
export async function waitForEmail(
toEmail: string,
options: {
subject?: string | RegExp
timeout?: number
pollInterval?: number
} = {}
): Promise<MailDevEmail> {
const { subject, timeout = 5000, pollInterval = 250 } = options
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return match
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No email to ${toEmail} received within ${timeout}ms`)
}
/**
* Extract a 6-digit verification code from an email's HTML body.
*/
export function extractCode(email: MailDevEmail): string {
const match = email.html.match(/>(\d{6})<\//)
if (!match) {
throw new Error(`No 6-digit code found in email "${email.subject}"`)
}
return match[1]
}
/**
* Perform an action and capture the verification code from the resulting email.
* Filters out emails that existed before the action was triggered.
*/
export async function captureCode(
toEmail: string,
action: () => Promise<void>,
options: { subject?: string | RegExp; timeout?: number } = {}
): Promise<string> {
const existingEmails = await getAllEmails()
const existingIds = new Set(
existingEmails
.filter((e) => e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase()))
.map((e) => e.id)
)
await action()
const { timeout = 5000, subject } = options
const pollInterval = 250
const start = Date.now()
while (Date.now() - start < timeout) {
const emails = await getAllEmails()
const match = emails.find((e) => {
if (existingIds.has(e.id)) return false
const toMatch = e.to.some((t) => t.address.toLowerCase() === toEmail.toLowerCase())
if (!toMatch) return false
if (!subject) return true
if (typeof subject === "string") return e.subject.includes(subject)
return subject.test(e.subject)
})
if (match) return extractCode(match)
await new Promise((r) => setTimeout(r, pollInterval))
}
throw new Error(`Timeout: No new email to ${toEmail} received within ${timeout}ms`)
}
/**
* Dispose the MailDev API context. Call in globalTeardown.
*/
export async function disposeMailDev(): Promise<void> {
if (maildevContext) {
await maildevContext.dispose()
maildevContext = null
}
}

View File

@@ -0,0 +1,72 @@
import { APIRequestContext, request } from "@playwright/test"
import { TEST_USER } from "../../fixtures/test-constants"
const API_BASE = "/api"
export interface TestUserCredentials {
email: string
password: string
accessToken: string
firstName: string
lastName: string
}
/**
* Login a user via the tibi action endpoint.
* Returns the access token or null on failure.
*/
export async function loginUser(baseURL: string, email: string, password: string): Promise<string | null> {
const ctx = await request.newContext({
baseURL,
ignoreHTTPSErrors: true,
})
try {
const res = await ctx.post(`${API_BASE}/action`, {
params: { cmd: "login" },
data: { email, password },
})
if (!res.ok()) return null
const body = await res.json()
return body.accessToken || null
} finally {
await ctx.dispose()
}
}
/**
* Ensure the test user exists and is logged in.
* Tries login first; if that fails, logs a warning (registration must be
* implemented per project or the user must be seeded via globalSetup).
*/
export async function ensureTestUser(baseURL: string): Promise<TestUserCredentials> {
const email = TEST_USER.email
const password = TEST_USER.password
const token = await loginUser(baseURL, email, password)
if (token) {
return {
email,
password,
accessToken: token,
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}
// If login fails, the test user doesn't exist yet.
// Implement project-specific registration here, or seed via globalSetup.
console.warn(
`⚠️ Test user ${email} could not be logged in. ` +
`Either implement registration in test-user.ts or seed the user via globalSetup.`
)
// Return a placeholder tests requiring auth will fail gracefully
return {
email,
password,
accessToken: "",
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
}
}