diff --git a/2 b/2
new file mode 100644
index 00000000..7d127335
--- /dev/null
+++ b/2
@@ -0,0 +1,31 @@
+Show error on AV removal on desktop $264, $304
+
+This solves $264 where users do not get error messages when running
+script file fails due to antivirus intervention (it being blocking the
+script file as soon as privacy.sexy generates it to run it). Now if the
+desktop app users tries to save or run a script file and it afils due to
+antivirus removal, they'll get a special error message with guiding next
+steps.
+
+- Add additional check to able to fail if the file writing fails. This
+ includes trying to reading the written file back as suggested in $304.
+ This successfully detects antivirus (Defender) intervation as read
+ file operation triggers the antivirus scan that deletes the file.
+- Show directory and file path in error messages as suggested in $304.
+- Show an error message with more detailed information if an antivirus
+ is detected.
+
+# Please enter the commit message for your changes. Lines starting
+# with '#' will be ignored, and an empty message aborts the commit.
+#
+# Date: Tue Jan 16 16:23:08 2024 +0100
+#
+# On branch master
+# Your branch is ahead of 'origin/master' by 1 commit.
+# (use "git push" to publish your local commits)
+#
+# Changes to be committed:
+# modified: ../../application/CodeRunner/CodeRunner.ts
+# new file: NodeReliableFileWriter.ts
+# new file: ReliableFileWriter.ts
+#
diff --git a/docs/tests.md b/docs/tests.md
index 2ab67659..5b67747f 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -29,7 +29,9 @@ There are different types of tests executed:
- Evaluate individual components in isolation.
- Located in [`./tests/unit`](./../tests/unit).
-- Achieve isolation using [stubs](./../tests/unit/shared/Stubs).
+- Achieve isolation using stubs where you place:
+ - Common stubs in [`./shared/Stubs`](./../tests/unit/shared/Stubs),
+ - Component-specific stubs in same folder as test file.
- Include Vue component tests, enabled by `@vue/test-utils`.
#### Unit tests naming
diff --git a/src/application/CodeRunner/CodeRunner.ts b/src/application/CodeRunner/CodeRunner.ts
index 40e41f7d..9010a793 100644
--- a/src/application/CodeRunner/CodeRunner.ts
+++ b/src/application/CodeRunner/CodeRunner.ts
@@ -5,16 +5,17 @@ export interface CodeRunner {
): Promise;
}
+export type CodeRunOutcome = SuccessfulCodeRun | FailedCodeRun;
+
export type CodeRunErrorType =
| 'FileWriteError'
+ | 'FileReadbackVerificationError'
| 'FilePathGenerationError'
| 'UnsupportedOperatingSystem'
| 'FileExecutionError'
| 'DirectoryCreationError'
| 'UnexpectedError';
-export type CodeRunOutcome = SuccessfulCodeRun | FailedCodeRun;
-
interface CodeRunStatus {
readonly success: boolean;
readonly error?: CodeRunError;
diff --git a/src/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.ts b/src/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.ts
index d8556ee5..abad6da6 100644
--- a/src/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.ts
+++ b/src/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.ts
@@ -1,6 +1,8 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
+import { FileReadbackVerificationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
+import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { SystemOperations } from '../System/SystemOperations';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
import { FilenameGenerator } from './Filename/FilenameGenerator';
@@ -14,6 +16,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
+ private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger,
) { }
@@ -65,17 +68,19 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
filePath: string,
contents: string,
): Promise {
- try {
- this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`);
- await this.system.fileSystem.writeToFile(filePath, contents);
- this.logger.info(`File created successfully at ${filePath}`);
+ const {
+ success, error,
+ } = await this.fileWriter.writeAndVerifyFile(filePath, contents);
+ if (success) {
return { success: true };
- } catch (error) {
- return {
- success: false,
- error: this.handleException(error, 'FileWriteError'),
- };
}
+ return {
+ success: false,
+ error: {
+ message: error.message,
+ type: FileReadbackVerificationErrors.find((e) => e === error.type) ? 'FileReadbackVerificationError' : 'FileWriteError',
+ },
+ };
}
private handleException(
diff --git a/src/infrastructure/CodeRunner/System/NodeElectronSystemOperations.ts b/src/infrastructure/CodeRunner/System/NodeElectronSystemOperations.ts
index b2078521..e3eea4c3 100644
--- a/src/infrastructure/CodeRunner/System/NodeElectronSystemOperations.ts
+++ b/src/infrastructure/CodeRunner/System/NodeElectronSystemOperations.ts
@@ -1,5 +1,5 @@
import { join } from 'node:path';
-import { chmod, mkdir, writeFile } from 'node:fs/promises';
+import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process';
import { app } from 'electron/main';
import {
@@ -46,10 +46,6 @@ export class NodeElectronSystemOperations implements SystemOperations {
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
- writeToFile: (
- filePath: string,
- data: string,
- ) => writeFile(filePath, data),
};
public readonly command: CommandOps = {
diff --git a/src/infrastructure/CodeRunner/System/SystemOperations.ts b/src/infrastructure/CodeRunner/System/SystemOperations.ts
index d8b026ed..42fd03e4 100644
--- a/src/infrastructure/CodeRunner/System/SystemOperations.ts
+++ b/src/infrastructure/CodeRunner/System/SystemOperations.ts
@@ -20,5 +20,4 @@ export interface CommandOps {
export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise;
- writeToFile(filePath: string, data: string): Promise;
}
diff --git a/src/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.ts b/src/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.ts
index fe4ca469..9ff98087 100644
--- a/src/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.ts
+++ b/src/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.ts
@@ -1,11 +1,12 @@
import { join } from 'node:path';
-import { writeFile } from 'node:fs/promises';
import { app, dialog } from 'electron/main';
import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import {
FileType, SaveFileError, SaveFileErrorType, SaveFileOutcome,
} from '@/presentation/common/Dialog';
+import { FileReadbackVerificationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
+import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
@@ -15,10 +16,8 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
getUserDownloadsPath: () => app.getPath('downloads'),
showSaveDialog: dialog.showSaveDialog.bind(dialog),
},
- private readonly node: NodeFileOperations = {
- join,
- writeFile,
- },
+ private readonly node: NodeFileOperations = { join },
+ private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
) { }
public async saveFile(
@@ -55,19 +54,19 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
filePath: string,
fileContents: string,
): Promise {
- try {
- this.logger.info(`Saving file: ${filePath}`);
- await this.node.writeFile(filePath, fileContents);
- this.logger.info(`File saved: ${filePath}`);
- return {
- success: true,
- };
- } catch (error) {
- return {
- success: false,
- error: this.handleException(error, 'FileCreationError'),
- };
+ const {
+ success, error,
+ } = await this.fileWriter.writeAndVerifyFile(filePath, fileContents);
+ if (success) {
+ return { success: true };
}
+ return {
+ success: false,
+ error: {
+ message: error.message,
+ type: FileReadbackVerificationErrors.find((e) => e === error.type) ? 'FileReadbackVerificationError' : 'FileCreationError',
+ },
+ };
}
private async showSaveFileDialog(
@@ -139,7 +138,6 @@ export interface ElectronFileDialogOperations {
export interface NodeFileOperations {
readonly join: typeof join;
- writeFile(file: string, data: string): Promise;
}
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
diff --git a/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts b/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts
new file mode 100644
index 00000000..e559502f
--- /dev/null
+++ b/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts
@@ -0,0 +1,120 @@
+import { writeFile, access, readFile } from 'node:fs/promises';
+import { constants } from 'node:fs';
+import { Logger } from '@/application/Common/Log/Logger';
+import { ElectronLogger } from '../Log/ElectronLogger';
+import {
+ FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
+ FileWriteOutcome, SuccessfulFileWrite,
+} from './ReadbackFileWriter';
+
+const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
+
+export class NodeReadbackFileWriter implements ReadbackFileWriter {
+ constructor(
+ private readonly logger: Logger = ElectronLogger,
+ private readonly fileSystem: FileReadWriteOperations = {
+ writeFile,
+ readFile: (path, encoding) => readFile(path, encoding),
+ access,
+ },
+ ) { }
+
+ public async writeAndVerifyFile(
+ filePath: string,
+ fileContents: string,
+ ): Promise {
+ this.logger.info(`Starting file write and verification process for: ${filePath}`);
+ const fileWritePipelineActions: ReadonlyArray<() => Promise> = [
+ () => this.createOrOverwriteFile(filePath, fileContents),
+ () => this.verifyFileExistsWithoutReading(filePath),
+ /*
+ Reading the file contents back, we can detect if the file has been altered or
+ removed post-creation. Removal of scripts when reading back is seen by some antivirus
+ software when it falsely identifies a script as harmful.
+ */
+ () => this.verifyFileContentsByReading(filePath, fileContents),
+ ];
+ for (const action of fileWritePipelineActions) {
+ const actionOutcome = await action(); // eslint-disable-line no-await-in-loop
+ if (!actionOutcome.success) {
+ return actionOutcome;
+ }
+ }
+ return this.reportSuccess(`File successfully written and verified: ${filePath}`);
+ }
+
+ private async createOrOverwriteFile(
+ filePath: string,
+ fileContents: string,
+ ): Promise {
+ try {
+ this.logger.info(`Creating file at ${filePath}, size: ${fileContents.length} characters`);
+ await this.fileSystem.writeFile(filePath, fileContents, FILE_ENCODING);
+ return this.reportSuccess('Created file.');
+ } catch (error) {
+ return this.reportFailure('WriteOperationFailed', error);
+ }
+ }
+
+ private async verifyFileExistsWithoutReading(
+ filePath: string,
+ ): Promise {
+ try {
+ await this.fileSystem.access(filePath, constants.F_OK);
+ return this.reportSuccess('Verified file existence without reading.');
+ } catch (error) {
+ return this.reportFailure('FileExistenceVerificationFailed', error);
+ }
+ }
+
+ private async verifyFileContentsByReading(
+ filePath: string,
+ expectedFileContents: string,
+ ): Promise {
+ try {
+ const actualFileContents = await this.fileSystem.readFile(filePath, FILE_ENCODING);
+ if (actualFileContents !== expectedFileContents) {
+ return this.reportFailure(
+ 'ContentVerificationFailed',
+ [
+ 'The contents of the written file do not match the expected contents.',
+ 'Written file contents do not match the expected file contents',
+ `File path: ${filePath}`,
+ `Expected total characters: ${actualFileContents.length}`,
+ `Actual total characters: ${expectedFileContents.length}`,
+ ].join('\n'),
+ );
+ }
+ return this.reportSuccess('Verified file content by reading.');
+ } catch (error) {
+ return this.reportFailure('ReadVerificationFailed', error);
+ }
+ }
+
+ private reportFailure(
+ errorType: FileWriteErrorType,
+ error: Error | string,
+ ): FailedFileWrite {
+ this.logger.error('Error saving file', errorType, error);
+ return {
+ success: false,
+ error: {
+ type: errorType,
+ message: typeof error === 'string' ? error : error.message,
+ },
+ };
+ }
+
+ private reportSuccess(successAction: string): SuccessfulFileWrite {
+ this.logger.info(`Successful file save: ${successAction}`);
+ return {
+ success: true,
+ };
+ }
+}
+
+export interface FileReadWriteOperations {
+ readonly writeFile: typeof writeFile;
+ readonly access: typeof access;
+ readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise;
+}
diff --git a/src/infrastructure/ReadbackFileWriter/ReadbackFileWriter.ts b/src/infrastructure/ReadbackFileWriter/ReadbackFileWriter.ts
new file mode 100644
index 00000000..d62a1b89
--- /dev/null
+++ b/src/infrastructure/ReadbackFileWriter/ReadbackFileWriter.ts
@@ -0,0 +1,59 @@
+/**
+ * It defines the contract for file writing operations with an added layer of
+ * verification. This approach is useful in environments where file write operations
+ * might be silently intercepted or manipulated by external factors, such as antivirus software.
+ *
+ * This additional verification provides a more reliable and transparent file writing
+ * process, enhancing the application's resilience against external disruptions and
+ * improving the overall user experience. It enables the application to notify users
+ * of potential issues, such as antivirus interventions, and offer guidance on how to
+ * resolve them.
+ */
+export interface ReadbackFileWriter {
+ writeAndVerifyFile(filePath: string, fileContents: string): Promise;
+}
+
+export type FileWriteOutcome = SuccessfulFileWrite | FailedFileWrite;
+
+export type FileWriteErrorType =
+ | UnionOfConstArray
+ | UnionOfConstArray;
+
+export const FileWriteOperationErrors = [
+ 'WriteOperationFailed',
+] as const;
+
+export const FileReadbackVerificationErrors = [
+ 'FileExistenceVerificationFailed',
+ 'ContentVerificationFailed',
+
+ /*
+ This error indicates a failure in verifying the contents of a written file.
+ This error often occurs when antivirus software falsely identifies a script as harmful and
+ either alters or removes it during the readback process. This verification step is crucial
+ for detecting and handling such antivirus interventions.
+ */
+ 'ReadVerificationFailed',
+] as const;
+
+interface FileWriteStatus {
+ readonly success: boolean;
+ readonly error?: FileWriteError;
+}
+
+export interface SuccessfulFileWrite extends FileWriteStatus {
+ readonly success: true;
+ readonly error?: undefined;
+}
+
+export interface FailedFileWrite extends FileWriteStatus {
+ readonly success: false;
+ readonly error: FileWriteError;
+}
+
+export interface FileWriteError {
+ readonly type: FileWriteErrorType;
+ readonly message: string;
+}
+
+type UnionOfConstArray> = T[number];
diff --git a/src/presentation/common/Dialog.ts b/src/presentation/common/Dialog.ts
index 21e4c0b5..2882c5b8 100644
--- a/src/presentation/common/Dialog.ts
+++ b/src/presentation/common/Dialog.ts
@@ -32,4 +32,5 @@ export interface SaveFileError {
export type SaveFileErrorType =
| 'FileCreationError'
+ | 'FileReadbackVerificationError'
| 'DialogDisplayError';
diff --git a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue
index 90540f90..6ee908c3 100644
--- a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue
+++ b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue
@@ -12,6 +12,7 @@ import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Dialog } from '@/presentation/common/Dialog';
+import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
import IconButton from './IconButton.vue';
export default defineComponent({
@@ -37,7 +38,7 @@ export default defineComponent({
currentContext.state.collection.scripting.fileExtension,
);
if (!success) {
- showScriptRunError(dialog, `${error.type}: ${error.message}`);
+ showScriptRunError(dialog, error);
}
}
@@ -57,8 +58,18 @@ function getCanRunState(
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
}
-function showScriptRunError(dialog: Dialog, technicalDetails: string) {
+function showScriptRunError(dialog: Dialog, error: CodeRunError) {
+ const technicalDetails = `[${error.type}] ${error.message}`;
dialog.showError(
+ ...(
+ error.type === 'FileReadbackVerificationError'
+ ? createAntivirusErrorDialog(technicalDetails)
+ : createGenericErrorDialog(technicalDetails)),
+ );
+}
+
+function createGenericErrorDialog(technicalDetails: string): Parameters
These false positives are common for scripts that modify system settings.
+ privacy.sexy is secure, transparent, and open-source.
To handle false warnings in Microsoft Defender:
- - Open Virus & threat protection from the Start menu.
+ -
+ Open Virus & threat protection from
+ the Start menu.
+
-
Locate the event in Protection history
that pertains to the script.
diff --git a/tests/unit/domain/ProjectInformation.spec.ts b/tests/unit/domain/ProjectInformation.spec.ts
index 22132186..ee1b678e 100644
--- a/tests/unit/domain/ProjectInformation.spec.ts
+++ b/tests/unit/domain/ProjectInformation.spec.ts
@@ -119,7 +119,7 @@ describe('ProjectInformation', () => {
});
});
describe('correct retrieval of download URL for every supported operating system', () => {
- const testCases: Record {
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)
diff --git a/tests/unit/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider.spec.ts b/tests/unit/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider.spec.ts
index ff5d9655..c8306285 100644
--- a/tests/unit/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider.spec.ts
+++ b/tests/unit/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider.spec.ts
@@ -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(
diff --git a/tests/unit/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.spec.ts b/tests/unit/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.spec.ts
index e79c414e..cede3a93 100644
--- a/tests/unit/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.spec.ts
+++ b/tests/unit/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator.spec.ts
@@ -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 {
const creator = new ScriptFileCreationOrchestrator(
this.system,
this.filenameGenerator,
this.directoryProvider,
+ this.fileWriter,
this.logger,
);
return creator.createScriptFile(this.fileContents, this.filenameParts);
diff --git a/tests/unit/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.spec.ts b/tests/unit/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.spec.ts
index 01d843a8..da05b89c 100644
--- a/tests/unit/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.spec.ts
+++ b/tests/unit/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.spec.ts
@@ -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 = [
+ ...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,
diff --git a/tests/unit/infrastructure/Dialog/Electron/NodeFileOperationsStub.ts b/tests/unit/infrastructure/Dialog/Electron/NodeFileOperationsStub.ts
deleted file mode 100644
index 264b2911..00000000
--- a/tests/unit/infrastructure/Dialog/Electron/NodeFileOperationsStub.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
-import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
-
-export class NodeFileOperationsStub
- extends StubWithObservableMethodCalls
- 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 {
- this.registerMethodCall({
- methodName: 'writeFile',
- args: [file, data],
- });
- return Promise.resolve();
- }
-
- public withPathSegmentSeparator(pathSegmentSeparator: string): this {
- this.pathSegmentSeparator = pathSegmentSeparator;
- return this;
- }
-}
diff --git a/tests/unit/infrastructure/Dialog/Electron/NodePathOperationsStub.ts b/tests/unit/infrastructure/Dialog/Electron/NodePathOperationsStub.ts
new file mode 100644
index 00000000..6e8ee7c4
--- /dev/null
+++ b/tests/unit/infrastructure/Dialog/Electron/NodePathOperationsStub.ts
@@ -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
+ 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;
+ }
+}
diff --git a/tests/unit/infrastructure/ReadbackFileWriter/FileReadWriteOperationsStub.ts b/tests/unit/infrastructure/ReadbackFileWriter/FileReadWriteOperationsStub.ts
new file mode 100644
index 00000000..43da8085
--- /dev/null
+++ b/tests/unit/infrastructure/ReadbackFileWriter/FileReadWriteOperationsStub.ts
@@ -0,0 +1,34 @@
+import { FileReadWriteOperations } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
+import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
+
+export class FileReadWriteOperationsStub
+ extends StubWithObservableMethodCalls
+ implements FileReadWriteOperations {
+ private readonly writtenFiles: Record = {};
+
+ 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) => {
+ 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);
+ };
+}
diff --git a/tests/unit/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.spec.ts b/tests/unit/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.spec.ts
new file mode 100644
index 00000000..7b370085
--- /dev/null
+++ b/tests/unit/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.spec.ts
@@ -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> = [
+ '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 {
+ const writer = new NodeReadbackFileWriter(
+ this.logger,
+ this.fileSystem,
+ );
+ return writer.writeAndVerifyFile(
+ this.filePath,
+ this.fileContents,
+ );
+ }
+}
diff --git a/tests/unit/shared/Stubs/FileSystemOpsStub.ts b/tests/unit/shared/Stubs/FileSystemOpsStub.ts
index e92ca056..8e617bfc 100644
--- a/tests/unit/shared/Stubs/FileSystemOpsStub.ts
+++ b/tests/unit/shared/Stubs/FileSystemOpsStub.ts
@@ -19,12 +19,4 @@ export class FileSystemOpsStub
});
return Promise.resolve();
}
-
- public writeToFile(filePath: string, data: string): Promise {
- this.registerMethodCall({
- methodName: 'writeToFile',
- args: [filePath, data],
- });
- return Promise.resolve();
- }
}
diff --git a/tests/unit/shared/Stubs/ReadbackFileWriterStub.ts b/tests/unit/shared/Stubs/ReadbackFileWriterStub.ts
new file mode 100644
index 00000000..b75a24f2
--- /dev/null
+++ b/tests/unit/shared/Stubs/ReadbackFileWriterStub.ts
@@ -0,0 +1,29 @@
+import { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
+import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
+
+export class ReadbackFileWriterStub
+ extends StubWithObservableMethodCalls
+ implements ReadbackFileWriter {
+ private outcome: FileWriteOutcome = { success: true };
+
+ public writeAndVerifyFile(
+ ...args: Parameters
+ ): Promise {
+ 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;
+ }
+}