diff --git a/tests/clazz-scheduling.spec.ts b/tests/clazz-scheduling.spec.ts new file mode 100644 index 0000000..08704e2 --- /dev/null +++ b/tests/clazz-scheduling.spec.ts @@ -0,0 +1,282 @@ +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); + }); +}); diff --git a/tests/debug-anan-info.spec.ts b/tests/debug-anan-info.spec.ts new file mode 100644 index 0000000..7562e2d --- /dev/null +++ b/tests/debug-anan-info.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from './fixtures'; +const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); +test.skip(!moicenUnionid, 'MOICEN_E2E_UNIONID 未设置'); + +function decodeJwt(token: string): any { + const raw = token.replace('Bearer ', '').trim(); + const parts = raw.split('.'); + if (parts.length !== 3) return null; + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); +} + +test('提取阿难全部localStorage信息', async ({ page }) => { + 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)) { + // Try to select TEACHER role (may be 2nd or 3rd grid item) + const items = page.locator('.van-grid-item'); + const count = await items.count(); + for (let i = 0; i < count; i++) { + const text = await items.nth(i).textContent(); + console.log(`Role item ${i}: ${text?.trim()}`); + } + await items.first().click(); + await page.waitForTimeout(3000); + } + + const ls = await page.evaluate(() => { + const data: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i)!; + const val = localStorage.getItem(key)!; + if (val.length > 200) { + data[key] = val.substring(0, 200) + '...'; + } else { + data[key] = val; + } + } + return data; + }); + + for (const [k, v] of Object.entries(ls)) { + console.log(`${k}: ${v}`); + } + + // Try a real API call from the SPA's context + const auth = ls['Authorization'] || ''; + const sudoerToken = ls['HtySudoerToken'] || ''; + + // Test API call via page fetch (uses frontend interceptor) + const result = await page.evaluate(async () => { + const resp = await fetch('/api/v1/clazz/find_all_non_repeatable_within_date_range?start=2026-05-01&end=2026-05-03', { + headers: { + 'Content-Type': 'application/json', + }, + }); + return { status: resp.status, text: await resp.text().then(t => t.substring(0, 200)) }; + }); + console.log('API test via SPA:', JSON.stringify(result)); + + // Try direct call to admin.moicen.com + const result2 = await page.evaluate(async ({ auth, sudoerToken }) => { + const resp = await fetch('https://admin.moicen.com/api/v1/clazz/find_all_non_repeatable_within_date_range?start=2026-05-01&end=2026-05-03', { + headers: { + 'Authorization': auth, + 'HtySudoerToken': sudoerToken, + 'HtyHost': 'admin.moicen.com', + }, + }); + return { status: resp.status, text: await resp.text().then(t => t.substring(0, 500)) }; + }, { auth, sudoerToken }); + console.log('API test direct:', JSON.stringify(result2)); +}); diff --git a/tests/debug-env.spec.ts b/tests/debug-env.spec.ts new file mode 100644 index 0000000..386a202 --- /dev/null +++ b/tests/debug-env.spec.ts @@ -0,0 +1,6 @@ +import { test } from './fixtures'; + +test('check env', () => { + console.log('MOICEN_E2E_UNIONID from env:', process.env.MOICEN_E2E_UNIONID); + console.log('HUIKE_FRONT_BASE_URL:', process.env.HUIKE_FRONT_BASE_URL); +});