test(P22PROD1-5): 差旅支付与对账E2E补齐及稳态修复
这个提交包含在:
@@ -0,0 +1,150 @@
|
||||
import { expect, request as playwrightRequest, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000";
|
||||
|
||||
export type E2EUser = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const EMPLOYEE: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "EmployeePassword1234",
|
||||
name: "Employee",
|
||||
};
|
||||
|
||||
export const MANAGER: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "ManagerPassword1234",
|
||||
name: "Manager",
|
||||
};
|
||||
|
||||
export const FINANCE: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "FinancePassword1234",
|
||||
name: "Finance",
|
||||
};
|
||||
|
||||
export const FINANCE_MGR: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "FinanceMgrPassword1234",
|
||||
name: "Finance Manager",
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roleCodes: string[];
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type AuthenticatedApiClient = {
|
||||
api: APIRequestContext;
|
||||
accessToken: string;
|
||||
me: MeResponse;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
type Envelope<T> = {
|
||||
data: T;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function buildDeviceFingerprint(user: E2EUser) {
|
||||
return `pw-e2e-${user.identifier.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: { ok(): boolean; status(): number; text(): Promise<string> }) {
|
||||
const rawText = await response.text();
|
||||
const parsed = rawText ? (JSON.parse(rawText) as Envelope<T>) : ({ data: null } as Envelope<T>);
|
||||
if (!response.ok()) {
|
||||
const errorCode = parsed.error?.code ?? `HTTP_${response.status()}`;
|
||||
const errorMessage = parsed.error?.message ?? rawText;
|
||||
throw new Error(`${errorCode}: ${errorMessage}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function login(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
}
|
||||
|
||||
export async function logout(page: Page, userDisplayName: string) {
|
||||
try {
|
||||
await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true });
|
||||
await expect(page.getByRole("menuitem", { name: "退出登录" })).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole("menuitem", { name: "退出登录" }).click();
|
||||
} catch {
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
await page.goto("/login");
|
||||
}
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
}
|
||||
|
||||
export async function createAuthenticatedApiClient(user: E2EUser, baseURL = E2E_BASE_URL): Promise<AuthenticatedApiClient> {
|
||||
const loginContext = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: {
|
||||
"x-device-fingerprint": buildDeviceFingerprint(user),
|
||||
},
|
||||
});
|
||||
|
||||
const loginResponse = await loginContext.post("/api/auth/login", {
|
||||
data: {
|
||||
identifier: user.identifier,
|
||||
password: user.password,
|
||||
},
|
||||
});
|
||||
|
||||
const loginData = await readJson<{ accessToken: string }>(loginResponse);
|
||||
const accessToken = loginData.accessToken;
|
||||
|
||||
const api = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const meResponse = await api.get("/api/me");
|
||||
const me = await readJson<MeResponse>(meResponse);
|
||||
|
||||
return {
|
||||
api,
|
||||
accessToken,
|
||||
me,
|
||||
dispose: async () => {
|
||||
await Promise.all([api.dispose(), loginContext.dispose()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiGetData<T>(api: APIRequestContext, path: string) {
|
||||
const response = await api.get(path);
|
||||
return readJson<T>(response);
|
||||
}
|
||||
|
||||
export async function apiPostData<T>(api: APIRequestContext, path: string, data: unknown) {
|
||||
const response = await api.post(path, { data });
|
||||
return readJson<T>(response);
|
||||
}
|
||||
|
||||
export async function apiPatchData<T>(api: APIRequestContext, path: string, data: unknown) {
|
||||
const response = await api.patch(path, { data });
|
||||
return readJson<T>(response);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户