mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
336 lines
15 KiB
TypeScript
336 lines
15 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||
import {
|
||
DEFAULT_SEARCH_KEYWORD,
|
||
getSearchCards,
|
||
prepareSearchSeed,
|
||
type PreparedSearchSeed,
|
||
} from './helpers/search-seed'
|
||
import { registerSession } from './helpers/session'
|
||
|
||
function searchUrl(query: string, sort = 'relevance', page = 0, starredOnly = false) {
|
||
return `/search?q=${encodeURIComponent(query)}&sort=${sort}&page=${page}&starredOnly=${starredOnly}`
|
||
}
|
||
|
||
let basicSeed: PreparedSearchSeed | undefined
|
||
|
||
test.setTimeout(300_000)
|
||
|
||
test.beforeAll(async ({ browser }, testInfo) => {
|
||
test.setTimeout(300_000)
|
||
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })
|
||
})
|
||
|
||
test.afterAll(async () => {
|
||
await basicSeed?.dispose()
|
||
basicSeed = undefined
|
||
})
|
||
|
||
// ─── Search Input ────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Input (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_001 P0
|
||
test('TC_SEARCH_INPUT_001: searches with a single keyword and shows results', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list
|
||
test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => {
|
||
const emptyQueryResponse = page.waitForResponse((response) => {
|
||
if (!response.url().includes('/api/web/skills?')) {
|
||
return false
|
||
}
|
||
|
||
const url = new URL(response.url())
|
||
return response.status() === 200
|
||
&& url.searchParams.has('q')
|
||
&& url.searchParams.get('q') === ''
|
||
})
|
||
|
||
await page.goto(searchUrl(''))
|
||
await emptyQueryResponse
|
||
await expect(page).toHaveURL(/\/search/)
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_004 P0 - Enter key triggers search
|
||
test('TC_SEARCH_INPUT_004: pressing Enter in search box triggers search', async ({ page }) => {
|
||
await page.goto(searchUrl(''))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill(basicSeed!.keyword)
|
||
await searchInput.press('Enter')
|
||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||
await expect(getSearchCards(page).first()).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_009 P0 - Chinese keyword search
|
||
test('TC_SEARCH_INPUT_009: supports Chinese keyword search without error', async ({ page }) => {
|
||
await page.goto(searchUrl('测试技能'))
|
||
await expect(page).toHaveURL(/\/search/)
|
||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_010 P0 - English keyword search
|
||
test('TC_SEARCH_INPUT_010: supports English keyword search', async ({ page }) => {
|
||
await page.goto(searchUrl('skill'))
|
||
await expect(page).toHaveURL(/q=skill/)
|
||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_007 P1 - special characters handled gracefully
|
||
test('TC_SEARCH_INPUT_007: handles special characters in search without crashing', async ({ page }) => {
|
||
await page.goto(searchUrl('@#$%'))
|
||
await expect(page).toHaveURL(/\/search/)
|
||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||
})
|
||
|
||
// TC_SEARCH_INPUT_011 P1 - leading/trailing spaces trimmed
|
||
test('TC_SEARCH_INPUT_011: trims leading and trailing spaces from search query', async ({ page }) => {
|
||
await page.goto(searchUrl(''))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill(` ${basicSeed!.keyword} `)
|
||
await searchInput.press('Enter')
|
||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||
await expect(getSearchCards(page).first()).toBeVisible()
|
||
})
|
||
})
|
||
|
||
// ─── Sort / Filter ────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Sort and Filter (Authenticated Real API)', () => {
|
||
test.beforeEach(async ({ page }, testInfo) => {
|
||
await setEnglishLocale(page)
|
||
await registerSession(page, testInfo)
|
||
})
|
||
|
||
// TC_SEARCH_SORT_001 P0 - default relevance tab selected
|
||
test('TC_SEARCH_SORT_001: relevance sort tab is selected by default', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible()
|
||
})
|
||
|
||
// TC_SEARCH_SORT_004 P0 - downloads sort
|
||
test('TC_SEARCH_SORT_004: clicking Downloads tab updates sort in URL', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
await expect(page).toHaveURL(/sort=downloads/)
|
||
})
|
||
|
||
// TC_SEARCH_SORT_005 P0 - newest sort
|
||
test('TC_SEARCH_SORT_005: clicking Newest tab updates sort in URL', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await page.getByRole('button', { name: 'Newest' }).click()
|
||
await expect(page).toHaveURL(/sort=newest|sort=created/)
|
||
})
|
||
|
||
// TC_SEARCH_SORT_006 P0 - switching sort preserves search keyword
|
||
test('TC_SEARCH_SORT_006: switching sort tab preserves the search keyword', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||
await expect(page).toHaveURL(/sort=downloads/)
|
||
})
|
||
|
||
// TC_SEARCH_SORT_007 P0 - switching sort resets page to 0
|
||
test('TC_SEARCH_SORT_007: switching sort tab resets page to 0', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 1))
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
await expect(page).toHaveURL(/page=0/)
|
||
})
|
||
|
||
// TC_SEARCH_SORT_012 P1 - URL contains sort param
|
||
test('TC_SEARCH_SORT_012: URL contains sort parameter after switching tabs', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
await expect(page).toHaveURL(/sort=/)
|
||
})
|
||
|
||
test('starred only filter stays on search page for authenticated user', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance'))
|
||
await page.getByRole('button', { name: 'Starred only' }).click()
|
||
await expect(page).toHaveURL(/starredOnly=true/)
|
||
await expect(page).not.toHaveURL(/\/login/)
|
||
})
|
||
})
|
||
|
||
test.describe('Search Sort and Filter (Anonymous Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
test('starred only filter redirects anonymous user to login', async ({ page }) => {
|
||
await page.goto(searchUrl(DEFAULT_SEARCH_KEYWORD, 'relevance'))
|
||
await page.getByRole('button', { name: 'Starred only' }).click()
|
||
await expect(page).toHaveURL(/\/login\?returnTo=/)
|
||
})
|
||
})
|
||
|
||
// ─── Skill Count ──────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Skill Count Display (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_COUNT_001 P0 - count visible
|
||
test('TC_SEARCH_COUNT_001: skill count indicator is visible on search page with results', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// TC_SEARCH_COUNT_007 P0 - count updates after search
|
||
test('TC_SEARCH_COUNT_007: skill count updates after performing a search', async ({ page }) => {
|
||
await page.goto(searchUrl(''))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill(basicSeed!.keyword)
|
||
await searchInput.press('Enter')
|
||
await expect(page.getByText(/\d+\s+skills found/i)).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// TC_SEARCH_COUNT_009 P0 - zero results shows 0
|
||
test('TC_SEARCH_COUNT_009: shows empty-state copy when search returns no results', async ({ page }) => {
|
||
await page.goto(searchUrl('xyznonexistentkeyword99999'))
|
||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_COUNT_008 P0 - count stays same when switching sort
|
||
test('TC_SEARCH_COUNT_008: skill count remains the same after switching sort tab', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||
const initialCount = Number(countText?.match(/\d+/)?.[0] ?? '0')
|
||
await page.getByRole('button', { name: 'Downloads' }).click()
|
||
const updatedCountText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||
const updatedCount = Number(updatedCountText?.match(/\d+/)?.[0] ?? '0')
|
||
expect(updatedCount).toBe(initialCount)
|
||
})
|
||
})
|
||
|
||
// ─── Search Results ───────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Results (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_001 P0 - results shown
|
||
test('TC_SEARCH_RESULT_001: shows skill cards when search returns results', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_002 P0 - no results message
|
||
test('TC_SEARCH_RESULT_002: shows empty state message when no results found', async ({ page }) => {
|
||
await page.goto(searchUrl('xyznonexistentkeyword99999'))
|
||
await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible({ timeout: 8_000 })
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_006 P0 - loading state
|
||
test('TC_SEARCH_RESULT_006: page renders without error during and after search', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_008 P0 - result count matches cards
|
||
test('TC_SEARCH_RESULT_008: number of displayed cards matches the count indicator', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword))
|
||
await page.waitForLoadState('networkidle')
|
||
const cards = getSearchCards(page)
|
||
await expect(cards.first()).toBeVisible({ timeout: 10_000 })
|
||
const visibleCount = await cards.count()
|
||
const countText = await page.getByText(/\d+\s+skills found/i).textContent()
|
||
const totalMatch = countText?.match(/\d+/)
|
||
expect(totalMatch).toBeTruthy()
|
||
expect(visibleCount).toBeGreaterThan(0)
|
||
expect(Number(totalMatch?.[0])).toBeGreaterThanOrEqual(visibleCount)
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_009 P0 - downloads sort order
|
||
test('TC_SEARCH_RESULT_009: results are sorted by downloads when Downloads tab is selected', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'downloads'))
|
||
await expect(page).toHaveURL(/sort=downloads/)
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_RESULT_010 P0 - newest sort order
|
||
test('TC_SEARCH_RESULT_010: results are sorted by newest when Newest tab is selected', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'newest'))
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
})
|
||
|
||
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Pagination (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_PAGE_011 P1 - URL contains page param
|
||
test('TC_SEARCH_PAGE_011: URL contains page parameter', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
|
||
await expect(page).toHaveURL(/page=/)
|
||
})
|
||
|
||
// TC_SEARCH_PAGE_012 P0 - switching page preserves search and sort
|
||
test('TC_SEARCH_PAGE_012: switching page preserves search keyword and sort', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'downloads', 0))
|
||
const nextBtn = page.getByRole('button', { name: /next|›|»/i })
|
||
await expect(nextBtn).toBeVisible({ timeout: 10_000 })
|
||
await nextBtn.click()
|
||
await expect(page).toHaveURL(new RegExp(`q=${basicSeed!.keyword}`))
|
||
await expect(page).toHaveURL(/sort=downloads/)
|
||
await expect(page).toHaveURL(/page=1/)
|
||
})
|
||
|
||
// TC_SEARCH_PAGE_007 P0 - first page disables previous button
|
||
test('TC_SEARCH_PAGE_007: previous page button is disabled on first page', async ({ page }) => {
|
||
await page.goto(searchUrl(basicSeed!.keyword, 'relevance', 0))
|
||
const prevBtn = page.getByRole('button', { name: /prev|‹|«/i })
|
||
if (await prevBtn.isVisible()) {
|
||
await expect(prevBtn).toBeDisabled()
|
||
}
|
||
})
|
||
})
|
||
|
||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||
|
||
test.describe('Search Security (Real API)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await setEnglishLocale(page)
|
||
})
|
||
|
||
// TC_SEARCH_SEC_001 P0 - XSS in search box
|
||
test('TC_SEARCH_SEC_001: XSS payload in search box is not executed', async ({ page }) => {
|
||
let alerted = false
|
||
page.on('dialog', () => { alerted = true })
|
||
|
||
await page.goto(searchUrl(''))
|
||
const searchInput = page.getByPlaceholder('Search skills...')
|
||
await searchInput.fill("<script>alert('xss')</script>")
|
||
await searchInput.press('Enter')
|
||
|
||
await page.waitForTimeout(1_000)
|
||
expect(alerted).toBe(false)
|
||
await expect(page.locator('body')).not.toContainText(/error|500/i)
|
||
})
|
||
|
||
// TC_SEARCH_SEC_002 P0 - SQL injection in search box
|
||
test('TC_SEARCH_SEC_002: SQL injection payload in search box is handled safely', async ({ page }) => {
|
||
await page.goto(searchUrl("' OR '1'='1"))
|
||
await expect(page.locator('body')).not.toContainText(/sql|syntax error|database/i)
|
||
await expect(page).toHaveURL(/\/search/)
|
||
})
|
||
|
||
// TC_SEARCH_SEC_003 P1 - URL param tampering
|
||
test('TC_SEARCH_SEC_003: tampered URL parameters are handled gracefully', async ({ page }, testInfo) => {
|
||
await registerSession(page, testInfo)
|
||
await page.goto('/search?q=agent&sort=INVALID_SORT&page=-1&starredOnly=invalid')
|
||
await expect(page).toHaveURL(/\/search/)
|
||
await expect(page.locator('body')).not.toContainText(/error|500|crash/i)
|
||
})
|
||
})
|