This commit addresses issues #264 and #304, where users were not receiving error messages when script execution failed due to antivirus intervention, particularly with Microsoft Defender. Now, desktop app users will see a detailed error message with guidance on next steps if script saving or execution fails due to antivirus removal. Key changes: - Implement a check to detect failure in file writing, including reading the written file back. This method effectively detects antivirus interventions, as the read operation triggers an antivirus scan, leading to file deletion by the antivirus. - Introduce a specific error message for scenarios where an antivirus intervention is detected.
This commit is contained in:
@@ -119,7 +119,7 @@ describe('ProjectInformation', () => {
|
||||
});
|
||||
});
|
||||
describe('correct retrieval of download URL for every supported operating system', () => {
|
||||
const testCases: Record<SupportedOperatingSystem, {
|
||||
const testScenarios: Record<SupportedOperatingSystem, {
|
||||
readonly expected: string,
|
||||
readonly repositoryUrl: string,
|
||||
readonly version: string,
|
||||
@@ -143,7 +143,7 @@ describe('ProjectInformation', () => {
|
||||
AllSupportedOperatingSystems.forEach((operatingSystem) => {
|
||||
it(`should return the expected download URL for ${OperatingSystem[operatingSystem]}`, () => {
|
||||
// arrange
|
||||
const { expected, version, repositoryUrl } = testCases[operatingSystem];
|
||||
const { expected, version, repositoryUrl } = testScenarios[operatingSystem];
|
||||
const sut = new ProjectInformationBuilder()
|
||||
.withVersion(new VersionStub(version))
|
||||
.withRepositoryUrl(repositoryUrl)
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('PersistentDirectoryProvider', () => {
|
||||
expect(error.message).to.include(expectedErrorMessage);
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
it(`logs error: ${description}`, async () => {
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
|
||||
@@ -15,6 +15,8 @@ import { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/Script
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
|
||||
|
||||
describe('ScriptFileCreationOrchestrator', () => {
|
||||
describe('createScriptFile', () => {
|
||||
@@ -116,17 +118,16 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
describe('file writing', () => {
|
||||
it('writes to generated file path', async () => {
|
||||
// arrange
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileWriter = new ReadbackFileWriterStub();
|
||||
const context = new ScriptFileCreatorTestSetup()
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withFileSystem(filesystem));
|
||||
.withFileWriter(fileWriter);
|
||||
|
||||
// act
|
||||
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
|
||||
const calls = fileWriter.callHistory.filter((call) => call.methodName === 'writeAndVerifyFile');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualFilePath] = calls[0].args;
|
||||
expect(actualFilePath).to.equal(scriptFileAbsolutePath);
|
||||
@@ -134,23 +135,23 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
it('writes script content to file', async () => {
|
||||
// arrange
|
||||
const expectedCode = 'expected-code';
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileWriter = new ReadbackFileWriterStub();
|
||||
const context = new ScriptFileCreatorTestSetup()
|
||||
.withSystem(new SystemOperationsStub().withFileSystem(filesystem))
|
||||
.withFileWriter(fileWriter)
|
||||
.withFileContents(expectedCode);
|
||||
|
||||
// act
|
||||
await context.createScriptFile();
|
||||
|
||||
// assert
|
||||
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
|
||||
const calls = fileWriter.callHistory.filter((call) => call.methodName === 'writeAndVerifyFile');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [, actualData] = calls[0].args;
|
||||
expect(actualData).to.equal(expectedCode);
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
interface FileCreationFailureTestScenario {
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: CodeRunErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
@@ -160,7 +161,8 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
errorMessage: string,
|
||||
errorType: CodeRunErrorType,
|
||||
): ScriptFileCreatorTestSetup;
|
||||
}> = [
|
||||
}
|
||||
const testScenarios: readonly FileCreationFailureTestScenario[] = [
|
||||
{
|
||||
description: 'path combination failure',
|
||||
expectedErrorType: 'FilePathGenerationError',
|
||||
@@ -174,19 +176,31 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'file writing failure',
|
||||
...FileWriteOperationErrors.map((writeError): FileCreationFailureTestScenario => ({
|
||||
description: `file writing failure: ${writeError}`,
|
||||
expectedErrorType: 'FileWriteError',
|
||||
expectedErrorMessage: 'Error when writing to file',
|
||||
expectLogs: true,
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOpsStub();
|
||||
fileSystemStub.writeToFile = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
fileWriterStub.configureFailure(writeError, errorMessage);
|
||||
return setup.withFileWriter(fileWriterStub);
|
||||
},
|
||||
},
|
||||
})),
|
||||
...FileReadbackVerificationErrors.map(
|
||||
(verificationError): FileCreationFailureTestScenario => (
|
||||
{
|
||||
description: `file verification failure: ${verificationError}`,
|
||||
expectedErrorType: 'FileReadbackVerificationError',
|
||||
expectedErrorMessage: 'Error when verifying the file',
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
fileWriterStub.configureFailure(verificationError, errorMessage);
|
||||
return setup.withFileWriter(fileWriterStub);
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
description: 'filename generation failure',
|
||||
expectedErrorType: 'FilePathGenerationError',
|
||||
@@ -239,7 +253,7 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
if (expectLogs) {
|
||||
it(`logs error: ${description}`, async () => {
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
@@ -270,6 +284,8 @@ class ScriptFileCreatorTestSetup {
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
|
||||
|
||||
private fileContents = `[${ScriptFileCreatorTestSetup.name}] script file contents`;
|
||||
|
||||
private filenameParts: ScriptFilenameParts = {
|
||||
@@ -307,11 +323,17 @@ class ScriptFileCreatorTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileWriter(fileWriter: ReadbackFileWriter): this {
|
||||
this.fileWriter = fileWriter;
|
||||
return this;
|
||||
}
|
||||
|
||||
public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> {
|
||||
const creator = new ScriptFileCreationOrchestrator(
|
||||
this.system,
|
||||
this.filenameGenerator,
|
||||
this.directoryProvider,
|
||||
this.fileWriter,
|
||||
this.logger,
|
||||
);
|
||||
return creator.createScriptFile(this.fileContents, this.filenameParts);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOpera
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
|
||||
import { NodeFileOperationsStub } from './NodeFileOperationsStub';
|
||||
import { NodePathOperationsStub } from './NodePathOperationsStub';
|
||||
|
||||
describe('NodeElectronSaveFileDialog', () => {
|
||||
describe('dialog options', () => {
|
||||
@@ -53,7 +55,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withDefaultFilename(expectedFileName)
|
||||
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
||||
.withNode(new NodePathOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
||||
// act
|
||||
await context.saveFile();
|
||||
// assert
|
||||
@@ -118,16 +120,16 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled)
|
||||
.withUserSelectedFilePath(expectedFilePath);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
.withFileWriter(fileWriterStub);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = saveFileCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
@@ -138,17 +140,17 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
const isCancelled = false;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withFileContents(expectedFileContents)
|
||||
.withNode(nodeMock);
|
||||
.withFileWriter(fileWriterStub);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [,actualFileContents] = saveFileCalls[0].args;
|
||||
expect(actualFileContents).to.equal(expectedFileContents);
|
||||
@@ -171,16 +173,16 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
const isCancelled = true;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
.withFileWriter(fileWriterStub);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
|
||||
const saveFileCall = fileWriterStub.callHistory.find((c) => c.methodName === 'writeAndVerifyFile');
|
||||
expect(saveFileCall).to.equal(undefined);
|
||||
});
|
||||
it('logs cancelation info', async () => {
|
||||
@@ -219,32 +221,50 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
interface SaveFileFailureTestScenario {
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: SaveFileErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
readonly expectLogs: boolean,
|
||||
buildFaultyContext(
|
||||
setup: SaveFileDialogTestSetup,
|
||||
errorMessage: string,
|
||||
): SaveFileDialogTestSetup;
|
||||
}> = [
|
||||
{
|
||||
description: 'file writing failure',
|
||||
}
|
||||
const testScenarios: ReadonlyArray<SaveFileFailureTestScenario> = [
|
||||
...FileWriteOperationErrors.map((writeError): SaveFileFailureTestScenario => ({
|
||||
description: `file writing failure: ${writeError}`,
|
||||
expectedErrorType: 'FileCreationError',
|
||||
expectedErrorMessage: 'Error when writing file',
|
||||
expectedErrorMessage: 'Error when writing to file',
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
nodeMock.writeFile = () => Promise.reject(new Error(errorMessage));
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
fileWriterStub.configureFailure(writeError, errorMessage);
|
||||
return setup
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
.withFileWriter(fileWriterStub);
|
||||
},
|
||||
},
|
||||
})),
|
||||
...FileReadbackVerificationErrors.map((verificationError): SaveFileFailureTestScenario => ({
|
||||
description: `file verification failure: ${verificationError}`,
|
||||
expectedErrorType: 'FileReadbackVerificationError',
|
||||
expectedErrorMessage: 'Error when verifying the file',
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
const fileWriterStub = new ReadbackFileWriterStub();
|
||||
fileWriterStub.configureFailure(verificationError, errorMessage);
|
||||
return setup
|
||||
.withElectron(electronMock)
|
||||
.withFileWriter(fileWriterStub);
|
||||
},
|
||||
})),
|
||||
{
|
||||
description: 'user path retrieval failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when retrieving user path',
|
||||
expectLogs: true,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.getUserDownloadsPath = () => {
|
||||
@@ -258,8 +278,9 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
description: 'path combination failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when combining paths',
|
||||
expectLogs: true,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const nodeMock = new NodePathOperationsStub();
|
||||
nodeMock.join = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
@@ -271,6 +292,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
description: 'dialog display failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when showing save dialog',
|
||||
expectLogs: true,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage));
|
||||
@@ -282,6 +304,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
description: 'unexpected dialog return value failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.',
|
||||
expectLogs: true,
|
||||
buildFaultyContext: (setup) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.showSaveDialog = () => Promise.resolve({
|
||||
@@ -294,7 +317,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
|
||||
}) => {
|
||||
it(`handles error - ${description}`, async () => {
|
||||
// arrange
|
||||
@@ -312,21 +335,23 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
expect(error.message).to.include(expectedErrorMessage);
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
it(`logs error: ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
new SaveFileDialogTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
if (expectLogs) {
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
new SaveFileDialogTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -342,7 +367,9 @@ class SaveFileDialogTestSetup {
|
||||
|
||||
private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub();
|
||||
|
||||
private node: NodeFileOperations = new NodeFileOperationsStub();
|
||||
private node: NodeFileOperations = new NodePathOperationsStub();
|
||||
|
||||
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
|
||||
|
||||
public withElectron(electron: ElectronFileDialogOperations): this {
|
||||
this.electron = electron;
|
||||
@@ -354,6 +381,11 @@ class SaveFileDialogTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileWriter(fileWriter: ReadbackFileWriter): this {
|
||||
this.fileWriter = fileWriter;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
@@ -375,7 +407,12 @@ class SaveFileDialogTestSetup {
|
||||
}
|
||||
|
||||
public saveFile() {
|
||||
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node);
|
||||
const dialog = new NodeElectronSaveFileDialog(
|
||||
this.logger,
|
||||
this.electron,
|
||||
this.node,
|
||||
this.fileWriter,
|
||||
);
|
||||
return dialog.saveFile(
|
||||
this.fileContents,
|
||||
this.filename,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
|
||||
export class NodeFileOperationsStub
|
||||
extends StubWithObservableMethodCalls<NodeFileOperations>
|
||||
implements NodeFileOperations {
|
||||
private pathSegmentSeparator = `[${NodeFileOperationsStub.name} path segment separator]`;
|
||||
|
||||
public join(...paths: string[]): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'join',
|
||||
args: [...paths],
|
||||
});
|
||||
return paths.join(this.pathSegmentSeparator);
|
||||
}
|
||||
|
||||
public writeFile(file: string, data: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeFile',
|
||||
args: [file, data],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public withPathSegmentSeparator(pathSegmentSeparator: string): this {
|
||||
this.pathSegmentSeparator = pathSegmentSeparator;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NodeFileOperations as NodePathOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
|
||||
export class NodePathOperationsStub
|
||||
extends StubWithObservableMethodCalls<NodePathOperations>
|
||||
implements NodePathOperations {
|
||||
private pathSegmentSeparator = `[${NodePathOperationsStub.name} path segment separator]`;
|
||||
|
||||
public join(...paths: string[]): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'join',
|
||||
args: [...paths],
|
||||
});
|
||||
return paths.join(this.pathSegmentSeparator);
|
||||
}
|
||||
|
||||
public withPathSegmentSeparator(pathSegmentSeparator: string): this {
|
||||
this.pathSegmentSeparator = pathSegmentSeparator;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FileReadWriteOperations } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
|
||||
export class FileReadWriteOperationsStub
|
||||
extends StubWithObservableMethodCalls<FileReadWriteOperations>
|
||||
implements FileReadWriteOperations {
|
||||
private readonly writtenFiles: Record<string, string> = {};
|
||||
|
||||
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeFile',
|
||||
args: [filePath, fileContents, encoding],
|
||||
});
|
||||
this.writtenFiles[filePath] = fileContents;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
public access = (...args: Parameters<FileReadWriteOperations['access']>) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'access',
|
||||
args: [...args],
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'readFile',
|
||||
args: [filePath, encoding],
|
||||
});
|
||||
const fileContents = this.writtenFiles[filePath];
|
||||
return Promise.resolve(fileContents);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
import { sequenceEqual } from '@/application/Common/Array';
|
||||
import { FileWriteErrorType } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { FileReadWriteOperations, NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { FileReadWriteOperationsStub } from './FileReadWriteOperationsStub';
|
||||
|
||||
describe('NodeReadbackFileWriter', () => {
|
||||
describe('writeAndVerifyFile', () => {
|
||||
describe('successful write and verify operations', () => {
|
||||
it('confirms successful operation', async () => {
|
||||
// arrange
|
||||
const context = new NodeReadbackFileWriterTestSetup();
|
||||
|
||||
// act
|
||||
const { success } = await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(true);
|
||||
});
|
||||
describe('file write operations', () => {
|
||||
it('writes to specified path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test.txt';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = fileWriteCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('writes specified contents', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'expected file contents';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFileContents(expectedFileContents);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [,actualFileContents] = fileWriteCalls[0].args;
|
||||
expect(actualFileContents).to.equal(expectedFileContents);
|
||||
});
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [,,actualEncoding] = fileWriteCalls[0].args;
|
||||
expect(actualEncoding).to.equal(expectedEncoding);
|
||||
});
|
||||
});
|
||||
describe('existence verification', () => {
|
||||
it('checks correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test-file-path';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
|
||||
expect(accessCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = accessCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('uses correct mode', async () => {
|
||||
// arrange
|
||||
const expectedMode = constants.F_OK;
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
|
||||
expect(accessCalls).to.have.lengthOf(1);
|
||||
const [,actualMode] = accessCalls[0].args;
|
||||
expect(actualMode).to.equal(expectedMode);
|
||||
});
|
||||
});
|
||||
describe('content verification', () => {
|
||||
it('reads from correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path.txt';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
|
||||
expect(fileReadCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = fileReadCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
|
||||
expect(fileReadCalls).to.have.lengthOf(1);
|
||||
const [,actualEncoding] = fileReadCalls[0].args;
|
||||
expect(actualEncoding).to.equal(expectedEncoding);
|
||||
});
|
||||
});
|
||||
it('executes file system operations in correct sequence', async () => {
|
||||
// arrange
|
||||
const expectedOrder: ReadonlyArray<FunctionKeys<FileReadWriteOperations>> = [
|
||||
'writeFile',
|
||||
'access',
|
||||
'readFile',
|
||||
];
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const actualOrder = fileSystemStub.callHistory.map((c) => c.methodName);
|
||||
expect(sequenceEqual(expectedOrder, actualOrder)).to.equal(true);
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: FileWriteErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
buildFaultyContext(
|
||||
setup: NodeReadbackFileWriterTestSetup,
|
||||
errorMessage: string,
|
||||
): NodeReadbackFileWriterTestSetup;
|
||||
}> = [
|
||||
{
|
||||
description: 'writing failure',
|
||||
expectedErrorType: 'WriteOperationFailed',
|
||||
expectedErrorMessage: 'Error when writing file',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'existence verification error',
|
||||
expectedErrorType: 'FileExistenceVerificationFailed',
|
||||
expectedErrorMessage: 'Access denied',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
fileSystemStub.access = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'reading failure',
|
||||
expectedErrorType: 'ReadVerificationFailed',
|
||||
expectedErrorMessage: 'Read error',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'content match failure',
|
||||
expectedErrorType: 'ContentVerificationFailed',
|
||||
expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
|
||||
buildFaultyContext: (setup) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.resolve('different contents');
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||
}) => {
|
||||
it(`handles error - ${description}`, async () => {
|
||||
// arrange
|
||||
const context = buildFaultyContext(
|
||||
new NodeReadbackFileWriterTestSetup(),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
const { success, error } = await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(false);
|
||||
expectExists(error);
|
||||
expect(error.message).to.include(expectedErrorMessage);
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
new NodeReadbackFileWriterTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class NodeReadbackFileWriterTestSetup {
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private fileSystem: FileReadWriteOperations = new FileReadWriteOperationsStub();
|
||||
|
||||
private filePath = '/test/file/path.txt';
|
||||
|
||||
private fileContents = 'test file contents';
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileReadWriteOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileContents(fileContents: string): this {
|
||||
this.fileContents = fileContents;
|
||||
return this;
|
||||
}
|
||||
|
||||
public writeAndVerifyFile(): ReturnType<NodeReadbackFileWriter['writeAndVerifyFile']> {
|
||||
const writer = new NodeReadbackFileWriter(
|
||||
this.logger,
|
||||
this.fileSystem,
|
||||
);
|
||||
return writer.writeAndVerifyFile(
|
||||
this.filePath,
|
||||
this.fileContents,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,4 @@ export class FileSystemOpsStub
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public writeToFile(filePath: string, data: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeToFile',
|
||||
args: [filePath, data],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
29
tests/unit/shared/Stubs/ReadbackFileWriterStub.ts
Normal file
29
tests/unit/shared/Stubs/ReadbackFileWriterStub.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ReadbackFileWriterStub
|
||||
extends StubWithObservableMethodCalls<ReadbackFileWriter>
|
||||
implements ReadbackFileWriter {
|
||||
private outcome: FileWriteOutcome = { success: true };
|
||||
|
||||
public writeAndVerifyFile(
|
||||
...args: Parameters<ReadbackFileWriter['writeAndVerifyFile']>
|
||||
): Promise<FileWriteOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeAndVerifyFile',
|
||||
args: [...args],
|
||||
});
|
||||
return Promise.resolve(this.outcome);
|
||||
}
|
||||
|
||||
public configureFailure(errorType: FileWriteErrorType, message: string): this {
|
||||
this.outcome = {
|
||||
success: false,
|
||||
error: {
|
||||
type: errorType,
|
||||
message: `[${ReadbackFileWriterStub.name}] ${message}`,
|
||||
},
|
||||
};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user