203 lines
5.8 KiB
TypeScript
203 lines
5.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { Readable } from 'node:stream'
|
|
|
|
import {
|
|
manifestKeyFor,
|
|
parseBackupArgs,
|
|
pickLatestDumpKey,
|
|
resolveBackupStorageConfig,
|
|
runDownload,
|
|
runLatest,
|
|
runList,
|
|
runUpload,
|
|
} from './db-backup.js'
|
|
import type { StorageObjectSummary } from './services/file-storage/object-storage.js'
|
|
|
|
test('resolveBackupStorageConfig 优先 DB_BACKUP_OSS_* 并按字段回退 STORAGE_OSS_*', () => {
|
|
const config = resolveBackupStorageConfig({
|
|
DB_BACKUP_OSS_ENDPOINT: 'https://backup.example.com',
|
|
STORAGE_OSS_ENDPOINT: 'https://storage.example.com',
|
|
STORAGE_OSS_BUCKET: 'order-site',
|
|
STORAGE_OSS_ACCESS_KEY_ID: 'storage-key',
|
|
DB_BACKUP_OSS_ACCESS_KEY_ID: 'backup-key',
|
|
STORAGE_OSS_SECRET_ACCESS_KEY: 'storage-secret',
|
|
})
|
|
|
|
assert.equal(config.endpoint, 'https://backup.example.com')
|
|
assert.equal(config.bucket, 'order-site')
|
|
assert.equal(config.accessKeyId, 'backup-key')
|
|
assert.equal(config.secretAccessKey, 'storage-secret')
|
|
assert.equal(config.region, 'oss-cn-hangzhou')
|
|
})
|
|
|
|
test('resolveBackupStorageConfig 全部未配置时返回空值由调用方校验', () => {
|
|
const config = resolveBackupStorageConfig({})
|
|
assert.equal(config.endpoint, '')
|
|
assert.equal(config.bucket, '')
|
|
})
|
|
|
|
test('parseBackupArgs 解析命令与参数并去掉前缀尾部斜杠', () => {
|
|
const args = parseBackupArgs([
|
|
'upload',
|
|
'--key',
|
|
'backups/postgres/order_site-20260824-030000.dump',
|
|
'--prefix',
|
|
'backups/postgres/',
|
|
'--kind',
|
|
'app-configs',
|
|
'--size',
|
|
'1234',
|
|
])
|
|
|
|
assert.equal(args.command, 'upload')
|
|
assert.equal(args.key, 'backups/postgres/order_site-20260824-030000.dump')
|
|
assert.equal(args.prefix, 'backups/postgres')
|
|
assert.equal(args.kind, 'app-configs')
|
|
assert.equal(args.size, 1234)
|
|
})
|
|
|
|
test('parseBackupArgs 无参数时使用默认前缀与类型', () => {
|
|
const args = parseBackupArgs(['list'])
|
|
assert.equal(args.command, 'list')
|
|
assert.equal(args.prefix, 'backups/postgres')
|
|
assert.equal(args.kind, 'postgres-dump')
|
|
assert.equal(args.key, '')
|
|
assert.equal(args.allowUnverified, false)
|
|
})
|
|
|
|
test('parseBackupArgs 支持显式兼容未校验备份', () => {
|
|
assert.equal(parseBackupArgs(['latest', '--allow-unverified']).allowUnverified, true)
|
|
})
|
|
|
|
test('manifestKeyFor 在备份 key 后追加清单后缀', () => {
|
|
assert.equal(manifestKeyFor('backups/postgres/a.dump'), 'backups/postgres/a.dump.manifest.json')
|
|
})
|
|
|
|
test('pickLatestDumpKey 只认 dump 并取最近修改的对象', () => {
|
|
const key = pickLatestDumpKey([
|
|
{
|
|
key: 'backups/postgres/a.dump',
|
|
size: 1,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-01T00:00:00Z'),
|
|
},
|
|
{
|
|
key: 'backups/postgres/a.dump.manifest.json',
|
|
size: 1,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-23T00:00:00Z'),
|
|
},
|
|
{
|
|
key: 'backups/configs/a.tar.gz',
|
|
size: 1,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-23T00:00:00Z'),
|
|
},
|
|
{
|
|
key: 'backups/postgres/b.dump',
|
|
size: 1,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-22T00:00:00Z'),
|
|
},
|
|
])
|
|
|
|
assert.equal(key, 'backups/postgres/b.dump')
|
|
})
|
|
|
|
test('pickLatestDumpKey 无备份时返回 null,无时间时按 key 兜底', () => {
|
|
assert.equal(pickLatestDumpKey([]), null)
|
|
assert.equal(
|
|
pickLatestDumpKey([
|
|
{ key: 'backups/postgres/order_site-1.dump', size: 1, etag: '' },
|
|
{ key: 'backups/postgres/order_site-2.dump', size: 1, etag: '' },
|
|
]),
|
|
'backups/postgres/order_site-2.dump',
|
|
)
|
|
})
|
|
|
|
test('runUpload 支持流式输入并生成带长度和 sha256 的 manifest', async () => {
|
|
const uploads: Array<{ key: string; content: Buffer; contentLength?: number }> = []
|
|
const storage = {
|
|
putObject: async (input: {
|
|
key: string
|
|
content: Buffer | Readable
|
|
contentLength?: number
|
|
}) => {
|
|
const chunks: Buffer[] = []
|
|
if (Buffer.isBuffer(input.content)) {
|
|
chunks.push(input.content)
|
|
} else {
|
|
for await (const chunk of input.content) chunks.push(Buffer.from(chunk))
|
|
}
|
|
uploads.push({
|
|
key: input.key,
|
|
content: Buffer.concat(chunks),
|
|
contentLength: input.contentLength,
|
|
})
|
|
},
|
|
} as never
|
|
|
|
await runUpload(
|
|
storage,
|
|
parseBackupArgs([
|
|
'upload',
|
|
'--key',
|
|
'backups/configs/a.tar.gz',
|
|
'--kind',
|
|
'app-configs',
|
|
'--size',
|
|
'5',
|
|
]),
|
|
Readable.from([Buffer.from('hello')]),
|
|
)
|
|
|
|
assert.equal(uploads.length, 2)
|
|
assert.equal(uploads[0]?.content.toString(), 'hello')
|
|
assert.equal(uploads[0]?.contentLength, 5)
|
|
assert.match(uploads[1]?.content.toString() || '', /"size": 5/)
|
|
assert.match(uploads[1]?.content.toString() || '', /sha256/)
|
|
})
|
|
|
|
test('runLatest 跳过没有 manifest 的新对象并选择最近的有效备份', async () => {
|
|
const objects: StorageObjectSummary[] = [
|
|
{
|
|
key: 'backups/postgres/new.dump',
|
|
size: 5,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-23T03:00:00Z'),
|
|
},
|
|
{
|
|
key: 'backups/postgres/valid.dump',
|
|
size: 5,
|
|
etag: '',
|
|
lastModified: new Date('2026-08-22T03:00:00Z'),
|
|
},
|
|
]
|
|
const storage = {
|
|
listObjects: async () => objects,
|
|
getObject: async (key: string) => {
|
|
if (key.endsWith('new.dump.manifest.json')) {
|
|
const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
|
|
throw error
|
|
}
|
|
return {
|
|
reader: Readable.from([
|
|
JSON.stringify({
|
|
key: 'backups/postgres/valid.dump',
|
|
kind: 'postgres-dump',
|
|
size: 5,
|
|
sha256: 'a'.repeat(64),
|
|
createdAt: '2026-08-22T03:00:00Z',
|
|
}),
|
|
]),
|
|
}
|
|
},
|
|
} as never
|
|
|
|
assert.equal(
|
|
await runLatest(storage, parseBackupArgs(['latest', '--prefix', 'backups/postgres'])),
|
|
'backups/postgres/valid.dump',
|
|
)
|
|
})
|