Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-07-03 08:27:12 +00:00
2 changed files with 66 additions and 5 deletions
@@ -12,7 +12,7 @@ import { BackupManager, BackupSettings } from '../../managers/backup';
import { MockServer } from '@nocobase/test';
import path from 'path';
import { storagePathJoin } from '@nocobase/utils';
import { BACKUP_EXTENSION, METADATA_EXTENSION } from '../../utils';
import { BACKUP_EXTENSION, METADATA_EXTENSION, SETTINGS } from '../../utils';
import fs from 'fs';
import * as cp from 'child_process';
import PluginFileManagerServer from '@nocobase/plugin-file-manager';
@@ -126,6 +126,55 @@ describe('BackupManager', async () => {
expect(files).toContain(`${backupFileBaseName}.nbdata`);
});
it('should upload using configured cloud storage when backup options omit storageId', async () => {
const uploadFile = vi.fn().mockResolvedValue({ filename: 'cloud-backups/backup_for_unit_tests.nbdata' });
const storageRepository = {
findOne: vi.fn().mockResolvedValue({
id: 1,
title: 'Aliyun OSS',
type: 'ali-oss',
name: 'aliyun-oss',
baseUrl: '',
options: {},
}),
};
const getRepository = app.db.getRepository.bind(app.db);
const getRepositorySpy = vi.spyOn(app.db, 'getRepository').mockImplementation((name: string) => {
if (name === 'storages') {
return storageRepository as ReturnType<typeof app.db.getRepository>;
}
return getRepository(name);
});
const pmGetSpy = vi.spyOn(app.pm, 'get').mockReturnValue({
uploadFile,
} as unknown as PluginFileManagerServer);
const settingsRepository = app.db.getRepository(SETTINGS);
const settings = await settingsRepository.findOne();
await settingsRepository.update({
values: { storageId: 1 },
filterByTk: settings.get('id'),
});
const backupSettings = await settingsRepository.findOne();
const backupManager = new BackupManager(app, null, backupSettings);
try {
await backupManager.backup(backupFileBaseName, {
description: 'Manual backup from settings page',
});
expect(storageRepository.findOne).toHaveBeenCalledWith({
filterByTk: 1,
});
expect(uploadFile).toHaveBeenCalledWith({
filePath: finalBackupFilePath,
storageName: 'aliyun-oss',
});
} finally {
getRepositorySpy.mockRestore();
pmGetSpy.mockRestore();
}
});
it('should honor enableFilesBackup from backup options', async () => {
const backupManager = new BackupManager(app, null, defaultBackupSettings);
const fileCollection = {
@@ -36,7 +36,7 @@ import {
const BACKUP_METADATA_VERSION = 2;
export interface BackupSettings {
storageId?: string;
storageId?: string | number | null;
encryptionPassword: string;
enableFilesBackup: boolean;
keep?: number;
@@ -71,6 +71,10 @@ export interface BackupTaskResult {
inProgress: boolean;
}
type BackupSettingsInput = BackupSettings & {
toJSON?: () => Partial<BackupSettings>;
};
export class BackupManager {
app: Application;
ctx: ResourcerContext | null; // when triggered by cron job, ctx is null
@@ -83,10 +87,10 @@ export class BackupManager {
#uploadDir: string;
#aesKeyPath: string;
constructor(app: Application, ctx: ResourcerContext | null, settings: BackupSettings) {
constructor(app: Application, ctx: ResourcerContext | null, settings: BackupSettingsInput) {
this.app = app;
this.ctx = ctx;
this.#settings = settings;
this.#settings = this.#normalizeBackupSettings(settings);
this.#dbAdapter = getDBAdapter(app.db.options);
this.#backupTasksCacheName = BACKUP_TASKS_CACHE_NAME;
this.#backupPrefix = 'backup_';
@@ -116,6 +120,14 @@ export class BackupManager {
this.#backupTasksCacheName = backupTasksCacheName;
}
#normalizeBackupSettings(settings: BackupSettingsInput): BackupSettings {
if (typeof settings.toJSON === 'function') {
return settings.toJSON() as BackupSettings;
}
return { ...settings };
}
async createBackupName() {
await this.#dbAdapter.check('backup');
await fsPromises.mkdir(this.#backupDir, { recursive: true });
@@ -454,7 +466,7 @@ export class BackupManager {
return output;
}
async #uploadFiles(filePath: string, storageId?: string) {
async #uploadFiles(filePath: string, storageId?: BackupSettings['storageId']) {
if (!storageId) {
return;
}