[worker] add exact-file rclone deletion #168

Merged
eleith merged 1 commits from feat/unpublish into main 2026-10-29 20:51:54 +00:00
3 changed files with 123 additions and 0 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@luzzle/web.worker": patch
---
Add an exact-file remote deletion helper using the existing rclone runner,
logging and configured flags.
@@ -120,6 +120,100 @@ describe('RcloneClient flags passthrough', () => {
})
})
describe('RcloneClient deleteFile', () => {
const options = {
remote: 'cdn',
remotePath: 'published/books/key/cover #1.png',
configPath: '/config/rclone.conf',
}
beforeEach(() => vi.clearAllMocks())
it.each([{ flags: undefined }, { flags: [] }])(
'deletes one exact remote file with flags=$flags',
async ({ flags }) => {
const spawnMock = await mockSpawnSuccess()
const client = new RcloneClient(makeLogger())
await client.deleteFile({ ...options, flags })
expect(spawnMock).toHaveBeenCalledExactlyOnceWith(
'rclone',
[
'deletefile',
'cdn:published/books/key/cover #1.png',
'--config',
'/config/rclone.conf',
'--verbose',
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
}
)
it('passes configured flags through unchanged', async () => {
const spawnMock = await mockSpawnSuccess()
const logger = makeLogger()
const client = new RcloneClient(logger)
const flags = ['--s3-endpoint', 'https://objects.example.test', '--dry-run']
await client.deleteFile({ ...options, flags })
expect(spawnMock).toHaveBeenCalledExactlyOnceWith(
'rclone',
[
'deletefile',
'cdn:published/books/key/cover #1.png',
'--config',
'/config/rclone.conf',
'--verbose',
...flags,
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
expect(logger.info).toHaveBeenCalledWith('rclone deletefile starting', {
remote: 'cdn:published/books/key/cover #1.png',
flags,
})
})
it('reports a failed deletion without retrying', async () => {
const { spawn } = await import('child_process')
const child = new EventEmitter() as any
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
vi.mocked(spawn).mockReturnValue(child)
const logger = makeLogger()
const client = new RcloneClient(logger)
const deletion = client.deleteFile(options)
const rejection = expect(deletion).rejects.toThrow('rclone exited with code 1')
child.stderr.emit('data', Buffer.from('remote rejected deletion\n'))
child.emit('close', 1)
await rejection
expect(logger.stderr).toHaveBeenCalledWith('remote rejected deletion')
expect(spawn).toHaveBeenCalledTimes(1)
})
it('propagates a process error without retrying', async () => {
const { spawn } = await import('child_process')
const child = new EventEmitter() as any
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
vi.mocked(spawn).mockReturnValue(child)
const client = new RcloneClient(makeLogger())
const error = new Error('rclone is unavailable')
const deletion = client.deleteFile(options)
const rejection = expect(deletion).rejects.toBe(error)
child.emit('error', error)
await rejection
expect(spawn).toHaveBeenCalledTimes(1)
})
})
describe('RcloneClient checkConnectivity', () => {
beforeEach(() => vi.clearAllMocks())
@@ -27,6 +27,13 @@ export interface RcloneCopyOptions {
flags?: string[]
}
export interface RcloneDeleteFileOptions {
remote: string
remotePath: string
configPath: string
flags?: string[]
}
export interface RcloneCheckOptions {
remote: string
remotePath: string
@@ -124,6 +131,22 @@ export class RcloneClient {
await this.run('rclone', args)
}
async deleteFile(options: RcloneDeleteFileOptions): Promise<void> {
const target = `${options.remote}:${options.remotePath}`
const args = ['deletefile', target, '--config', options.configPath, '--verbose']
if (options.flags?.length) {
args.push(...options.flags)
}
this.logger.info('rclone deletefile starting', {
remote: target,
flags: options.flags,
})
await this.run('rclone', args)
}
checkConnectivity(options: RcloneCheckOptions): Promise<RcloneCheckResult> {
const { remote, remotePath, configPath, timeoutMs = 10000 } = options
const args = ['lsd', `${remote}:${remotePath}`, '--config', configPath, '--max-depth', '1']