diff --git a/CHANGELOG.md b/CHANGELOG.md index bab90f6..672b803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). failed this check stopped the run and sent every file already moved back. It is now listed as not moved, with whether it is still at its original location, and the other files stay moved. Three such failures in a row, with no confirmed move between them, still undo the run. +- **A move to another drive that loses its temporary copy no longer says the original was not + found.** Such a move copies the file to a temporary name in the destination folder and renames it + into place. When another program removed or held that copy, the failure read "File not found" with + the path of the original, which was still there. It now names the destination folder, says the + temporary copy was removed or could not be read or renamed, and says the original was not moved. + Three of these in a row, with no confirmed move between them, undo the run, as failed move checks + do. - **Files that are already in the folder chosen for them are pointed out before the run.** When a folder you sort from is also one of your Smart Folders, files matched to it stay where they are (or are renamed there). A notice on the Organize screen and a mark in Preview now say which, so a diff --git a/docs/commercial-use-inventory.json b/docs/commercial-use-inventory.json index 73f2659..51d5176 100644 --- a/docs/commercial-use-inventory.json +++ b/docs/commercial-use-inventory.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-11-12T23:23:23.866Z", + "generatedAt": "2026-11-13T01:20:54.483Z", "projectLicense": { "riskLevel": "allowed", "reason": "Project license is PolyForm Noncommercial 1.0.0 for personal/noncommercial use. Commercial rights are sold separately; third-party dependency obligations remain governed by their own licenses.", @@ -15113,7 +15113,7 @@ }, { "packageName": "hono", - "version": "4.13.14", + "version": "4.13.17", "license": "MIT", "riskLevel": "allowed", "directRuntime": false, diff --git a/package-lock.json b/package-lock.json index 6012765..d961fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21489,9 +21489,9 @@ } }, "node_modules/hono": { - "version": "4.13.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.14.tgz", - "integrity": "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==", + "version": "4.13.17", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz", + "integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/src/main/errors/FileSystemError.js b/src/main/errors/FileSystemError.js index e6a8d08..873c020 100644 --- a/src/main/errors/FileSystemError.js +++ b/src/main/errors/FileSystemError.js @@ -64,6 +64,10 @@ const FILE_SYSTEM_ERROR_CODES = { ATOMIC_OPERATION_FAILED: 'ATOMIC_OPERATION_FAILED', ROLLBACK_FAILED: 'ROLLBACK_FAILED', TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', + // A move between drives lost the temporary copy it makes in the destination + // folder (removed, or held, by something else) before renaming it into + // place. The source has not been touched at that point. + STAGED_COPY_FAILED: 'STAGED_COPY_FAILED', // I/O errors IO_ERROR: 'IO_ERROR', @@ -171,6 +175,7 @@ class FileSystemError extends Error { [FILE_SYSTEM_ERROR_CODES.ATOMIC_OPERATION_FAILED]: `Atomic operation failed: ${this.metadata.operation || 'unknown'}`, [FILE_SYSTEM_ERROR_CODES.ROLLBACK_FAILED]: 'Failed to rollback file operation', [FILE_SYSTEM_ERROR_CODES.TRANSACTION_TIMEOUT]: 'File operation transaction timed out', + [FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]: this.describeStagedCopyFailure(), [FILE_SYSTEM_ERROR_CODES.IO_ERROR]: `I/O error occurred: ${this.metadata.path || 'unknown'}`, [FILE_SYSTEM_ERROR_CODES.NETWORK_ERROR]: `Network error accessing file: ${this.metadata.path || 'unknown'}`, [FILE_SYSTEM_ERROR_CODES.UNKNOWN_ERROR]: `Unknown file system error: ${this.metadata.originalError || 'unknown'}` @@ -179,6 +184,21 @@ class FileSystemError extends Error { return messages[this.code] || `File system error: ${this.code}`; } + /** + * Message for STAGED_COPY_FAILED. `metadata.path` is the temporary copy, so + * `metadata.directory` is the destination folder; `metadata.source` is the + * file being moved, which is still where it was. + */ + describeStagedCopyFailure() { + const { directory, originalCode, source } = /** @type {Record} */ (this.metadata); + const where = directory ? `in ${directory}` : 'in the destination folder'; + const what = + originalCode === 'ENOENT' + ? 'was removed before the move finished' + : `could not be read or renamed${originalCode ? ` (${originalCode})` : ''}`; + return `The temporary copy ${where} ${what}. The original was not moved: ${source || 'unknown'}`; + } + /** * Get user-friendly error message suitable for display in UI */ @@ -244,6 +264,8 @@ class FileSystemError extends Error { 'Failed to undo the file operation. Some files may need manual recovery.', [FILE_SYSTEM_ERROR_CODES.TRANSACTION_TIMEOUT]: 'The operation took too long and was cancelled.', + [FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]: + 'The copy made in the destination folder was removed or blocked before the move finished. The original file was not moved.', [FILE_SYSTEM_ERROR_CODES.IO_ERROR]: 'A hardware or disk error occurred. Check your disk health.', [FILE_SYSTEM_ERROR_CODES.NETWORK_ERROR]: @@ -299,6 +321,11 @@ class FileSystemError extends Error { 'Check disk for errors', 'Verify the source file is not corrupted' ], + [FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]: [ + 'Try the move again', + 'Check whether a sync client or security tool removes or locks new files in the destination folder', + 'Choose a destination folder that other programs do not manage' + ], [FILE_SYSTEM_ERROR_CODES.IO_ERROR]: [ 'Check your disk health', 'Run disk check (chkdsk)', diff --git a/src/main/ipc/files/batchOrganizeHandler.js b/src/main/ipc/files/batchOrganizeHandler.js index c7243d9..f247b52 100644 --- a/src/main/ipc/files/batchOrganizeHandler.js +++ b/src/main/ipc/files/batchOrganizeHandler.js @@ -1536,7 +1536,12 @@ async function handleBatchOrganize(params) { * not bring it back; it only undid every move that had been confirmed. The * failed row says where the file is. * - * A run of FAILURE_ESCALATION_THRESHOLD of these two kinds of failure with no + * NOTE: STAGED_COPY_FAILED (a move between drives whose temporary copy in the + * destination folder was removed or held before it was renamed into place) is + * the same finding made one step earlier: the destination lost a file. The + * source was not touched, so it is a per-file failure too. + * + * A run of FAILURE_ESCALATION_THRESHOLD of these kinds of failure with no * verified move in between is still treated as systemic (permissions revoked * on the destination, or a destination that is losing files) and rolls the * batch back; see isEscalatingFileError and its use in handleBatchOrganize. @@ -1569,7 +1574,8 @@ const CRITICAL_MESSAGE_FRAGMENTS = ['checksum mismatch']; /** * Codes that are a per-file failure once and systemic in a run: a permission - * or access denial, and the move check's two findings. + * or access denial, the move check's two findings, and a cross-drive move's + * lost temporary copy. */ const ESCALATING_ERROR_CODES = new Set([ 'EACCES', @@ -1577,7 +1583,8 @@ const ESCALATING_ERROR_CODES = new Set([ 'FILE_ACCESS_DENIED', 'PERMISSION_DENIED', MOVE_CHECK_DESTINATION_MISSING, - MOVE_CHECK_SOURCE_REMAINS + MOVE_CHECK_SOURCE_REMAINS, + 'STAGED_COPY_FAILED' ]); /** diff --git a/src/renderer/utils/errorMapping.js b/src/renderer/utils/errorMapping.js index b63b33c..42b8a30 100644 --- a/src/renderer/utils/errorMapping.js +++ b/src/renderer/utils/errorMapping.js @@ -116,6 +116,16 @@ const FILE_OPERATION_CODE_MAP = { severity: 'error', message: 'The move could not be confirmed: the file is still at its original location.', useHandlerMessage: true + }, + // A move between drives (atomicFileOperations.js crossDeviceMove): the + // temporary copy in the destination folder was removed or held before it + // could be renamed into place. The message names that folder and the + // original, which was not moved, so it is shown as is. + STAGED_COPY_FAILED: { + severity: 'error', + message: + 'The copy made in the destination folder was removed or blocked before the move finished. The original file was not moved.', + useHandlerMessage: true } }; diff --git a/src/shared/atomicFileOperations.js b/src/shared/atomicFileOperations.js index b979512..7a1513d 100644 --- a/src/shared/atomicFileOperations.js +++ b/src/shared/atomicFileOperations.js @@ -98,7 +98,8 @@ try { SIZE_MISMATCH: 'SIZE_MISMATCH', CHECKSUM_MISMATCH: 'CHECKSUM_MISMATCH', FILE_NOT_FOUND: 'FILE_NOT_FOUND', - WRITE_FAILED: 'WRITE_FAILED' + WRITE_FAILED: 'WRITE_FAILED', + STAGED_COPY_FAILED: 'STAGED_COPY_FAILED' }; } @@ -1805,7 +1806,9 @@ AtomicFileOperations.prototype.safeWriteFile = function safeWriteFile(filePath, * nothing more is published or deleted: the source stays, the staging copy * is removed and the call rejects with an AbortError. * @returns {Promise} Resolves when move is complete - * @throws {Error} If copy fails or verification fails + * @throws {Error} If copy fails or verification fails. A failure on the + * staging copy is a FileSystemError with code STAGED_COPY_FAILED whose + * message says the source was not moved. */ async function crossDeviceMove(source, dest, options = {}) { const normalizedSource = resolveFsPath(source); @@ -1955,10 +1958,26 @@ async function crossDeviceMove(source, dest, options = {}) { if (error.isFileSystemError || error?.name === 'AbortError') { throw error; } - // EEXIST can only come from the publish onto the destination name, so - // name the occupied destination, not the source, in the error. + // Name the file the failure is about. Node reports the path a failed call + // was given, unchanged, as `error.path`. A failure on the staging copy + // (removed or held by a sync client or scanner between the copy and the + // publish) must not read as "source not found": the source is deleted + // only after the publish, so it is still in place. + if (error?.path === stagingPath) { + throw new FileSystemError(FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED, { + path: stagingPath, + source: normalizedSource, + destination: normalizedDest, + operation: 'crossDeviceMove', + originalError: error.message, + originalCode: error.code + }); + } + // The destination name: EEXIST can only come from the publish onto it, + // and a failed probe of it (renameNoReplace) carries it as `error.path`. + const aboutDestination = error?.code === 'EEXIST' || error?.path === normalizedDest; const fsError = FileSystemError.fromNodeError(error, { - path: error?.code === 'EEXIST' ? normalizedDest : normalizedSource, + path: aboutDestination ? normalizedDest : normalizedSource, operation: 'crossDeviceMove', destination: normalizedDest }); diff --git a/src/shared/constants.js b/src/shared/constants.js index 4b544a7..25e0979 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -735,6 +735,7 @@ const FILE_SYSTEM_ERROR_CODES = { ATOMIC_OPERATION_FAILED: 'ATOMIC_OPERATION_FAILED', ROLLBACK_FAILED: 'ROLLBACK_FAILED', TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', + STAGED_COPY_FAILED: 'STAGED_COPY_FAILED', IO_ERROR: 'IO_ERROR', NETWORK_ERROR: 'NETWORK_ERROR', UNKNOWN_ERROR: 'UNKNOWN_ERROR' diff --git a/test/FileSystemError.test.js b/test/FileSystemError.test.js index 38e3c9c..5b065b9 100644 --- a/test/FileSystemError.test.js +++ b/test/FileSystemError.test.js @@ -134,6 +134,35 @@ describe('FileSystemError', () => { }); }); + describe('STAGED_COPY_FAILED (the temporary copy of a move between drives)', () => { + const stagedCopy = (/** @type {Record} */ metadata) => + new FileSystemError(FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED, { + path: '/dest/Invoices/a.pdf.stratosort-1-abcd.tmp', + source: '/home/user/Downloads/a.pdf', + ...metadata + }); + + test('a removed copy names the destination folder and the original', () => { + expect(stagedCopy({ originalCode: 'ENOENT' }).message).toBe( + 'The temporary copy in /dest/Invoices was removed before the move finished. The original was not moved: /home/user/Downloads/a.pdf' + ); + }); + + test('any other failure names its code instead of calling the copy removed', () => { + expect(stagedCopy({ originalCode: 'EBUSY' }).message).toBe( + 'The temporary copy in /dest/Invoices could not be read or renamed (EBUSY). The original was not moved: /home/user/Downloads/a.pdf' + ); + }); + + test('does not read as a missing file', () => { + const error = stagedCopy({ originalCode: 'ENOENT' }); + + expect(error.message).not.toMatch(/not found/i); + expect(error.getUserFriendlyMessage()).toContain('The original file was not moved'); + expect(error.getActionableSteps()[0]).toBe('Try the move again'); + }); + }); + describe('isRecoverable', () => { test('returns false for corrupted file', () => { const error = new FileSystemError(FILE_SYSTEM_ERROR_CODES.CORRUPTED_FILE, {}); diff --git a/test/atomicFileOperations.crossDeviceCollision.test.js b/test/atomicFileOperations.crossDeviceCollision.test.js index 79112fe..618e46b 100644 --- a/test/atomicFileOperations.crossDeviceCollision.test.js +++ b/test/atomicFileOperations.crossDeviceCollision.test.js @@ -212,3 +212,174 @@ describe('cross-device move aborted by its caller', () => { await expectNothingMoved(); }); }); + +/** + * A cross-device move copies to a temporary name in the destination folder + * and renames that into place. When something else removes or holds the + * temporary copy (a sync client, a scanner), the failure is about that copy: + * the error must say so and say the source was not moved, not report the + * source as missing. + */ +describe('cross-device move losing its temporary copy', () => { + /** @type {string} */ + let root; + /** @type {string} */ + let source; + /** @type {string} */ + let destination; + + beforeEach(async () => { + root = await fsp.mkdtemp(path.join(realOs.tmpdir(), 'ss-exdev-staging-')); + source = path.join(root, 'vol1', 'scan.pdf'); + destination = path.join(root, 'vol2', 'scan.pdf'); + await fsp.mkdir(path.dirname(source), { recursive: true }); + await fsp.mkdir(path.dirname(destination), { recursive: true }); + await fsp.writeFile(source, 'MINE'); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await fsp.rm(root, { recursive: true, force: true }); + }); + + const failure = () => crossDeviceMove(source, destination).catch((e) => e); + + async function expectNothingMoved() { + expect(await fsp.readFile(source, 'utf8')).toBe('MINE'); + expect(await fsp.readdir(path.dirname(destination))).toEqual([]); + } + + const removedMessage = () => + `The temporary copy in ${path.dirname(destination)} was removed before the move finished. ` + + `The original was not moved: ${source}`; + + test('removed right after the copy: says so and that the original was not moved', async () => { + const realCopy = fsp.copyFile.bind(fsp); + jest.spyOn(fsp, 'copyFile').mockImplementation(async (from, to, mode) => { + await realCopy(from, to, mode); + await fsp.unlink(to); + }); + + const error = await failure(); + + expect(error).toMatchObject({ code: 'STAGED_COPY_FAILED', isFileSystemError: true }); + expect(error.message).toBe(removedMessage()); + expect(error.message).not.toMatch(/not found/i); + expect(error.metadata).toMatchObject({ + originalCode: 'ENOENT', + source, + destination, + directory: path.dirname(destination) + }); + await expectNothingMoved(); + }); + + test('removed while its checksum is read: same report', async () => { + const checksumFn = async (/** @type {string} */ file) => { + if (file.endsWith('.tmp')) await fsp.unlink(file); + return fsp.readFile(file, 'utf8'); + }; + + const error = await crossDeviceMove(source, destination, { checksumFn }).catch((e) => e); + + expect(error).toMatchObject({ code: 'STAGED_COPY_FAILED' }); + expect(error.message).toBe(removedMessage()); + await expectNothingMoved(); + }); + + test('removed between the check and the rename into place: same report', async () => { + const realRename = fsp.rename.bind(fsp); + jest.spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + await fsp.unlink(from); + return realRename(from, to); + }); + + const error = await failure(); + + expect(error).toMatchObject({ code: 'STAGED_COPY_FAILED' }); + expect(error.message).toBe(removedMessage()); + await expectNothingMoved(); + }); + + test('held by another program: names the code and cleans the copy up', async () => { + jest.spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + throw Object.assign( + new Error(`EPERM: operation not permitted, rename '${from}' -> '${to}'`), + { + code: 'EPERM', + syscall: 'rename', + path: from, + dest: to + } + ); + }); + + const error = await failure(); + + expect(error).toMatchObject({ code: 'STAGED_COPY_FAILED' }); + expect(error.metadata.originalCode).toBe('EPERM'); + expect(error.message).toBe( + `The temporary copy in ${path.dirname(destination)} could not be read or renamed (EPERM). ` + + `The original was not moved: ${source}` + ); + await expectNothingMoved(); + }); + + test('a destination name that cannot be probed is reported as the destination', async () => { + const realAccess = fsp.access.bind(fsp); + jest.spyOn(fsp, 'access').mockImplementation(async (target, mode) => { + if (String(target) === destination) { + throw Object.assign(new Error(`EACCES: permission denied, access '${target}'`), { + code: 'EACCES', + syscall: 'access', + path: target + }); + } + return realAccess(target, mode); + }); + + const error = await failure(); + + expect(error).toMatchObject({ code: 'FILE_ACCESS_DENIED' }); + expect(error.metadata.path).toBe(destination); + await expectNothingMoved(); + }); + + test('a source that really is missing is still reported as the source', async () => { + await fsp.unlink(source); + + const error = await failure(); + + expect(error).toMatchObject({ code: 'FILE_NOT_FOUND' }); + expect(error.metadata.path).toBe(source); + expect(await fsp.readdir(path.dirname(destination))).toEqual([]); + }); + + test('atomicMove reports it once instead of trying another name', async () => { + const ops = new AtomicFileOperations(); + const realRename = fsp.rename.bind(fsp); + jest.spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + if (String(from) === source) { + throw Object.assign(new Error('EXDEV: cross-device link not permitted'), { + code: 'EXDEV' + }); + } + return realRename(from, to); + }); + const realCopy = fsp.copyFile.bind(fsp); + const copySpy = jest.spyOn(fsp, 'copyFile').mockImplementation(async (from, to, mode) => { + await realCopy(from, to, mode); + await fsp.unlink(to); + }); + + try { + await expect(ops.atomicMove(source, destination)).rejects.toMatchObject({ + code: 'STAGED_COPY_FAILED' + }); + expect(copySpy).toHaveBeenCalledTimes(1); + await expectNothingMoved(); + } finally { + ops.shutdown(); + } + }); +}); diff --git a/test/batchOrganize.moveCheckFailsAlone.realfs.test.js b/test/batchOrganize.moveCheckFailsAlone.realfs.test.js index 95723b1..b79468b 100644 --- a/test/batchOrganize.moveCheckFailsAlone.realfs.test.js +++ b/test/batchOrganize.moveCheckFailsAlone.realfs.test.js @@ -67,6 +67,9 @@ const path = require('path'); const realOs = jest.requireActual('os'); const { handleBatchOrganize } = require('../src/main/ipc/files/batchOrganizeHandler'); +const sendOperationProgress = /** @type {jest.Mock} */ ( + require('../src/main/ipc/files/batchProgressReporter').sendOperationProgress +); const silentLog = { info() {}, warn() {}, error() {}, debug() {} }; @@ -247,3 +250,76 @@ describe('a file that is not where it should be after its move (real filesystem) expect(await fsp.readFile(moves[0].source, 'utf8')).toBe('DOC-1'); }); }); + +/** + * The same other program, one step earlier. A move to another drive copies + * the file to a temporary name in the destination folder and renames that + * into place; here the temporary copy is what gets taken. Input and Sorted + * share a drive in the temp dir, so the first rename is refused with EXDEV to + * send the handler down that path; everything after it is real. + * @param {(name: string) => boolean | Promise} takes - Whether the + * other program takes the temporary copy of the file with this name + */ +function betweenDrives(takes) { + const realRename = fsp.rename.bind(fsp); + jest.spyOn(fsp, 'rename').mockImplementation(async (from, to) => { + const folders = [from, to].map((file) => path.basename(path.dirname(String(file)))); + if (folders[0] === 'Input' && folders[1] === 'Sorted') { + throw Object.assign(new Error('EXDEV: cross-device link not permitted'), { code: 'EXDEV' }); + } + return realRename(from, to); + }); + const realCopy = fsp.copyFile.bind(fsp); + jest.spyOn(fsp, 'copyFile').mockImplementation(async (from, to, mode) => { + await realCopy(from, to, mode); + if (await takes(path.basename(String(from)))) await fsp.unlink(to); + }); +} + +describe('a temporary copy taken during a move to another drive (real filesystem)', () => { + test('the file fails alone, is reported as not moved, and the others stay moved', async () => { + const root = await makeRoot(); + const moves = await seedMoves(root, 5); + betweenDrives((name) => name === 'doc3.txt'); + + const result = await organize(moves); + + expect(result.rolledBack).toBeFalsy(); + expect(result.successCount).toBe(4); + expect(result.failCount).toBe(1); + expect(rowFor(result, 'doc3.txt')).toMatchObject({ + success: false, + errorCode: 'STAGED_COPY_FAILED', + critical: false + }); + expect(rowFor(result, 'doc3.txt').error).toBe( + `The temporary copy in ${path.join(root, 'Sorted')} was removed before the move finished. ` + + `The original was not moved: ${moves[2].source}` + ); + expect(await tree(root)).toEqual([ + 'Input/doc3.txt', + 'Sorted/doc1.txt', + 'Sorted/doc2.txt', + 'Sorted/doc4.txt', + 'Sorted/doc5.txt' + ]); + expect(await fsp.readFile(moves[2].source, 'utf8')).toBe('DOC-3'); + }); + + test('a destination that keeps taking them is rolled back, and the moved file returns', async () => { + const root = await makeRoot(); + const moves = await seedMoves(root, 8); + // The other program starts once the first file is confirmed, so the run + // has a move to undo whatever order the rest are tried in. + const firstConfirmed = new Promise((resolve) => { + sendOperationProgress.mockImplementationOnce(() => resolve(true)); + }); + betweenDrives((name) => name !== 'doc1.txt' && firstConfirmed); + + const result = await organize(moves); + + expect(result.rolledBack).toBe(true); + expect(await tree(root)).toEqual(moves.map((move) => `Input/${path.basename(move.source)}`)); + expect(await fsp.readFile(moves[0].source, 'utf8')).toBe('DOC-1'); + }); +}); diff --git a/test/constants.test.js b/test/constants.test.js index c7af6cc..5ef1437 100644 --- a/test/constants.test.js +++ b/test/constants.test.js @@ -149,6 +149,14 @@ describe('constants', () => { expect(constants.FILE_SYSTEM_ERROR_CODES.CHECKSUM_MISMATCH).toBeDefined(); expect(constants.FILE_SYSTEM_ERROR_CODES.SIZE_MISMATCH).toBeDefined(); }); + + // The main process keeps its own copy of this table next to the error + // class that uses it; a code added to one and not the other would reach + // the renderer without a mapping. + test('is the same table FileSystemError uses', () => { + const { FILE_SYSTEM_ERROR_CODES } = require('../src/main/errors/FileSystemError'); + expect(constants.FILE_SYSTEM_ERROR_CODES).toEqual(FILE_SYSTEM_ERROR_CODES); + }); }); describe('ACTION_TYPES', () => { diff --git a/test/errorMapping.fileOperations.test.js b/test/errorMapping.fileOperations.test.js index c7875d2..46cf502 100644 --- a/test/errorMapping.fileOperations.test.js +++ b/test/errorMapping.fileOperations.test.js @@ -127,6 +127,26 @@ describe('errorMapping: file operation codes', () => { }); }); + describe('a move between drives that lost its temporary copy', () => { + const HANDLER_MESSAGE = + 'The temporary copy in /dest/Invoices was removed before the move finished. The original was not moved: /home/user/Downloads/a.pdf'; + + test('shows which folder and that the original was not moved, not "File not found"', () => { + const mapped = mapErrorToNotification({ + error: HANDLER_MESSAGE, + errorCode: 'STAGED_COPY_FAILED' + }); + expect(mapped).toMatchObject({ severity: 'error', message: HANDLER_MESSAGE }); + }); + + test('without a handler message still says the original was not moved', () => { + expect(mapErrorToNotification({ errorCode: 'STAGED_COPY_FAILED' })).toMatchObject({ + severity: 'error', + message: expect.stringMatching(/The original file was not moved\.$/) + }); + }); + }); + test('an unknown code falls back to the handler message', () => { const mapped = mapErrorToNotification({ error: 'Invalid destination path: something odd',