mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(settings): refresh upload limits and default to 4 GB
Share the saved upload limit with validation so changes apply without a page reload. Seed new installations with a 4096 MB limit while preserving existing values. Fixes #1733
This commit is contained in:
@@ -11,6 +11,7 @@ import MigrationModal from '@/features/migration/components/MigrationModal.vue'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getWorkflowState, type MigrationWorkflowState } from '@/features/migration/lib/migration-api'
|
import { getWorkflowState, type MigrationWorkflowState } from '@/features/migration/lib/migration-api'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||||
|
import { useAppInfo } from './composables/useAppInfo'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ const migrationLoading = ref(true)
|
|||||||
|
|
||||||
const updateCheckEnabled = ref(true)
|
const updateCheckEnabled = ref(true)
|
||||||
const updateCheckLoading = ref(false)
|
const updateCheckLoading = ref(false)
|
||||||
const maxUploadSizeMb = ref(500)
|
const { maxUploadSizeMb } = useAppInfo()
|
||||||
const localMaxUploadSizeMb = ref(500)
|
const localMaxUploadSizeMb = ref(500)
|
||||||
const maxUploadSizeSaving = ref(false)
|
const maxUploadSizeSaving = ref(false)
|
||||||
const achievementBackfillRunning = ref(false)
|
const achievementBackfillRunning = ref(false)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useBookUpload } from '@/features/library/composables/useBookUpload'
|
||||||
|
import { useAddBookFile } from '@/features/book/composables/useAddBookFile'
|
||||||
|
import { useAppInfo } from '../composables/useAppInfo'
|
||||||
|
import MaintenanceSettings from '../MaintenanceSettings.vue'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||||
|
getValidToken: vi.fn<() => Promise<string | null>>().mockResolvedValue(null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||||
|
usePermissions: () => ({ isSuperuser: ref(true) }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/features/migration/lib/migration-api', () => ({
|
||||||
|
getWorkflowState: vi.fn<() => Promise<{ active: null }>>().mockResolvedValue({ active: null }),
|
||||||
|
}))
|
||||||
|
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>() } }))
|
||||||
|
|
||||||
|
const mockApi = vi.mocked(api)
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
|
||||||
|
function makeFile(sizeMb: number): File {
|
||||||
|
const file = new File(['audio'], 'audiobook.m4b')
|
||||||
|
Object.defineProperty(file, 'size', { value: sizeMb * 1024 * 1024 })
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountSettings() {
|
||||||
|
wrapper = mount(MaintenanceSettings, {
|
||||||
|
global: { stubs: { MigrationModal: true, SettingsPageHeader: true, ToggleSwitch: true } },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLimit(sizeMb: number) {
|
||||||
|
await wrapper.get('input[type="number"]').setValue(sizeMb)
|
||||||
|
const save = wrapper.findAll('button').find((button) => button.text() === i18n.global.t('settings.admin.maintenance.save'))!
|
||||||
|
await save.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MaintenanceSettings upload limit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
i18n.global.locale.value = 'en'
|
||||||
|
useAppInfo().maxUploadSizeMb.value = 500
|
||||||
|
mockApi.mockResolvedValue(new Response(JSON.stringify([{ key: 'max_upload_size_mb', value: '500' }])))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount())
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['library uploads', useBookUpload],
|
||||||
|
['additional book files', useAddBookFile],
|
||||||
|
] as const)('uses a newly saved higher limit for %s without a reload', async (_name, useUpload) => {
|
||||||
|
const upload = useUpload()
|
||||||
|
await mountSettings()
|
||||||
|
mockApi.mockResolvedValueOnce(new Response(JSON.stringify({ key: 'max_upload_size_mb', value: '900' })))
|
||||||
|
|
||||||
|
await saveLimit(900)
|
||||||
|
upload.addFiles([makeFile(600)])
|
||||||
|
|
||||||
|
expect(mockApi).toHaveBeenLastCalledWith('/api/v1/app-settings/max_upload_size_mb', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: '900' }),
|
||||||
|
})
|
||||||
|
expect(upload.files.value[0]?.status).toBe('pending')
|
||||||
|
expect(upload.files.value[0]?.validationError).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['library uploads', useBookUpload],
|
||||||
|
['additional book files', useAddBookFile],
|
||||||
|
] as const)('uses a newly saved lower limit for %s without a reload', async (_name, useUpload) => {
|
||||||
|
const upload = useUpload()
|
||||||
|
await mountSettings()
|
||||||
|
mockApi.mockResolvedValueOnce(new Response(JSON.stringify({ key: 'max_upload_size_mb', value: '200' })))
|
||||||
|
|
||||||
|
await saveLimit(200)
|
||||||
|
upload.addFiles([makeFile(300)])
|
||||||
|
|
||||||
|
expect(upload.files.value[0]?.status).toBe('error')
|
||||||
|
expect(upload.files.value[0]?.validationError).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the current limit when saving fails', async () => {
|
||||||
|
await mountSettings()
|
||||||
|
mockApi.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||||
|
|
||||||
|
await saveLimit(900)
|
||||||
|
const upload = useBookUpload()
|
||||||
|
upload.addFiles([makeFile(600)])
|
||||||
|
|
||||||
|
expect(useAppInfo().maxUploadSizeMb.value).toBe(500)
|
||||||
|
expect(upload.files.value[0]?.status).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the saved limit loaded by the settings page', async () => {
|
||||||
|
mockApi.mockResolvedValueOnce(new Response(JSON.stringify([{ key: 'max_upload_size_mb', value: '900' }])))
|
||||||
|
|
||||||
|
await mountSettings()
|
||||||
|
const upload = useBookUpload()
|
||||||
|
upload.addFiles([makeFile(600)])
|
||||||
|
|
||||||
|
expect(upload.files.value[0]?.status).toBe('pending')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const DEFAULT_MAX_UPLOAD_SIZE_MB = 500;
|
export const DEFAULT_MAX_UPLOAD_SIZE_MB = 4_096;
|
||||||
export const HARD_MAX_UPLOAD_SIZE_MB = 4_096;
|
export const HARD_MAX_UPLOAD_SIZE_MB = 4_096;
|
||||||
export const HARD_MAX_UPLOAD_BYTES = HARD_MAX_UPLOAD_SIZE_MB * 1_024 * 1_024;
|
export const HARD_MAX_UPLOAD_BYTES = HARD_MAX_UPLOAD_SIZE_MB * 1_024 * 1_024;
|
||||||
export const UPLOAD_CHUNK_SIZE_BYTES = 16 * 1_024 * 1_024;
|
export const UPLOAD_CHUNK_SIZE_BYTES = 16 * 1_024 * 1_024;
|
||||||
|
|||||||
@@ -620,9 +620,9 @@ describe('AppSettingsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getMaxUploadSizeMb', () => {
|
describe('getMaxUploadSizeMb', () => {
|
||||||
it('returns 500 by default when setting is absent', async () => {
|
it('returns 4096 by default when setting is absent', async () => {
|
||||||
repo.findByKey.mockResolvedValue(undefined);
|
repo.findByKey.mockResolvedValue(undefined);
|
||||||
expect(await service.getMaxUploadSizeMb()).toBe(500);
|
expect(await service.getMaxUploadSizeMb()).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns parsed integer value when valid', async () => {
|
it('returns parsed integer value when valid', async () => {
|
||||||
@@ -630,10 +630,15 @@ describe('AppSettingsService', () => {
|
|||||||
expect(await service.getMaxUploadSizeMb()).toBe(1024);
|
expect(await service.getMaxUploadSizeMb()).toBe(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 when value is invalid or <= 0', async () => {
|
it('preserves an existing 500 MB limit', async () => {
|
||||||
repo.findByKey.mockResolvedValue({ key: 'max_upload_size_mb', value: '-50' } as never);
|
repo.findByKey.mockResolvedValue({ key: 'max_upload_size_mb', value: '500' } as never);
|
||||||
expect(await service.getMaxUploadSizeMb()).toBe(500);
|
expect(await service.getMaxUploadSizeMb()).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 4096 when value is invalid or <= 0', async () => {
|
||||||
|
repo.findByKey.mockResolvedValue({ key: 'max_upload_size_mb', value: '-50' } as never);
|
||||||
|
expect(await service.getMaxUploadSizeMb()).toBe(4096);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update max_upload_size_mb validation', () => {
|
describe('update max_upload_size_mb validation', () => {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe('SeedService', () => {
|
|||||||
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.ALLOW_REGISTRATION, value: 'false' });
|
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.ALLOW_REGISTRATION, value: 'false' });
|
||||||
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.OPDS_ENABLED, value: 'true' });
|
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.OPDS_ENABLED, value: 'true' });
|
||||||
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' });
|
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' });
|
||||||
|
expect(insertedRows).toContainEqual({ key: APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB, value: '4096' });
|
||||||
|
|
||||||
const oidcRow = insertedRows.find((row) => row.key === APP_SETTING_KEYS.OIDC_CONFIG);
|
const oidcRow = insertedRows.find((row) => row.key === APP_SETTING_KEYS.OIDC_CONFIG);
|
||||||
expect(oidcRow).toBeDefined();
|
expect(oidcRow).toBeDefined();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DEFAULT_AUTHOR_ENRICHMENT_CONFIG,
|
DEFAULT_AUTHOR_ENRICHMENT_CONFIG,
|
||||||
DEFAULT_OIDC_CONFIG,
|
DEFAULT_OIDC_CONFIG,
|
||||||
} from '../../common/constants/app-settings.constants';
|
} from '../../common/constants/app-settings.constants';
|
||||||
|
import { DEFAULT_MAX_UPLOAD_SIZE_MB } from '../../common/constants/upload.constants';
|
||||||
import { DB } from '../../db';
|
import { DB } from '../../db';
|
||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
import { getSanitizedErrorInfo } from './seed-log.util';
|
import { getSanitizedErrorInfo } from './seed-log.util';
|
||||||
@@ -41,7 +42,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
|
|||||||
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
|
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
|
||||||
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
|
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
|
||||||
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' },
|
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' },
|
||||||
{ key: APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB, value: '500' },
|
{ key: APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB, value: String(DEFAULT_MAX_UPLOAD_SIZE_MB) },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Reference in New Issue
Block a user