mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
feat: 增加数据库备份策略设置 (#686)
This commit is contained in:
@@ -2219,6 +2219,21 @@ export default {
|
|||||||
logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',
|
logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',
|
||||||
logFileFormat: 'Log File Format',
|
logFileFormat: 'Log File Format',
|
||||||
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
|
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
|
||||||
|
dbBackupEnable: 'Enable Data Backup',
|
||||||
|
dbBackupEnableHint: 'Master switch for automatic database backups; scheduled backups require a backup schedule',
|
||||||
|
dbBackupCron: 'Backup Schedule',
|
||||||
|
dbBackupCronHint: 'Cron expression; leave empty to disable scheduled backups',
|
||||||
|
dbBackupCronInvalid: 'Backup schedule must be empty or a valid Cron expression',
|
||||||
|
dbBackupPath: 'Backup Directory',
|
||||||
|
dbBackupPathPlaceholder: '/config/database_backup',
|
||||||
|
dbBackupPathHint:
|
||||||
|
'Leave empty to use database_backup under the configuration directory; local paths are supported',
|
||||||
|
dbBackupRetentionDays: 'Backup Retention Days',
|
||||||
|
dbBackupRetentionDaysHint: 'Unit: days. Set to 0 for no limit',
|
||||||
|
dbBackupRetentionDaysInvalid: 'Backup retention days must be an integer greater than or equal to 0',
|
||||||
|
dbBackupMaxCount: 'Maximum Backup Count',
|
||||||
|
dbBackupMaxCountHint: 'Set to 0 for no limit',
|
||||||
|
dbBackupMaxCountInvalid: 'Maximum backup count must be an integer greater than or equal to 0',
|
||||||
dataCleanupEnable: 'Enable Data Cleanup',
|
dataCleanupEnable: 'Enable Data Cleanup',
|
||||||
dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped',
|
dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped',
|
||||||
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
|
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
|
||||||
|
|||||||
@@ -2189,6 +2189,20 @@ export default {
|
|||||||
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
|
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
|
||||||
logFileFormat: '日志文件格式',
|
logFileFormat: '日志文件格式',
|
||||||
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
|
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
|
||||||
|
dbBackupEnable: '启用数据备份',
|
||||||
|
dbBackupEnableHint: '数据库自动备份总开关,定时备份仅在配置备份周期后启用',
|
||||||
|
dbBackupCron: '备份周期',
|
||||||
|
dbBackupCronHint: 'Cron 表达式,留空不启用定时备份',
|
||||||
|
dbBackupCronInvalid: '备份周期必须留空或使用有效的 Cron 表达式',
|
||||||
|
dbBackupPath: '备份目录',
|
||||||
|
dbBackupPathPlaceholder: '/config/database_backup',
|
||||||
|
dbBackupPathHint: '留空使用配置目录下的 database_backup,支持本地存储路径',
|
||||||
|
dbBackupRetentionDays: '备份过期天数',
|
||||||
|
dbBackupRetentionDaysHint: '单位:天,0 表示不限制',
|
||||||
|
dbBackupRetentionDaysInvalid: '备份过期天数必须是大于等于 0 的整数',
|
||||||
|
dbBackupMaxCount: '最大保留份数',
|
||||||
|
dbBackupMaxCountHint: '0 表示不限制',
|
||||||
|
dbBackupMaxCountInvalid: '最大保留份数必须是大于等于 0 的整数',
|
||||||
dataCleanupEnable: '启用数据清理',
|
dataCleanupEnable: '启用数据清理',
|
||||||
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
|
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
|
||||||
dataCleanupDaysRequired: '请输入清理周期',
|
dataCleanupDaysRequired: '请输入清理周期',
|
||||||
|
|||||||
@@ -2188,6 +2188,20 @@ export default {
|
|||||||
logBackupCountMin: '日誌文件最大備份數量必須大於等於1',
|
logBackupCountMin: '日誌文件最大備份數量必須大於等於1',
|
||||||
logFileFormat: '日誌文件格式',
|
logFileFormat: '日誌文件格式',
|
||||||
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
|
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
|
||||||
|
dbBackupEnable: '啟用數據備份',
|
||||||
|
dbBackupEnableHint: '數據庫自動備份總開關,定時備份僅在配置備份週期後啟用',
|
||||||
|
dbBackupCron: '備份週期',
|
||||||
|
dbBackupCronHint: 'Cron 表達式,留空不啟用定時備份',
|
||||||
|
dbBackupCronInvalid: '備份週期必須留空或使用有效的 Cron 表達式',
|
||||||
|
dbBackupPath: '備份目錄',
|
||||||
|
dbBackupPathPlaceholder: '/config/database_backup',
|
||||||
|
dbBackupPathHint: '留空使用配置目錄下的 database_backup,支持本地存儲路徑',
|
||||||
|
dbBackupRetentionDays: '備份過期天數',
|
||||||
|
dbBackupRetentionDaysHint: '單位:天,0 表示不限制',
|
||||||
|
dbBackupRetentionDaysInvalid: '備份過期天數必須是大於等於 0 的整數',
|
||||||
|
dbBackupMaxCount: '最大保留份數',
|
||||||
|
dbBackupMaxCountHint: '0 表示不限制',
|
||||||
|
dbBackupMaxCountInvalid: '最大保留份數必須是大於等於 0 的整數',
|
||||||
dataCleanupEnable: '啟用數據清理',
|
dataCleanupEnable: '啟用數據清理',
|
||||||
dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務',
|
dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務',
|
||||||
dataCleanupDaysRequired: '請輸入清理週期',
|
dataCleanupDaysRequired: '請輸入清理週期',
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ const SystemSettings = ref<any>({
|
|||||||
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 180,
|
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 180,
|
||||||
DATA_CLEANUP_SITE_USERDATA_DAYS: 180,
|
DATA_CLEANUP_SITE_USERDATA_DAYS: 180,
|
||||||
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 365 * 3,
|
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 365 * 3,
|
||||||
|
// 本地数据库备份策略同时适用于 SQLite 与 PostgreSQL。
|
||||||
|
DB_BACKUP_ENABLE: false,
|
||||||
|
DB_BACKUP_CRON: '0 3 * * *',
|
||||||
|
DB_BACKUP_PATH: null,
|
||||||
|
DB_BACKUP_RETENTION_DAYS: 30,
|
||||||
|
DB_BACKUP_MAX_COUNT: 30,
|
||||||
// 媒体
|
// 媒体
|
||||||
RECOGNIZE_PLUGIN_FIRST: false,
|
RECOGNIZE_PLUGIN_FIRST: false,
|
||||||
MEDIA_RECOGNIZE_SHARE: true,
|
MEDIA_RECOGNIZE_SHARE: true,
|
||||||
@@ -610,6 +616,36 @@ const dataCleanupFieldRules = [
|
|||||||
(value: unknown) => Number(value) >= 0 || t('setting.system.dataCleanupDaysMin'),
|
(value: unknown) => Number(value) >= 0 || t('setting.system.dataCleanupDaysMin'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const dbBackupPath = computed({
|
||||||
|
get: () => String(SystemSettings.value.Advanced.DB_BACKUP_PATH ?? ''),
|
||||||
|
set: (value: string) => {
|
||||||
|
SystemSettings.value.Advanced.DB_BACKUP_PATH = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function hasValidCronFieldCount(value: string) {
|
||||||
|
return value.trim().split(/\s+/).length === 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonNegativeInteger(value: unknown) {
|
||||||
|
if (value === '' || value === null || value === undefined) return false
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isInteger(numberValue) && numberValue >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbBackupCronRules = [
|
||||||
|
(value: unknown) => {
|
||||||
|
const cron = String(value ?? '').trim()
|
||||||
|
return !cron || hasValidCronFieldCount(cron) || t('setting.system.dbBackupCronInvalid')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dbBackupRetentionRules = [
|
||||||
|
(value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupRetentionDaysInvalid'),
|
||||||
|
]
|
||||||
|
const dbBackupMaxCountRules = [
|
||||||
|
(value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupMaxCountInvalid'),
|
||||||
|
]
|
||||||
|
|
||||||
// 安全域名添加变量
|
// 安全域名添加变量
|
||||||
const newSecurityDomain = ref('')
|
const newSecurityDomain = ref('')
|
||||||
// 图片代理允许非公网网段添加变量
|
// 图片代理允许非公网网段添加变量
|
||||||
@@ -859,6 +895,7 @@ async function testLlmConnection() {
|
|||||||
|
|
||||||
// 保存高级设置
|
// 保存高级设置
|
||||||
async function saveAdvancedSettings() {
|
async function saveAdvancedSettings() {
|
||||||
|
if (!normalizeDbBackupSettings()) return
|
||||||
if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false
|
if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false
|
||||||
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
|
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
|
||||||
|
|
||||||
@@ -876,6 +913,35 @@ async function saveAdvancedSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 规范化备份策略,并在请求前阻止后端无法执行的配置。 */
|
||||||
|
function normalizeDbBackupSettings() {
|
||||||
|
const settings = SystemSettings.value.Advanced
|
||||||
|
const path = String(settings.DB_BACKUP_PATH ?? '').trim()
|
||||||
|
settings.DB_BACKUP_PATH = path || null
|
||||||
|
|
||||||
|
const cron = String(settings.DB_BACKUP_CRON ?? '').trim()
|
||||||
|
const retentionDays = Number(settings.DB_BACKUP_RETENTION_DAYS)
|
||||||
|
const maxCount = Number(settings.DB_BACKUP_MAX_COUNT)
|
||||||
|
|
||||||
|
if (cron && !hasValidCronFieldCount(cron)) {
|
||||||
|
$toast.error(t('setting.system.dbBackupCronInvalid'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!isNonNegativeInteger(settings.DB_BACKUP_RETENTION_DAYS)) {
|
||||||
|
$toast.error(t('setting.system.dbBackupRetentionDaysInvalid'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!isNonNegativeInteger(settings.DB_BACKUP_MAX_COUNT)) {
|
||||||
|
$toast.error(t('setting.system.dbBackupMaxCountInvalid'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.DB_BACKUP_CRON = cron
|
||||||
|
settings.DB_BACKUP_RETENTION_DAYS = retentionDays
|
||||||
|
settings.DB_BACKUP_MAX_COUNT = maxCount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
|
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
|
||||||
function cleanEmptyFields(settings: Record<string, unknown>, fields: string[]) {
|
function cleanEmptyFields(settings: Record<string, unknown>, fields: string[]) {
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
@@ -2413,6 +2479,65 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
<VWindowItem value="data">
|
<VWindowItem value="data">
|
||||||
<div>
|
<div>
|
||||||
<VRow>
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VSwitch
|
||||||
|
v-model="SystemSettings.Advanced.DB_BACKUP_ENABLE"
|
||||||
|
:label="t('setting.system.dbBackupEnable')"
|
||||||
|
:hint="t('setting.system.dbBackupEnableHint')"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<template v-if="SystemSettings.Advanced.DB_BACKUP_ENABLE">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VCronField
|
||||||
|
v-model="SystemSettings.Advanced.DB_BACKUP_CRON"
|
||||||
|
:label="t('setting.system.dbBackupCron')"
|
||||||
|
:hint="t('setting.system.dbBackupCronHint')"
|
||||||
|
persistent-hint
|
||||||
|
clearable
|
||||||
|
:rules="dbBackupCronRules"
|
||||||
|
prepend-inner-icon="mdi-clock-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VPathField
|
||||||
|
v-model="dbBackupPath"
|
||||||
|
storage="local"
|
||||||
|
:label="t('setting.system.dbBackupPath')"
|
||||||
|
:placeholder="t('setting.system.dbBackupPathPlaceholder')"
|
||||||
|
:hint="t('setting.system.dbBackupPathHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-folder-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="SystemSettings.Advanced.DB_BACKUP_RETENTION_DAYS"
|
||||||
|
:label="t('setting.system.dbBackupRetentionDays')"
|
||||||
|
:hint="t('setting.system.dbBackupRetentionDaysHint')"
|
||||||
|
persistent-hint
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
:suffix="t('setting.system.day')"
|
||||||
|
:rules="dbBackupRetentionRules"
|
||||||
|
prepend-inner-icon="mdi-calendar-remove-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="SystemSettings.Advanced.DB_BACKUP_MAX_COUNT"
|
||||||
|
:label="t('setting.system.dbBackupMaxCount')"
|
||||||
|
:hint="t('setting.system.dbBackupMaxCountHint')"
|
||||||
|
persistent-hint
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
:rules="dbBackupMaxCountRules"
|
||||||
|
prepend-inner-icon="mdi-backup-restore"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</template>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="SystemSettings.Advanced.DATA_CLEANUP_ENABLE"
|
v-model="SystemSettings.Advanced.DATA_CLEANUP_ENABLE"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useGlobalSettingsStore } from '@/stores'
|
|||||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { nextTick, ref } from 'vue'
|
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -278,10 +278,44 @@ function enableLlmSettings() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为异步表单字段提供可真实回写 v-model 的测试边界。 */
|
||||||
|
function createModelFieldStub(name: string) {
|
||||||
|
return defineComponent({
|
||||||
|
name,
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
modelValue: { default: '' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
storage: { type: String, default: undefined },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () =>
|
||||||
|
h('label', [
|
||||||
|
h('span', props.label),
|
||||||
|
h('input', {
|
||||||
|
'aria-label': props.label,
|
||||||
|
'data-storage': props.storage,
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const CronFieldStub = createModelFieldStub('VCronFieldStub')
|
||||||
|
const PathFieldStub = createModelFieldStub('VPathFieldStub')
|
||||||
|
|
||||||
async function renderSettings(props: { active?: boolean } = {}) {
|
async function renderSettings(props: { active?: boolean } = {}) {
|
||||||
return renderWithProviders(AccountSettingSystem, {
|
return renderWithProviders(AccountSettingSystem, {
|
||||||
props,
|
props,
|
||||||
global: { stubs: { VDialogCloseBtn: true } },
|
global: {
|
||||||
|
components: { VCronField: CronFieldStub, VPathField: PathFieldStub },
|
||||||
|
stubs: { VDialogCloseBtn: true },
|
||||||
|
},
|
||||||
stubActions: false,
|
stubActions: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1049,141 @@ describe('AccountSettingSystem', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['sqlite', 'postgresql'])('shows database backup defaults for %s', async databaseType => {
|
||||||
|
systemEnv.DB_TYPE = databaseType
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
expect(dialog.getByLabelText('启用数据备份')).not.toBeChecked()
|
||||||
|
expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
|
||||||
|
|
||||||
|
expect(dialog.getByLabelText('备份周期')).toHaveValue('0 3 * * *')
|
||||||
|
expect(dialog.getByLabelText('备份目录')).toHaveValue('')
|
||||||
|
expect(dialog.getByLabelText('备份目录')).toHaveAttribute('placeholder', '/config/database_backup')
|
||||||
|
expect(dialog.getByLabelText('备份目录')).toHaveAttribute('data-storage', 'local')
|
||||||
|
expect(dialog.getByLabelText('备份过期天数')).toHaveValue(30)
|
||||||
|
expect(dialog.getByLabelText('最大保留份数')).toHaveValue(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads, edits, and saves the database backup policy', async () => {
|
||||||
|
systemEnv = {
|
||||||
|
...systemEnv,
|
||||||
|
DB_BACKUP_CRON: '15 2 * * 1',
|
||||||
|
DB_BACKUP_ENABLE: true,
|
||||||
|
DB_BACKUP_MAX_COUNT: 12,
|
||||||
|
DB_BACKUP_PATH: '/data/backup',
|
||||||
|
DB_BACKUP_RETENTION_DAYS: 45,
|
||||||
|
}
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
expect(dialog.getByLabelText('备份周期')).toHaveValue('15 2 * * 1')
|
||||||
|
expect(dialog.getByLabelText('备份目录')).toHaveValue('/data/backup')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份周期'), '30 4 * * *')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份目录'), ' relative/backup ')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '60')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '20')
|
||||||
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(findPost('system/env')?.[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
DB_BACKUP_CRON: '30 4 * * *',
|
||||||
|
DB_BACKUP_ENABLE: true,
|
||||||
|
DB_BACKUP_MAX_COUNT: 20,
|
||||||
|
DB_BACKUP_PATH: 'relative/backup',
|
||||||
|
DB_BACKUP_RETENTION_DAYS: 60,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides disabled backup fields while preserving their values', async () => {
|
||||||
|
systemEnv = {
|
||||||
|
...systemEnv,
|
||||||
|
DB_BACKUP_CRON: ' 15 2 * * 1 ',
|
||||||
|
DB_BACKUP_ENABLE: true,
|
||||||
|
DB_BACKUP_MAX_COUNT: '12',
|
||||||
|
DB_BACKUP_PATH: ' /data/backup ',
|
||||||
|
DB_BACKUP_RETENTION_DAYS: '45',
|
||||||
|
}
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
|
||||||
|
expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument()
|
||||||
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(findPost('system/env')?.[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
DB_BACKUP_CRON: '15 2 * * 1',
|
||||||
|
DB_BACKUP_ENABLE: false,
|
||||||
|
DB_BACKUP_MAX_COUNT: 12,
|
||||||
|
DB_BACKUP_PATH: '/data/backup',
|
||||||
|
DB_BACKUP_RETENTION_DAYS: 45,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid hidden values while backup is disabled', async () => {
|
||||||
|
systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: '/data/backup' }
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份周期'), '* * * *')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份目录'), ' ')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '-1')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '1.5')
|
||||||
|
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
|
||||||
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('备份周期必须留空或使用有效的 Cron 表达式')
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts zero backup limits and normalizes a blank path to the backend default', async () => {
|
||||||
|
systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: null }
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
expect(dialog.getByLabelText('备份目录')).toHaveValue('')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份周期'), '')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份目录'), ' ')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '0')
|
||||||
|
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '0')
|
||||||
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(findPost('system/env')?.[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
DB_BACKUP_CRON: '',
|
||||||
|
DB_BACKUP_MAX_COUNT: 0,
|
||||||
|
DB_BACKUP_PATH: null,
|
||||||
|
DB_BACKUP_RETENTION_DAYS: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['备份周期', '* * * *', '备份周期必须留空或使用有效的 Cron 表达式'],
|
||||||
|
['备份过期天数', '-1', '备份过期天数必须是大于等于 0 的整数'],
|
||||||
|
['最大保留份数', '1.5', '最大保留份数必须是大于等于 0 的整数'],
|
||||||
|
])('rejects invalid database backup field %s before posting', async (label, value, message) => {
|
||||||
|
systemEnv.DB_BACKUP_ENABLE = true
|
||||||
|
await renderSettings()
|
||||||
|
const dialog = await openAdvancedTab('数据')
|
||||||
|
|
||||||
|
await fireEvent.update(dialog.getByLabelText(label), value)
|
||||||
|
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith(message)
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('round-trips every advanced laboratory control when Rust is available', async () => {
|
it('round-trips every advanced laboratory control when Rust is available', async () => {
|
||||||
systemEnv.RUST_ACCEL_AVAILABLE = true
|
systemEnv.RUST_ACCEL_AVAILABLE = true
|
||||||
await renderSettings()
|
await renderSettings()
|
||||||
|
|||||||
Reference in New Issue
Block a user