261 lines
14 KiB
TypeScript
261 lines
14 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, type PropType } from 'vue'
|
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
|
import ElementPlus, { ElMessageBox, ElRadioGroup } from 'element-plus'
|
|
import QRCode from 'qrcode'
|
|
import { pdaReleaseRoutes } from '../routes'
|
|
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
|
import CopyButton from '@/shared/components/CopyButton.vue'
|
|
import { copyText } from '@/shared/utils/copyText'
|
|
import { listPdaReleases, publishPdaRelease, withdrawPdaRelease, type PdaRelease } from '../api/pdaReleases'
|
|
import PdaReleasesView from './PdaReleasesView.vue'
|
|
|
|
const can = vi.hoisted(() => vi.fn(() => true))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
|
vi.mock('../api/pdaReleases', () => ({
|
|
listPdaReleases: vi.fn(), publishPdaRelease: vi.fn(), uploadPdaRelease: vi.fn(), withdrawPdaRelease: vi.fn(),
|
|
}))
|
|
vi.mock('@/shared/utils/copyText', () => ({ copyText: vi.fn() }))
|
|
|
|
enableAutoUnmount(afterEach)
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8')
|
|
|
|
describe('PDA release workbench', () => {
|
|
it('registers one permission-gated route and a system menu entry', () => {
|
|
expect(pdaReleaseRoutes[0]?.meta?.permission).toBe('platform:pda-release:list')
|
|
const system = adminNavigation.find((item) => item.key === 'system')
|
|
expect(system?.children?.find((item) => item.key === 'pda-releases')).toMatchObject({
|
|
path: '/system/pda-releases', permission: 'platform:pda-release:list',
|
|
})
|
|
})
|
|
|
|
it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => {
|
|
expect(page).toContain('value="domestic"')
|
|
expect(page).toContain('value="overseas"')
|
|
expect(page).toContain('aria-label="最新 APK 下载"')
|
|
expect(page).not.toContain('@click="download(asRelease(row))"')
|
|
expect(page).toContain('v-if="canManage"')
|
|
expect(page).toContain('row.status === \'DRAFT\'')
|
|
expect(page).toContain('row.current')
|
|
expect(page).toContain('ElMessageBox.confirm')
|
|
})
|
|
|
|
it('uses the standard dense list workspace without repeating the route title', () => {
|
|
expect(page).toContain('appearance="integrated"')
|
|
expect(page).not.toContain(':stripe="false"')
|
|
expect(page).not.toContain(':stripe="false"')
|
|
expect(page).toContain('<template #toolbar-start>')
|
|
expect(page).toContain('<template #toolbar-actions>')
|
|
expect(page).toContain('class="pda-release-actions density-actions row-actions admin-text-actions"')
|
|
const toolbar = page.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1]
|
|
expect(toolbar).toContain('class="pda-release-context query-toolbar workbench-list-tabs"')
|
|
expect(toolbar).toContain('<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">')
|
|
expect(toolbar).toContain('当前 WGT')
|
|
expect(page).not.toContain('<template #filters>')
|
|
expect(page).not.toContain('<h1>PDA版本</h1>')
|
|
expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false)
|
|
})
|
|
})
|
|
|
|
const apk: PdaRelease = {
|
|
id: 'apk-current', app: 'domestic', appid: 'domestic-pda', packageType: 'apk',
|
|
versionName: '0.1.0', versionCode: 10, minAppVersionCode: 10, mandatory: false,
|
|
releaseNotes: '更新扫码', status: 'PUBLISHED', current: true, sha256: 'checksum', sizeBytes: 1024,
|
|
downloadUrl: '/gateway-api/pda/domestic/current.apk', createdAt: '2026-11-06T14:00:00', publishedAt: '2026-11-06T14:10:00',
|
|
}
|
|
const apkUrl = new URL(apk.downloadUrl!, window.location.href).href
|
|
const overseasApk: PdaRelease = { ...apk, id: 'overseas-current', app: 'overseas', appid: 'overseas-pda',
|
|
downloadUrl: 'https://downloads.example.com/pda/overseas/current.apk' }
|
|
|
|
const TableStub = defineComponent({
|
|
props: { data: { type: Array as PropType<PdaRelease[]>, default: () => [] } },
|
|
template: `<div><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="notices" />
|
|
<div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>`,
|
|
})
|
|
|
|
function renderPage() {
|
|
return mount(PdaReleasesView, {
|
|
global: { plugins: [ElementPlus], stubs: { DataTable: TableStub, AppDialog: true } },
|
|
})
|
|
}
|
|
|
|
describe('latest APK download', () => {
|
|
beforeEach(() => {
|
|
can.mockReturnValue(true)
|
|
vi.mocked(listPdaReleases).mockReset().mockImplementation(async (targetApp) => targetApp === 'domestic' ? [apk] : [overseasApk])
|
|
vi.mocked(publishPdaRelease).mockReset().mockResolvedValue(apk)
|
|
vi.mocked(withdrawPdaRelease).mockReset().mockResolvedValue({ ...apk, current: false, status: 'WITHDRAWN' })
|
|
vi.spyOn(QRCode, 'toString')
|
|
})
|
|
|
|
it('encodes the current published APK as an absolute URL and shares it with read-only users', async () => {
|
|
can.mockReturnValue(false)
|
|
vi.mocked(listPdaReleases).mockResolvedValue([
|
|
{ ...apk, id: 'apk-history', current: false, downloadUrl: '/old.apk' },
|
|
{ ...apk, id: 'apk-draft', current: false, status: 'DRAFT', versionCode: 20, downloadUrl: '/draft.apk' },
|
|
{ ...apk, id: 'wgt-current', packageType: 'wgt', versionCode: 30, downloadUrl: '/current.wgt' },
|
|
overseasApk, apk,
|
|
])
|
|
const downloads: HTMLAnchorElement[] = []
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this) })
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
|
|
expect(QRCode.toString).toHaveBeenCalledTimes(2)
|
|
expect(QRCode.toString).toHaveBeenCalledWith(apkUrl, expect.objectContaining({ type: 'svg', margin: 4, width: 160 }))
|
|
expect(QRCode.toString).toHaveBeenCalledWith(overseasApk.downloadUrl, expect.objectContaining({ type: 'svg' }))
|
|
const domestic = wrapper.get('[data-app="domestic"]')
|
|
const overseas = wrapper.get('[data-app="overseas"]')
|
|
expect(domestic.findAll('img')).toHaveLength(1)
|
|
expect(overseas.findAll('img')).toHaveLength(1)
|
|
expect(wrapper.get('.list-page-layout__toolbar .pda-apk-downloads').exists()).toBe(true)
|
|
expect(wrapper.get('.workbench-table').find('.pda-apk-downloads').exists()).toBe(false)
|
|
const image = domestic.get('.pda-apk-qr img')
|
|
expect(image.attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
|
expect(decodeURIComponent(image.attributes('src').split(',')[1]!)).toContain('<svg')
|
|
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
|
expect(domestic.findComponent(CopyButton).props('value')).toBe(apkUrl)
|
|
await domestic.get('[aria-label="复制国内 PDA APK下载链接"]').trigger('click')
|
|
await flushPromises()
|
|
expect(copyText).toHaveBeenCalledWith(apkUrl)
|
|
await domestic.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
|
expect(downloads[0]?.href).toBe(apkUrl)
|
|
expect(downloads[0]?.download).toBe('domestic-pda-0.1.0.apk')
|
|
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
|
await flushPromises()
|
|
await overseas.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
|
expect(downloads[1]?.href).toBe(overseasApk.downloadUrl)
|
|
expect(downloads[1]?.download).toBe('overseas-pda-0.1.0.apk')
|
|
expect(wrapper.text()).not.toContain('上传版本')
|
|
})
|
|
|
|
it('offers no download for drafts, withdrawn APKs or WGT-only releases', async () => {
|
|
vi.mocked(listPdaReleases).mockResolvedValue([
|
|
{ ...apk, status: 'DRAFT', current: true },
|
|
{ ...apk, id: 'withdrawn', status: 'WITHDRAWN', current: true },
|
|
{ ...apk, id: 'wgt', packageType: 'wgt' },
|
|
])
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('尚未发布 APK')
|
|
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(false)
|
|
expect(wrapper.find('.pda-apk-qr').exists()).toBe(false)
|
|
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
|
expect(QRCode.toString).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows an unavailable state when a published APK has no download URL', async () => {
|
|
vi.mocked(listPdaReleases).mockResolvedValue([{ ...apk, downloadUrl: null }])
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('当前 APK 下载地址暂不可用')
|
|
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
|
expect(QRCode.toString).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps both download cards independent of the selected list tab, including pending QR generation', async () => {
|
|
let finishDomestic!: (svg: string) => void
|
|
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishDomestic = resolve }))
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
const domestic = wrapper.get('[data-app="domestic"]')
|
|
const overseas = wrapper.get('[data-app="overseas"]')
|
|
const overseasImage = overseas.get('.pda-apk-qr img').attributes('src')
|
|
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
|
await flushPromises()
|
|
|
|
finishDomestic('<svg>domestic QR</svg>')
|
|
await flushPromises()
|
|
expect(overseas.get('.pda-apk-qr img').attributes('src')).toBe(overseasImage)
|
|
expect(domestic.get('.pda-apk-qr img').attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
|
expect(overseas.get('.pda-apk-qr img').attributes('alt')).toBe('海外 PDA最新APK下载二维码')
|
|
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
|
expect(overseas.get('.pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
|
expect(wrapper.findComponent(TableStub).props('data')).toEqual([overseasApk])
|
|
expect(listPdaReleases).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('ignores a previous QR generation after refreshing the latest APK', async () => {
|
|
let finishOld!: (svg: string) => void
|
|
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishOld = resolve }))
|
|
const newerApk = { ...apk, id: 'newer-apk', downloadUrl: '/newer.apk' }
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? [newerApk] : [overseasApk])
|
|
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
|
await flushPromises()
|
|
const domestic = wrapper.get('[data-app="domestic"]')
|
|
const newerImage = domestic.get('.pda-apk-qr img').attributes('src')
|
|
finishOld('<svg>old QR</svg>')
|
|
await flushPromises()
|
|
|
|
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(new URL('/newer.apk', window.location.href).href)
|
|
expect(domestic.get('.pda-apk-qr img').attributes('src')).toBe(newerImage)
|
|
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('shows the QR only after publishing and removes it on withdrawal without selecting history', async () => {
|
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
|
const domesticResponses: PdaRelease[][] = [
|
|
[{ ...apk, status: 'DRAFT', current: false }], [apk],
|
|
[{ ...apk, status: 'WITHDRAWN', current: false }, { ...apk, id: 'history', current: false }],
|
|
]
|
|
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? domesticResponses.shift()! : [overseasApk])
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
const domestic = wrapper.get('[data-app="domestic"]')
|
|
const overseasImage = wrapper.get('[data-app="overseas"] img').attributes('src')
|
|
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
|
|
|
await wrapper.findAll('button').find((button) => button.text() === '发布')!.trigger('click')
|
|
await flushPromises()
|
|
expect(publishPdaRelease).toHaveBeenCalledWith(apk.id)
|
|
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
|
|
|
await wrapper.findAll('button').find((button) => button.text() === '停发')!.trigger('click')
|
|
await flushPromises()
|
|
expect(withdrawPdaRelease).toHaveBeenCalledWith(apk.id)
|
|
expect(domestic.find('.pda-apk-qr').exists()).toBe(false)
|
|
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
|
expect(wrapper.get('[data-app="overseas"] img').attributes('src')).toBe(overseasImage)
|
|
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('clears the previous download when refresh fails and recovers on retry', async () => {
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
const domestic = wrapper.get('[data-app="domestic"]')
|
|
vi.mocked(listPdaReleases).mockRejectedValueOnce(new Error('network error'))
|
|
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
|
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
|
await flushPromises()
|
|
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
|
expect(domestic.get('[role="alert"]').text()).toBeTruthy()
|
|
expect(wrapper.get('[data-app="overseas"] .pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
|
|
|
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
|
await flushPromises()
|
|
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
|
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
|
})
|
|
|
|
it('reports QR generation failure while preserving the APK link and allows refresh to retry', async () => {
|
|
vi.mocked(QRCode.toString).mockImplementationOnce(() => Promise.reject(new Error('QR unavailable')))
|
|
const wrapper = renderPage()
|
|
await flushPromises()
|
|
expect(wrapper.get('[role="alert"]').text()).toBe('下载二维码生成失败,请刷新后重试')
|
|
expect(wrapper.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
|
|
|
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(true)
|
|
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
|
})
|
|
})
|