Files
huike-e2e-moicen/tests/clazz-scheduling.spec.ts
T

328 lines
14 KiB
TypeScript
Raw Normal View History

import { expect, test } from './fixtures';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
const USER_ANAN = '9feb6ed0-be5f-408d-ad6d-a16b4a11f910';
const USER_ZANGYIMAN = 'abcf5b00-d301-4ec3-92c8-3f85274e520d';
const USER_MUYICHEN = 'd59fab6e-7378-4af7-b5a4-e949f06804e5';
const USER_LIYONG = 'ba2f6235-2d7a-4835-87a7-2f1b8437f44b';
const ORG_ID = '57753e11dff343b1ab95623933e8960b';
test.skip(!moicenUnionid, 'MOICEN_E2E_UNIONID 未设置');
/** Login and get auth + sudoer tokens */
async function loginAndGetTokens(page: any) {
const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' });
await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
// Wait up to 15s for role selection dialog (may not appear if already logged in)
const rs = page.getByText('请选择您的登录身份');
try {
await rs.waitFor({ state: 'visible', timeout: 15_000 });
// Select first role (TESTER) for data seeding
const first = page.locator('.van-grid-item').first();
await first.click();
await page.waitForTimeout(3000);
} catch {
// No role dialog — proceed with default role
}
return await getAuthTokens(page);
}
/** Login as TEACHER role specifically */
async function loginAsTeacher(page: any) {
const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' });
await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
// Wait up to 15s for role selection dialog (may not appear if already logged in)
const rs = page.getByText('请选择您的登录身份');
try {
await rs.waitFor({ state: 'visible', timeout: 15_000 });
// Select TEACHER role (index 2 in the grid: 测试员, 学生, 教师, 管理员, 主管教师)
const items = page.locator('.van-grid-item');
const count = await items.count();
if (count >= 3) {
await items.nth(2).click();
} else {
await items.first().click();
}
await page.waitForTimeout(3000);
} catch {
// No role dialog — proceed with default role
}
return await getAuthTokens(page);
}
async function getAuthTokens(page: any) {
const auth = await page.evaluate(() => localStorage.getItem('Authorization') || '');
const sudoer = await page.evaluate(() => localStorage.getItem('HtySudoerToken') || '');
return { auth, sudoer };
}
/** Make authenticated API call directly to admin.moicen.com */
async function api(page: any, tokens: { auth: string; sudoer: string }, method: string, path: string, body?: any) {
const opts: any = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': tokens.auth,
'HtySudoerToken': tokens.sudoer,
'HtyHost': 'admin.moicen.com',
},
};
if (body) opts.body = JSON.stringify(body);
const result = await page.evaluate(async ({ url, opts }) => {
const r = await fetch(url, opts);
const text = await r.text();
try { return { status: r.status, body: JSON.parse(text) }; }
catch { return { status: r.status, body: text.substring(0, 200) }; }
}, { url: `https://admin.moicen.com${path}`, opts });
return result;
}
function fmtDate(d: Date) { return d.toISOString().replace('Z', ''); }
const studentGroup = (userId: string, userName: string) => ({
val: { users: { vals: [{ user_id: userId, real_name: userName }] } },
});
test.describe('排课系统 E2E(阿难账号)', () => {
let tokens: { auth: string; sudoer: string } = { auth: '', sudoer: '' };
const createdIds: string[] = [];
let pageForCleanup: any = null;
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
pageForCleanup = page;
try {
tokens = await loginAndGetTokens(page);
if (!tokens.auth) return;
console.log('Got auth tokens, seeding data...');
const today = new Date();
today.setSeconds(0, 0);
// 1. Future: 阿难 as teacher, 臧艺蔓 as student
const f1 = new Date(today); f1.setDate(f1.getDate() + 3); f1.setHours(10, 0);
const f1e = new Date(f1); f1e.setHours(10, 45);
const r1 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '钢琴1对1-蔓艺(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(f1), end_by: fmtDate(f1e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ZANGYIMAN, '臧艺蔓'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
console.log('r1:', r1.status, JSON.stringify(r1.body).substring(0, 120));
if (r1.body?.r && r1.body?.d?.id) createdIds.push(r1.body.d.id);
// 2. Today: 阿难 as teacher, 木逸辰 as student
const t2 = new Date(today); t2.setHours(14, 0);
const t2e = new Date(t2); t2e.setHours(14, 45);
const r2 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '声乐课-逸辰(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t2), end_by: fmtDate(t2e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_MUYICHEN, '木逸辰'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r2.body?.r && r2.body?.d?.id) createdIds.push(r2.body.d.id);
// 3. Yesterday: 李勇, with attendance
const y3 = new Date(today); y3.setDate(y3.getDate() - 1); y3.setHours(9, 0);
const y3e = new Date(y3); y3e.setHours(9, 45);
const r3 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '乐理-李勇(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(y3), end_by: fmtDate(y3e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_LIYONG, '李勇'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r3.body?.r && r3.body?.d?.id) {
createdIds.push(r3.body.d.id);
await api(page, tokens, 'POST', '/api/v1/clazz/batch_save_attendance', {
clazz_id: r3.body.d.id,
items: [{ student_id: USER_LIYONG, status: 'PRESENT', deducted_hours: 1.0 }],
});
}
// 4. Today: 阿难 self clazz + leave request
const t4 = new Date(today); t4.setHours(16, 0);
const t4e = new Date(t4); t4e.setHours(16, 45);
const r4 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '钢琴-阿难自测(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t4), end_by: fmtDate(t4e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ANAN, '阿难'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r4.body?.r && r4.body?.d?.id) {
createdIds.push(r4.body.d.id);
await api(page, tokens, 'POST', '/api/v1/clazz/leave/create', {
clazz_id: r4.body.d.id, student_id: USER_ANAN, teacher_id: USER_ANAN,
leave_type: 'PERSONAL', reason: '身体不适(e2e测试)', org_id: ORG_ID, request_status: 'PENDING',
});
}
// 5. Today: another clazz for student stats
const t5 = new Date(today); t5.setHours(11, 0);
const t5e = new Date(t5); t5e.setHours(11, 45);
const r5 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '练声-阿难(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t5), end_by: fmtDate(t5e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ANAN, '阿难'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r5.body?.r && r5.body?.d?.id) createdIds.push(r5.body.d.id);
console.log(`Created ${createdIds.length} records`);
} finally {
// Don't close page — will use for cleanup
}
});
test.afterAll(async () => {
if (createdIds.length === 0 || !tokens.auth) return;
for (const id of createdIds) {
await api(pageForCleanup, tokens, 'POST', '/api/v1/clazz/update', {
id, is_delete: true, clazz_name: 'dummy',
}).catch(() => {});
}
console.log(`Cleaned up ${createdIds.length} records`);
if (pageForCleanup) await pageForCleanup.close();
});
// ═══ Teacher-side UI tests ═══
test('教师端日历页加载并显示可点击的时间网格', async ({ page }) => {
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// FullCalendar renders for teacher with custom toolbar (headerToolbar: false)
await expect(page.locator('.fc')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.fc-timegrid')).toBeVisible({ timeout: 15_000 });
// Time slot body is the clickable area for creating classes via dateClick/select
await expect(page.locator('.fc-timegrid-body')).toBeVisible({ timeout: 10_000 });
});
test('教师端排课页视图切换按钮可用(日历/矩阵)', async ({ page }) => {
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// Verify view toggle buttons are visible and functional
const calBtn = page.locator('.view-toolbar button', { hasText: '日历' });
const matrixBtn = page.locator('.view-toolbar button', { hasText: '矩阵' });
await expect(calBtn).toBeVisible({ timeout: 10_000 });
await expect(matrixBtn).toBeVisible({ timeout: 10_000 });
// Switch to matrix view
await matrixBtn.click();
await page.waitForTimeout(1500);
await expect(page.locator('.matrix-container')).toBeVisible({ timeout: 10_000 });
// Switch back to calendar view
await calBtn.click();
await page.waitForTimeout(1500);
await expect(page.locator('.fc')).toBeVisible({ timeout: 10_000 });
});
test('教师端日历显示今日排课事件', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
const events = page.locator('.fc-event');
await expect(events.first()).toBeVisible({ timeout: 15_000 });
});
test('教师端排课详情有操作记录和点名按钮', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// Wait for FullCalendar to render
await expect(page.locator('.fc')).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2000);
// Debug: check event state
const eventCount = await page.locator('.fc-event').count();
console.log(`Found ${eventCount} fc-event elements`);
if (eventCount === 0) {
// Try scrolling the fc-scroller to make events visible
await page.evaluate(() => {
const scroller = document.querySelector('.fc-scroller');
if (scroller) {
scroller.scrollTop = 400;
scroller.dispatchEvent(new Event('scroll'));
}
});
await page.waitForTimeout(2000);
}
const ev = page.locator('.fc-event').first();
if (!(await ev.isVisible({ timeout: 5000 }).catch(() => false))) {
const total = await page.locator('.fc-event').count();
console.log(`After scroll: ${total} fc-event elements`);
test.skip(true, '日历无事件');
return;
}
// Trigger click via JS to bypass fc-timegrid-event-harness interception
await ev.evaluate((el) => el.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await page.waitForTimeout(3000);
await expect(page.locator('text=操作记录').first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('button').filter({ hasText: '点名' }).first()).toBeVisible({ timeout: 10_000 });
});
// ═══ Student-side ═══
test('学生端课时统计页面可访问', async ({ page }) => {
await loginAndGetTokens(page);
await page.goto('/student/lessons', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
await expect(page.getByText('页面不存在')).not.toBeVisible({ timeout: 5000 });
});
// ═══ API data correctness ═══
test('API审计日志可查询', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
const t = await loginAndGetTokens(page);
if (!t.auth) test.skip(true, '未登录');
// Query via SPA proxy (uses axios interceptor)
const r = await page.evaluate(async (clazzId) => {
const resp = await fetch(`/api/v1/clazz/audit-log/list?clazz_id=${clazzId}`, {
headers: { 'Content-Type': 'application/json' },
});
return resp.ok ? (await resp.json()) : { r: false, status: resp.status };
}, createdIds[0]);
console.log('Audit log result:', JSON.stringify(r).substring(0, 200));
expect(r.r).not.toBe(undefined);
});
test('API请假记录可查询', async ({ page }) => {
const t = await loginAndGetTokens(page);
if (!t.auth) test.skip(true, '未登录');
// Query via SPA proxy
const r = await page.evaluate(async (studentId) => {
const resp = await fetch(`/api/v1/clazz/leave/list?student_id=${studentId}`, {
headers: { 'Content-Type': 'application/json' },
});
return resp.ok ? (await resp.json()) : { r: false, status: resp.status };
}, USER_ANAN);
console.log('Leave list result:', JSON.stringify(r).substring(0, 200));
expect(r.r).not.toBe(undefined);
});
});