283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
|
|
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: 60_000 });
|
|||
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
|
|||
|
|
await page.waitForTimeout(3000);
|
|||
|
|
|
|||
|
|
const rs = page.getByText('请选择您的登录身份');
|
|||
|
|
if (await rs.isVisible().catch(() => false)) {
|
|||
|
|
// Select first role (TESTER) for data seeding
|
|||
|
|
const first = page.locator('.van-grid-item').first();
|
|||
|
|
if (await first.isVisible().catch(() => false)) { await first.click(); await page.waitForTimeout(3000); }
|
|||
|
|
}
|
|||
|
|
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: 60_000 });
|
|||
|
|
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
|
|||
|
|
await page.waitForTimeout(3000);
|
|||
|
|
|
|||
|
|
const rs = page.getByText('请选择您的登录身份');
|
|||
|
|
if (await rs.isVisible().catch(() => false)) {
|
|||
|
|
// 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();
|
|||
|
|
await page.waitForTimeout(3000);
|
|||
|
|
} else {
|
|||
|
|
await items.first().click();
|
|||
|
|
await page.waitForTimeout(3000);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
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);
|
|||
|
|
|
|||
|
|
await expect(page.locator('.fc')).toBeVisible({ timeout: 15_000 });
|
|||
|
|
await expect(page.locator('.fc-createClazz-button')).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);
|
|||
|
|
|
|||
|
|
// Click the create button
|
|||
|
|
await page.locator('.fc-createClazz-button').click();
|
|||
|
|
await page.waitForTimeout(1500);
|
|||
|
|
|
|||
|
|
// Should show the editing form with "新增排课" title
|
|||
|
|
await expect(page.locator('h1')).toContainText('新增排课', { 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');
|
|||
|
|
expect(await events.count()).toBeGreaterThanOrEqual(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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 ev = page.locator('.fc-event').first();
|
|||
|
|
if (!(await ev.isVisible().catch(() => false))) test.skip(true, '日历无事件');
|
|||
|
|
await ev.click();
|
|||
|
|
await page.waitForTimeout(2000);
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
});
|
|||
|
|
});
|