import { expect, test } from './fixtures'; const testOrgId = '57753e11dff343b1ab95623933e8960b'; const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); test.describe('课包商店', () => { test('未登录访问首页显示课包商店', async ({ page }) => { // Clear any stored auth await page.goto('/'); await page.evaluate(() => localStorage.clear()); // Reload without auth — store loads all public packages across all orgs await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // The page should not show the empty login message const loginHint = page.getByText('请返回微信小程序完成登录'); await expect(loginHint).not.toBeVisible(); // The course package store component should be present const store = page.locator('.course-package-store'); await expect(store).toBeVisible({ timeout: 15_000 }); }); test('课包卡片展示基本信息且不显示定价', async ({ page }) => { // Mock the public API to return test packages await page.route(/public-packages/, async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: [[ { id: 'pkg-001', package_name: '测试钢琴课包', description: '这是一门测试用的钢琴课程包,包含10节课时', total_lessons: 10, original_price: 200000, selling_price: 180000, validity_days: 180, package_status: 'ACTIVE', cover_image_url: null, sort_order: 1, }, { id: 'pkg-002', package_name: '声乐基础训练', description: '从零开始学声乐', total_lessons: 20, original_price: 300000, selling_price: 250000, validity_days: 365, package_status: 'ACTIVE', cover_image_url: null, sort_order: 2, }, ], 1, 2], e: null, }), }); }); await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.evaluate(() => localStorage.clear()); // Navigate with org_id in URL so the component calls the public API await page.goto(`/?org_id=${testOrgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // Package cards should be visible const packageCards = page.locator('.package-card'); await expect(packageCards).toHaveCount(2, { timeout: 15_000 }); // Verify first card shows title const firstTitle = packageCards.first().locator('.card-title'); await expect(firstTitle).toContainText('测试钢琴课包'); // Verify no price text ("¥" or "售价") is visible within cards const cards = await packageCards.all(); for (const card of cards) { const cardText = await card.innerText(); expect(cardText).not.toContain('¥'); expect(cardText).not.toContain('售价'); } // Verify lesson count IS shown await expect(packageCards.first().locator('.card-meta')).toContainText('10 课次'); }); test('试看按钮在未登录态弹出登录提示', async ({ page }) => { // Mock the public API to return test packages await page.route(/public-packages/, async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: [[ { id: 'pkg-001', package_name: '测试课包', description: '测试用课包', total_lessons: 10, validity_days: 180, package_status: 'ACTIVE', }, ], 1, 1], e: null, }), }); }); await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.evaluate(() => localStorage.clear()); await page.goto(`/?org_id=${testOrgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // Wait for package cards to load const previewBtn = page.locator('.preview-btn').first(); await expect(previewBtn).toBeVisible({ timeout: 15_000 }); await previewBtn.click(); await page.waitForTimeout(1000); // Dialog should appear with login prompt const dialog = page.locator('.van-dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); await expect(dialog).toContainText('微信登录'); // Close dialog const confirmBtn = page.locator('.van-dialog__confirm'); await confirmBtn.click(); }); test('课包商店显示加载中状态', async ({ page }) => { // Delay the API response to trigger loading skeleton await page.route(/public-packages/, async route => { await new Promise(resolve => setTimeout(resolve, 3000)); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: [[], 0, 0], e: null }), }); }); await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.evaluate(() => localStorage.clear()); await page.goto(`/?org_id=${testOrgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // Skeleton should be visible while loading (give it time to render) await expect(page.locator('.van-skeleton').first()).toBeVisible({ timeout: 10_000 }); }); test('课包商店API错误显示重试按钮', async ({ page }) => { // Mock the API to return an error await page.route(/public-packages/, async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: false, d: null, e: '服务器错误' }), }); }); await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.evaluate(() => localStorage.clear()); await page.goto(`/?org_id=${testOrgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // Error state should show retry button const retryBtn = page.locator('.course-package-store').getByText('重试'); await expect(retryBtn).toBeVisible({ timeout: 15_000 }); }); }); test.describe('课包商店(已登录学生查看老师主页)', () => { test('学生登录后 /teacher/home 显示课包商店', async ({ page }) => { test.setTimeout(180_000); if (!moicenUnionid) { test.skip(true, 'MOICEN_E2E_UNIONID 未设置'); return; } // Login as student const ok = await loginAsStudent(page, moicenUnionid); if (!ok) { test.skip(true, '会话不可用'); return; } // Navigate to teacher home await page.goto('/teacher/home', { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // Should NOT see the login prompt await expect(page.getByText('请返回微信小程序完成登录')).not.toBeVisible(); // Check if org is selected — course-package-section may not render without currentOrgId const section = page.locator('.course-package-section'); if (!(await section.isVisible().catch(() => false))) { test.skip(true, '学生未选择机构,课包商店区域不可见'); return; } await expect(section.locator('.section-header')).toContainText('课包商店'); // Should see at least one package card const cards = page.locator('.course-package-store .package-card'); await expect(cards.first()).toBeVisible({ timeout: 15_000 }); const cardCount = await cards.count(); expect(cardCount).toBeGreaterThan(0); // Each card should have package name and lesson count (no pricing) for (let i = 0; i < cardCount; i++) { const card = cards.nth(i); const text = await card.innerText(); expect(text.length).toBeGreaterThan(0); expect(text).not.toContain('¥'); expect(text).not.toContain('售价'); } // Should have a preview button on each card const previewBtns = page.locator('.course-package-store .preview-btn'); expect(await previewBtns.count()).toBe(cardCount); }); }); async function loginAsStudent(page: any, moicenUnionid: string) { 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 }); // Wait for SPA to settle and login/role resolution to complete await page.waitForTimeout(5000); // Pick STUDENT role explicitly const roleSelect = page.getByText('请选择您的登录身份'); if (await roleSelect.isVisible().catch(() => false)) { const studentRole = page.locator('.van-grid-item').filter({ hasText: '学生' }); if (await studentRole.isVisible().catch(() => false)) { await studentRole.click(); await page.waitForTimeout(5000); } } // Handle org select if needed if (page.url().includes('/org/select')) { const guest = page.getByText('请返回微信小程序完成登录'); if (await guest.isVisible().catch(() => false)) { return false; // 会话不可用 } const orgCells = page.locator('#app .van-cell-group .van-cell'); const n = await orgCells.count(); if (n === 0) return false; await orgCells.first().click(); await page.waitForTimeout(5000); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); if (await guest.isVisible().catch(() => false)) { return false; // org switch 后会话不可用 } await page.goto('/student/profile', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); } // Ensure we're on a student page const currentPath = new URL(page.url()).pathname; if (!currentPath.startsWith('/student/')) { await page.goto('/student/profile', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); } // Skip if session lost if (await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)) { return false; } return true; } test.describe('课包详情页权限与预览', () => { test.describe('权限、预览与状态(mock API)', () => { // FIXME: these 3 tests are flaky — loginAsStudent + page.route + page.goto // triggers route guard which may abort navigation (switchOrg API flakiness). // Marked fixme to unblock CI; fix root cause in route guard / JWT org context. test.beforeEach(({ }, {}) => { if (!moicenUnionid) { test.skip(true, 'MOICEN_E2E_UNIONID 未设置'); } }); test.fixme('非创建者查看课包详情不显示管理按钮', async ({ page }) => { test.setTimeout(180_000); if (!moicenUnionid) return; const ok = await loginAsStudent(page, moicenUnionid); if (!ok) { test.skip(true, '会话不可用'); return; } const testPkgId = 'pkg-e2e-perm'; await page.route(new RegExp(`/api/v1/clazz/course-package/${testPkgId}`), async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: { id: testPkgId, package_name: '课包-权限验证', description: '非创建者不应看到管理按钮', total_lessons: 10, package_status: 'ACTIVE', created_by: 'someone-else-id', published_at: '2026-01-01T00:00:00Z', published_snapshot: { course_groups: [ { id: 'group-1', group_name: '基础训练', course_sections: [ { id: 'sec-1', course_name: '钢琴', section_name: '第一课-基础指法' }, { id: 'sec-2', course_name: '钢琴', section_name: '第二课-音阶练习' }, ], }, ], }, }, e: null, }), }); }); await page.goto(`/course/course-package/detail?id=${testPkgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); await expect(page.locator('button').filter({ hasText: '编辑' })).not.toBeVisible(); await expect(page.locator('button').filter({ hasText: '删除' })).not.toBeVisible(); await expect(page.locator('button').filter({ hasText: '发布上架' })).not.toBeVisible(); await expect(page.locator('.title')).toContainText('课包-权限验证'); }); test.fixme('非创建者仅第一节课节可试看,其余已锁定', async ({ page }) => { test.setTimeout(180_000); if (!moicenUnionid) return; const ok = await loginAsStudent(page, moicenUnionid); if (!ok) { test.skip(true, '会话不可用'); return; } const testPkgId = 'pkg-e2e-preview'; await page.route(new RegExp(`/api/v1/clazz/course-package/${testPkgId}`), async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: { id: testPkgId, package_name: '课包-预览验证', description: '仅第一节课节可试看', total_lessons: 3, package_status: 'ACTIVE', created_by: 'someone-else-id', published_at: '2026-01-01T00:00:00Z', published_snapshot: { course_groups: [ { id: 'group-1', group_name: '基础训练', course_sections: [ { id: 'sec-p1', course_name: '钢琴', section_name: '第一课-基础指法' }, { id: 'sec-p2', course_name: '钢琴', section_name: '第二课-音阶练习' }, { id: 'sec-p3', course_name: '钢琴', section_name: '第三课-和弦' }, ], }, ], }, }, e: null, }), }); }); await page.goto(`/course/course-package/detail?id=${testPkgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // First section should have "试看" badge, not locked const firstSection = page.locator('.section-item').first(); await expect(firstSection.locator('.preview-badge')).toBeVisible(); await expect(firstSection.locator('.lock-label')).not.toBeVisible(); // Second section should be locked const secondSection = page.locator('.section-item').nth(1); await expect(secondSection.locator('.lock-label')).toBeVisible(); await expect(secondSection.locator('.preview-badge')).not.toBeVisible(); // Third section should be locked const thirdSection = page.locator('.section-item').nth(2); await expect(thirdSection.locator('.lock-label')).toBeVisible(); // Clicking locked section shows purchase dialog await secondSection.click(); await page.waitForTimeout(500); const dialog = page.locator('.van-dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toContainText('购买课包'); await page.locator('.van-dialog__confirm').click(); }); test.fixme('已上架课包显示状态标签', async ({ page }) => { test.setTimeout(180_000); if (!moicenUnionid) return; const ok = await loginAsStudent(page, moicenUnionid); if (!ok) { test.skip(true, '会话不可用'); return; } const testPkgId = 'pkg-e2e-active'; await page.route(new RegExp(`/api/v1/clazz/course-package/${testPkgId}`), async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ r: true, d: { id: testPkgId, package_name: '课包-已上架', description: '已上架的课包', total_lessons: 10, package_status: 'ACTIVE', created_by: 'someone-else-id', published_at: '2026-01-01T00:00:00Z', published_snapshot: null, }, e: null, }), }); }); await page.goto(`/course/course-package/detail?id=${testPkgId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); await page.waitForTimeout(3000); // Check status label is rendered (may be in Vant Tag, so check page body) await expect(page.locator('#app')).toContainText('上架中', { timeout: 15_000 }); }); }); test.describe('真实数据验证', () => { test('已登录学生从商店进入课包详情无管理权限', async ({ page }) => { test.setTimeout(180_000); if (!moicenUnionid) { test.skip(true, 'MOICEN_E2E_UNIONID 未设置'); return; } const ok = await loginAsStudent(page, moicenUnionid); if (!ok) { test.skip(true, '会话不可用'); return; } // Navigate to a published package detail (owned by 周晓慧, not by 阿难) await page.goto('/course/course-package/detail?id=pkg-mc-001', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); // Handle org-select redirect if it shows up if (page.url().includes('/org/select')) { const orgCells = page.locator('#app .van-cell-group .van-cell'); const n = await orgCells.count().catch(() => 0); if (n > 0) { await orgCells.first().click(); await page.waitForTimeout(5000); } } // Retry navigating if we got redirected if (!page.url().includes('/course-package/detail')) { await page.goto('/course/course-package/detail?id=pkg-mc-001', { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); } // Should see the package name — wait for it with longer timeout const title = page.locator('h1.title'); if (!(await title.isVisible().catch(() => false))) { test.skip(true, '课包详情页未加载(可能 pkg-mc-001 不存在或无权访问)'); return; } // Should NOT see management buttons await expect(page.locator('button').filter({ hasText: '编辑' })).not.toBeVisible(); await expect(page.locator('button').filter({ hasText: '删除' })).not.toBeVisible(); await expect(page.locator('button').filter({ hasText: '发布上架' })).not.toBeVisible(); await expect(page.locator('button').filter({ hasText: '下架' })).not.toBeVisible(); // Should see section list or empty state const sectionList = page.locator('.group-sections'); const emptyState = page.locator('.empty'); await expect(sectionList.first().or(emptyState)).toBeVisible({ timeout: 10_000 }); }); }); });