Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
/**
|
|
* Shared browser-test helpers.
|
|
*
|
|
* Import this in every `*.integration.test.ts` file.
|
|
* Provides login, API calling, and page-navigation utilities
|
|
* that work inside the Vitest browser environment.
|
|
*/
|
|
import { expect } from 'vitest';
|
|
import { CREDENTIALS } from './fixtures';
|
|
import { BASE } from './setup';
|
|
|
|
// ── Types ───────────────────────────────────────────────────────────
|
|
|
|
interface ApiResponse<T = unknown> {
|
|
code: number;
|
|
data: T;
|
|
message?: string;
|
|
}
|
|
|
|
type Role = keyof typeof CREDENTIALS;
|
|
|
|
// ── Auth helpers ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Login as a specific role and store the token in localStorage.
|
|
* Returns the parsed response data.
|
|
*/
|
|
export async function loginAs(role: Role): Promise<{ token: string; user: Record<string, unknown> }> {
|
|
const creds = CREDENTIALS[role];
|
|
const res = await fetch(`${BASE}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(creds),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
|
|
expect(json.code).toBe(0);
|
|
localStorage.setItem('token', json.data.token);
|
|
localStorage.setItem('user', JSON.stringify(json.data.user));
|
|
return json.data;
|
|
}
|
|
|
|
/**
|
|
* Logout: clear localStorage.
|
|
*/
|
|
export function logout(): void {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
localStorage.removeItem('permissions');
|
|
localStorage.removeItem('currentCampusId');
|
|
}
|
|
|
|
// ── API helpers (authenticated) ─────────────────────────────────────
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
const token = localStorage.getItem('token');
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
};
|
|
}
|
|
|
|
export async function apiGet<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
|
const res = await fetch(`${BASE}${url}`, { headers: authHeaders() });
|
|
return (await res.json()) as ApiResponse<T>;
|
|
}
|
|
|
|
export async function apiPost<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
const res = await fetch(`${BASE}${url}`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return (await res.json()) as ApiResponse<T>;
|
|
}
|
|
|
|
export async function apiPut<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
const res = await fetch(`${BASE}${url}`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return (await res.json()) as ApiResponse<T>;
|
|
}
|
|
|
|
export async function apiDelete<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
|
const res = await fetch(`${BASE}${url}`, {
|
|
method: 'DELETE',
|
|
headers: authHeaders(),
|
|
});
|
|
return (await res.json()) as ApiResponse<T>;
|
|
}
|
|
|
|
// ── Page helpers ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Navigate to a page and wait for it to load.
|
|
*/
|
|
export async function goTo(path: string): Promise<void> {
|
|
document.location.href = `${BASE}${path}`;
|
|
// Wait for React to render
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
|
|
/**
|
|
* Assert the current page URL contains the given path.
|
|
*/
|
|
export async function assertOnPage(path: string): Promise<void> {
|
|
// Wait a tick for SPA routing
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
expect(window.location.pathname).toContain(path);
|
|
}
|
|
|
|
// ── Wait helpers ────────────────────────────────────────────────────
|
|
|
|
/** Poll until a condition is true or timeout. */
|
|
export async function waitFor(
|
|
condition: () => boolean | Promise<boolean>,
|
|
timeout = 5000,
|
|
interval = 200,
|
|
): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeout) {
|
|
if (await condition()) return;
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
}
|
|
throw new Error(`waitFor timed out after ${timeout}ms`);
|
|
}
|
|
|
|
// ── Assertion helpers ───────────────────────────────────────────────
|
|
|
|
/** Assert an API response is successful (code === 0). */
|
|
export function assertOk<T>(res: ApiResponse<T>, msg?: string): T {
|
|
expect(res.code, msg ?? 'API should return code 0').toBe(0);
|
|
return res.data;
|
|
}
|
|
|
|
/** Assert an API response is an error (code !== 0). */
|
|
export function assertError(res: ApiResponse, expectedCode?: number): void {
|
|
expect(res.code).not.toBe(0);
|
|
if (expectedCode !== undefined) {
|
|
expect(res.code).toBe(expectedCode);
|
|
}
|
|
}
|
|
|
|
/** Assert a 403 is returned (permission denied). */
|
|
export async function assertForbidden(promise: Promise<Response>): Promise<void> {
|
|
const res = await promise;
|
|
expect(res.status).toBe(403);
|
|
}
|
|
|
|
/** Assert a 401 is returned (unauthenticated). */
|
|
export async function assertUnauthenticated(promise: Promise<Response>): Promise<void> {
|
|
const res = await promise;
|
|
expect(res.status).toBe(401);
|
|
}
|
|
|
|
// ── Sensitive data helpers (PRD §3.3) ───────────────────────────────
|
|
|
|
/** Assert phone number is masked: 138****0001 */
|
|
export function assertPhoneMasked(displayed: string): void {
|
|
expect(displayed).toMatch(/^\d{3}\*{4}\d{4}$/);
|
|
}
|
|
|
|
/** Assert ID card is masked: 110101********1234 */
|
|
export function assertIdCardMasked(displayed: string): void {
|
|
expect(displayed).toMatch(/^\d{6}\*{8}\d{4}$/);
|
|
}
|