mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
Codex/subscription governance (#744)
* feat(subscribe): show execution governance status * ci(frontend): allow manual test validation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
name: Frontend Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- v3
|
||||
|
||||
@@ -24,6 +24,40 @@ export interface MediaSourceInfo {
|
||||
media_types: string[]
|
||||
}
|
||||
|
||||
/** 订阅跨搜索与下载链路的当前业务状态。 */
|
||||
export interface SubscriptionExecutionStatus {
|
||||
state: string
|
||||
phase: string
|
||||
updated_at: string
|
||||
source?: string
|
||||
batch_id?: string
|
||||
task_id?: string
|
||||
current_site_id?: number
|
||||
error?: string
|
||||
can_cancel: boolean
|
||||
can_retry: boolean
|
||||
requires_reconciliation: boolean
|
||||
}
|
||||
|
||||
/** 订阅搜索批次的聚合进度与当前工作。 */
|
||||
export interface SubscriptionBatchStatus {
|
||||
batch_id: string
|
||||
source: string
|
||||
state: string
|
||||
phase: string
|
||||
total_count: number
|
||||
processed_count: number
|
||||
finished_count: number
|
||||
failed_count: number
|
||||
cancelled_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
current_subscription_id?: number
|
||||
current_site_id?: number
|
||||
error?: string
|
||||
can_cancel: boolean
|
||||
}
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
@@ -142,6 +176,8 @@ export interface Subscribe {
|
||||
downloader?: string
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
// 当前搜索或下载执行状态
|
||||
execution_status?: SubscriptionExecutionStatus
|
||||
}
|
||||
|
||||
/** 订阅删除成功后的机器可判断结果。 */
|
||||
|
||||
@@ -68,6 +68,34 @@ const subscribeState = ref<string>(props.media?.state ?? 'P')
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||
|
||||
// 将后端稳定业务状态映射为紧凑、可本地化的卡片展示。
|
||||
const executionStateDisplay = computed(() => {
|
||||
const execution = props.media?.execution_status
|
||||
if (!execution) return null
|
||||
const displays: Record<string, { color: string; icon: string }> = {
|
||||
queued: { color: 'info', icon: 'mdi-clock-outline' },
|
||||
running: { color: 'info', icon: 'mdi-progress-clock' },
|
||||
matching: { color: 'info', icon: 'mdi-filter-search-outline' },
|
||||
searching: { color: 'primary', icon: 'mdi-magnify-scan' },
|
||||
waiting_site_budget: { color: 'warning', icon: 'mdi-timer-sand' },
|
||||
preparing: { color: 'primary', icon: 'mdi-package-variant-closed' },
|
||||
submitting: { color: 'primary', icon: 'mdi-download-network-outline' },
|
||||
accepted: { color: 'success', icon: 'mdi-download-check-outline' },
|
||||
retryable: { color: 'warning', icon: 'mdi-refresh-circle' },
|
||||
reconcile_required: { color: 'warning', icon: 'mdi-alert-circle-outline' },
|
||||
failed: { color: 'error', icon: 'mdi-alert-outline' },
|
||||
cancelling: { color: 'warning', icon: 'mdi-cancel' },
|
||||
cancelled: { color: 'secondary', icon: 'mdi-cancel' },
|
||||
completed: { color: 'success', icon: 'mdi-check-circle-outline' },
|
||||
}
|
||||
const display = displays[execution.state] || displays[execution.phase] || displays.running
|
||||
return {
|
||||
...display,
|
||||
label: t(`subscribe.execution.state.${execution.state}`),
|
||||
error: execution.error,
|
||||
}
|
||||
})
|
||||
|
||||
// 判断后端数字/布尔开关是否启用
|
||||
function isEnabledFlag(value: any) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
@@ -92,6 +120,9 @@ const hasBestVersion = computed(() => isEnabledFlag(props.media?.best_version))
|
||||
const isBestVersion = computed(() => hasBestVersion.value && isTvSubscribe(props.media))
|
||||
|
||||
const rightBottomStateDisplay = computed(() => {
|
||||
if (executionStateDisplay.value) {
|
||||
return executionStateDisplay.value
|
||||
}
|
||||
if (subscribeState.value === 'S') {
|
||||
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
@@ -103,6 +134,9 @@ const rightBottomStateDisplay = computed(() => {
|
||||
|
||||
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
||||
const compactStateDisplay = computed(() => {
|
||||
if (executionStateDisplay.value) {
|
||||
return executionStateDisplay.value
|
||||
}
|
||||
if (subscribeState.value === 'S') {
|
||||
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
@@ -175,6 +209,10 @@ const musicSubscribeMeta = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const compactStateText = computed(
|
||||
() => executionStateDisplay.value?.label || subscribeProgressText.value || musicSubscribeMeta.value?.text || '',
|
||||
)
|
||||
|
||||
// 订阅卡片 hover 文案:
|
||||
// - 普通订阅:「已下载 X · 共 Y 集」
|
||||
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
|
||||
@@ -240,7 +278,8 @@ async function removeSubscribe() {
|
||||
async function searchSubscribe() {
|
||||
try {
|
||||
await api.get(`subscribe/search/${props.media?.id}`, { feedback: 'silent' })
|
||||
$toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||
$toast.success(t('subscribe.execution.searchSubmitted', { name: props.media?.name }))
|
||||
emit('save')
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
@@ -651,7 +690,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="subscribe-card-mobile-state"
|
||||
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
||||
:title="compactStateDisplay.label"
|
||||
:title="executionStateDisplay?.error || compactStateDisplay.label"
|
||||
:aria-label="compactStateDisplay.label"
|
||||
>
|
||||
<VIcon
|
||||
@@ -659,12 +698,16 @@ function handleCardClick() {
|
||||
:data-subscribe-state-icon="compactStateDisplay.icon"
|
||||
size="16"
|
||||
/>
|
||||
<span
|
||||
v-if="subscribeProgressText || musicSubscribeMeta"
|
||||
class="subscribe-card-mobile-progress-text"
|
||||
>
|
||||
{{ subscribeProgressText || musicSubscribeMeta?.text }}
|
||||
<span v-if="compactStateText" class="subscribe-card-mobile-progress-text">
|
||||
{{ compactStateText }}
|
||||
</span>
|
||||
<VTooltip
|
||||
v-if="executionStateDisplay?.error"
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{ executionStateDisplay.error }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
|
||||
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||
@@ -787,9 +830,18 @@ function handleCardClick() {
|
||||
<VCardText
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
:style="executionStateDisplay ? { color: `rgb(var(--v-theme-${executionStateDisplay.color}))` } : undefined"
|
||||
:title="executionStateDisplay?.error || rightBottomStateDisplay.label"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
<VTooltip
|
||||
v-if="executionStateDisplay?.error"
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{ executionStateDisplay.error }}
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
|
||||
@@ -348,6 +348,26 @@ describe('SubscribeCard display and progress', () => {
|
||||
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
})
|
||||
|
||||
it.each([480, 1024])('shows governed execution state and safe failure detail at %ipx', async width => {
|
||||
setViewport(width)
|
||||
await renderCard({
|
||||
execution_status: {
|
||||
batch_id: 'batch-1',
|
||||
can_cancel: true,
|
||||
can_retry: false,
|
||||
current_site_id: 9,
|
||||
error: '站点 9 冷却中',
|
||||
phase: 'waiting_site_budget',
|
||||
requires_reconciliation: false,
|
||||
state: 'waiting_site_budget',
|
||||
updated_at: '2026-09-01T01:00:00+00:00',
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('等待站点额度')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('站点 9 冷却中')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscribeCard interaction boundaries', () => {
|
||||
@@ -443,7 +463,7 @@ describe('SubscribeCard item operations', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
|
||||
['success', 200, { success: true }, 'success', '卡片测试媒体 已提交搜索请求'],
|
||||
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
|
||||
|
||||
@@ -1396,6 +1396,29 @@ export default {
|
||||
paused: 'Paused',
|
||||
cardStatePaused: 'Paused',
|
||||
cardStatePending: 'Pending',
|
||||
execution: {
|
||||
searchSubmitted: 'Search requested for {name}',
|
||||
batchProgress: '{processed}/{total}',
|
||||
cancel: 'Cancel this batch',
|
||||
cancelRequested: 'Batch cancellation requested',
|
||||
cancelFailed: 'Failed to cancel the search batch',
|
||||
state: {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
matching: 'Matching resources',
|
||||
searching: 'Searching sites',
|
||||
waiting_site_budget: 'Waiting for site budget',
|
||||
preparing: 'Preparing download',
|
||||
submitting: 'Submitting download',
|
||||
accepted: 'Accepted by downloader',
|
||||
retryable: 'Waiting to retry',
|
||||
reconcile_required: 'Download result needs review',
|
||||
failed: 'Failed',
|
||||
cancelling: 'Cancelling',
|
||||
cancelled: 'Cancelled',
|
||||
completed: 'Completed',
|
||||
},
|
||||
},
|
||||
mediaDetail: 'Media Details',
|
||||
fileStatistics: 'File Statistics',
|
||||
sortTitle: 'Sort',
|
||||
|
||||
@@ -1378,6 +1378,29 @@ export default {
|
||||
paused: '暂停',
|
||||
cardStatePaused: '已暂停',
|
||||
cardStatePending: '待定中',
|
||||
execution: {
|
||||
searchSubmitted: '{name} 已提交搜索请求',
|
||||
batchProgress: '{processed}/{total}',
|
||||
cancel: '取消本批次',
|
||||
cancelRequested: '已请求取消搜索批次',
|
||||
cancelFailed: '取消搜索批次失败',
|
||||
state: {
|
||||
queued: '排队中',
|
||||
running: '执行中',
|
||||
matching: '匹配资源',
|
||||
searching: '搜索站点',
|
||||
waiting_site_budget: '等待站点额度',
|
||||
preparing: '准备下载',
|
||||
submitting: '提交下载',
|
||||
accepted: '下载器已接受',
|
||||
retryable: '等待重试',
|
||||
reconcile_required: '需要确认下载结果',
|
||||
failed: '执行失败',
|
||||
cancelling: '取消中',
|
||||
cancelled: '已取消',
|
||||
completed: '执行完成',
|
||||
},
|
||||
},
|
||||
mediaDetail: '媒体详情',
|
||||
fileStatistics: '文件统计',
|
||||
sortTitle: '排序',
|
||||
|
||||
@@ -1379,6 +1379,29 @@ export default {
|
||||
paused: '暫停',
|
||||
cardStatePaused: '已暫停',
|
||||
cardStatePending: '待定中',
|
||||
execution: {
|
||||
searchSubmitted: '{name} 已提交搜索請求',
|
||||
batchProgress: '{processed}/{total}',
|
||||
cancel: '取消本批次',
|
||||
cancelRequested: '已請求取消搜索批次',
|
||||
cancelFailed: '取消搜索批次失敗',
|
||||
state: {
|
||||
queued: '排隊中',
|
||||
running: '執行中',
|
||||
matching: '匹配資源',
|
||||
searching: '搜索站點',
|
||||
waiting_site_budget: '等待站點額度',
|
||||
preparing: '準備下載',
|
||||
submitting: '提交下載',
|
||||
accepted: '下載器已接受',
|
||||
retryable: '等待重試',
|
||||
reconcile_required: '需要確認下載結果',
|
||||
failed: '執行失敗',
|
||||
cancelling: '取消中',
|
||||
cancelled: '已取消',
|
||||
completed: '執行完成',
|
||||
},
|
||||
},
|
||||
sortTitle: '排序',
|
||||
sort: {
|
||||
custom: '自定義',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { Subscribe, SubscribeDeletionResult } from '@/api/types'
|
||||
import type { Subscribe, SubscriptionBatchStatus, SubscribeDeletionResult } from '@/api/types'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
@@ -82,6 +82,47 @@ const loadError = ref(false)
|
||||
// 数据列表
|
||||
const dataList = ref<Subscribe[]>([])
|
||||
|
||||
// 最近批次用于展示聚合进度;订阅级状态仍由卡片各自渲染。
|
||||
const executionBatches = ref<SubscriptionBatchStatus[]>([])
|
||||
let executionPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let isUnmounted = false
|
||||
|
||||
const activeExecutionStates = new Set([
|
||||
'queued',
|
||||
'running',
|
||||
'matching',
|
||||
'searching',
|
||||
'waiting_site_budget',
|
||||
'preparing',
|
||||
'submitting',
|
||||
'accepted',
|
||||
'cancelling',
|
||||
])
|
||||
|
||||
const visibleExecutionBatch = computed(() => {
|
||||
return (
|
||||
executionBatches.value.find(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) ||
|
||||
executionBatches.value.find(batch => batch.state === 'failed' || batch.state === 'cancelled') ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const batchProgress = computed(() => {
|
||||
const batch = visibleExecutionBatch.value
|
||||
if (!batch?.total_count) return 0
|
||||
return Math.min(100, Math.round((batch.processed_count / batch.total_count) * 100))
|
||||
})
|
||||
|
||||
const hasActiveExecution = computed(() => {
|
||||
return (
|
||||
executionBatches.value.some(batch => activeExecutionStates.has(batch.state) || activeExecutionStates.has(batch.phase)) ||
|
||||
dataList.value.some(item => {
|
||||
const execution = item.execution_status
|
||||
return !!execution && (activeExecutionStates.has(execution.state) || activeExecutionStates.has(execution.phase))
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// 订阅顺序配置
|
||||
const orderConfig = ref<{ id: number }[]>([])
|
||||
|
||||
@@ -325,10 +366,16 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
}
|
||||
dataList.value = await api.get('subscribe/')
|
||||
const [subscribes, batches] = await Promise.all([
|
||||
api.get<Subscribe[]>('subscribe/'),
|
||||
api.get<SubscriptionBatchStatus[]>('subscribe/execution/batches?limit=10'),
|
||||
])
|
||||
dataList.value = subscribes
|
||||
executionBatches.value = batches
|
||||
loadError.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
if (isCancelledRequest(error)) return
|
||||
console.error(error)
|
||||
loadError.value = true
|
||||
if (isInitialLoad) {
|
||||
@@ -341,6 +388,37 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
}
|
||||
scheduleExecutionPoll()
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在页面可见且存在活动执行时轮询,终态后自动停止。
|
||||
function scheduleExecutionPoll() {
|
||||
if (executionPollTimer) {
|
||||
clearTimeout(executionPollTimer)
|
||||
executionPollTimer = undefined
|
||||
}
|
||||
if (isUnmounted || !props.active || !hasActiveExecution.value) return
|
||||
executionPollTimer = setTimeout(() => {
|
||||
void fetchData({ silent: true })
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
// 页面切换触发的请求取消是正常生命周期,不应展示为业务失败。
|
||||
function isCancelledRequest(error: unknown) {
|
||||
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_CANCELED'
|
||||
}
|
||||
|
||||
async function cancelExecutionBatch() {
|
||||
const batch = visibleExecutionBatch.value
|
||||
if (!batch?.can_cancel) return
|
||||
try {
|
||||
await api.put(`subscribe/execution/batches/${batch.batch_id}/cancel`, undefined, { feedback: 'silent' })
|
||||
$toast.success(t('subscribe.execution.cancelRequested'))
|
||||
await fetchData({ silent: true })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.execution.cancelFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +636,7 @@ const errorTitle = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
isUnmounted = false
|
||||
await loadSubscribeOrderConfig()
|
||||
await fetchData()
|
||||
if (props.subid) {
|
||||
@@ -570,6 +649,16 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
() => scheduleExecutionPoll(),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true
|
||||
if (executionPollTimer) clearTimeout(executionPollTimer)
|
||||
})
|
||||
|
||||
useKeepAliveRefresh(fetchData, {
|
||||
active: computed(() => props.active),
|
||||
})
|
||||
@@ -593,6 +682,47 @@ defineExpose({
|
||||
{{ t('subscribe.requestFailed') }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="visibleExecutionBatch"
|
||||
:color="visibleExecutionBatch.state === 'failed' ? 'error' : visibleExecutionBatch.state === 'cancelled' ? 'secondary' : 'info'"
|
||||
variant="tonal"
|
||||
class="subscribe-execution-banner mb-4 mx-2 py-2"
|
||||
>
|
||||
<div class="d-flex min-w-0 align-center gap-3">
|
||||
<VIcon
|
||||
:icon="activeExecutionStates.has(visibleExecutionBatch.state) ? 'mdi-progress-clock' : visibleExecutionBatch.state === 'failed' ? 'mdi-alert-outline' : 'mdi-cancel'"
|
||||
size="20"
|
||||
/>
|
||||
<div class="min-w-0 flex-grow-1">
|
||||
<div class="d-flex min-w-0 align-center justify-space-between gap-2 text-body-2 font-weight-medium">
|
||||
<span class="text-truncate">
|
||||
{{ t(`subscribe.execution.state.${visibleExecutionBatch.phase}`) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0">
|
||||
{{ t('subscribe.execution.batchProgress', { processed: visibleExecutionBatch.processed_count, total: visibleExecutionBatch.total_count }) }}
|
||||
</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="batchProgress"
|
||||
:indeterminate="visibleExecutionBatch.total_count === 0 && activeExecutionStates.has(visibleExecutionBatch.state)"
|
||||
height="3"
|
||||
class="mt-2"
|
||||
/>
|
||||
<div v-if="visibleExecutionBatch.error" class="text-caption mt-1 text-truncate">
|
||||
{{ visibleExecutionBatch.error }}
|
||||
</div>
|
||||
</div>
|
||||
<IconBtn
|
||||
v-if="visibleExecutionBatch.can_cancel"
|
||||
size="small"
|
||||
:title="t('subscribe.execution.cancel')"
|
||||
@click="cancelExecutionBatch"
|
||||
>
|
||||
<VIcon icon="mdi-close-circle-outline" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import type { Subscribe, SubscriptionBatchStatus } from '@/api/types'
|
||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createSubscribe } from '@tests/support/factories/subscribe'
|
||||
import {
|
||||
deleteSubscribeByIdHandler,
|
||||
cancelSubscriptionExecutionBatchHandler,
|
||||
saveSubscribeOrderConfigHandler,
|
||||
subscribeApiUrls,
|
||||
subscribeListHandler,
|
||||
subscriptionExecutionBatchesHandler,
|
||||
subscribeOrderConfigHandler,
|
||||
updateSubscribeStatusHandler,
|
||||
type SubscribeMediaType,
|
||||
@@ -217,6 +219,8 @@ const SubscribeListHost = defineComponent({
|
||||
|
||||
interface RenderListOptions {
|
||||
active?: boolean
|
||||
batchResponse?: SubscriptionBatchStatus[]
|
||||
onBatchRequest?: (url: URL) => void
|
||||
keyword?: string
|
||||
listResponse?: Subscribe[]
|
||||
listStatus?: number
|
||||
@@ -238,6 +242,7 @@ async function renderList(options: RenderListOptions = {}) {
|
||||
server.use(
|
||||
subscribeOrderConfigHandler(type, options.orderValue, options.orderStatus ?? 200, options.onOrderRequest),
|
||||
subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest),
|
||||
subscriptionExecutionBatchesHandler(options.batchResponse ?? [], 200, options.onBatchRequest),
|
||||
)
|
||||
|
||||
return renderWithProviders(SubscribeListHost, {
|
||||
@@ -313,6 +318,33 @@ describe('SubscribeListView loading and filtering', () => {
|
||||
expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date')
|
||||
})
|
||||
|
||||
it('shows batch progress and sends cancellation to the stable batch endpoint', async () => {
|
||||
const cancelRequested = vi.fn()
|
||||
const batch: SubscriptionBatchStatus = {
|
||||
batch_id: 'batch-42',
|
||||
can_cancel: true,
|
||||
cancelled_count: 0,
|
||||
created_at: '2026-09-01T00:00:00+00:00',
|
||||
failed_count: 0,
|
||||
finished_count: 1,
|
||||
phase: 'searching',
|
||||
processed_count: 1,
|
||||
source: 'manual',
|
||||
state: 'running',
|
||||
total_count: 3,
|
||||
updated_at: '2026-09-01T00:01:00+00:00',
|
||||
}
|
||||
server.use(cancelSubscriptionExecutionBatchHandler(batch.batch_id, { success: true }, 200, cancelRequested))
|
||||
await renderList({ batchResponse: [batch], listResponse: [movie(1, 'Own movie')] })
|
||||
|
||||
expect(await screen.findByText('搜索站点')).toBeInTheDocument()
|
||||
expect(screen.getByText('1/3')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByTitle('取消本批次'))
|
||||
|
||||
await waitFor(() => expect(cancelRequested).toHaveBeenCalledOnce())
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已请求取消搜索批次')
|
||||
})
|
||||
|
||||
it('lets a superuser see subscriptions from every owner while retaining type defense', async () => {
|
||||
await renderList({
|
||||
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')],
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
MediaInfo,
|
||||
Site,
|
||||
Subscribe,
|
||||
SubscriptionBatchStatus,
|
||||
SubscribeShare,
|
||||
SubscribeShareStatistics,
|
||||
TransferDirectoryConf,
|
||||
@@ -37,6 +38,9 @@ export const subscribeApiUrls = {
|
||||
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
||||
deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
||||
details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
|
||||
executionBatches: new URL('subscribe/execution/batches', API_BASE_URL).href,
|
||||
executionBatchCancel: (batchId: string) =>
|
||||
new URL(`subscribe/execution/batches/${batchId}/cancel`, API_BASE_URL).href,
|
||||
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
|
||||
downloaders: new URL('download/clients', API_BASE_URL).href,
|
||||
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||
@@ -88,6 +92,29 @@ export function subscribeListHandler(
|
||||
})
|
||||
}
|
||||
|
||||
export function subscriptionExecutionBatchesHandler(
|
||||
response: SubscriptionBatchStatus[] = [],
|
||||
status = 200,
|
||||
onRequest: (url: URL) => void = () => {},
|
||||
) {
|
||||
return http.get(subscribeApiUrls.executionBatches, ({ request }) => {
|
||||
onRequest(new URL(request.url))
|
||||
return dataResponse(response as unknown as JsonBodyType, status)
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelSubscriptionExecutionBatchHandler(
|
||||
batchId: string,
|
||||
response: SubscribeMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (url: URL) => void = () => {},
|
||||
) {
|
||||
return http.put(subscribeApiUrls.executionBatchCancel(batchId), ({ request }) => {
|
||||
onRequest(new URL(request.url))
|
||||
return mutationResponse(response, status)
|
||||
})
|
||||
}
|
||||
|
||||
export function popularSubscribesHandler(
|
||||
response: MediaInfo[] = [],
|
||||
status = 200,
|
||||
|
||||
Reference in New Issue
Block a user