From f03fc2409832ddf904bc6bd4e19274a8d40745dc Mon Sep 17 00:00:00 2001
From: undergroundwires
Date: Tue, 16 Jan 2024 22:26:28 +0100
Subject: [PATCH] Add AD detection on desktop app #264, #304
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.
---
2 | 31 ++
docs/tests.md | 4 +-
src/application/CodeRunner/CodeRunner.ts | 5 +-
.../ScriptFileCreationOrchestrator.ts | 23 +-
.../System/NodeElectronSystemOperations.ts | 6 +-
.../CodeRunner/System/SystemOperations.ts | 1 -
.../Electron/NodeElectronSaveFileDialog.ts | 34 +-
.../NodeReadbackFileWriter.ts | 120 +++++++
.../ReadbackFileWriter/ReadbackFileWriter.ts | 59 ++++
src/presentation/common/Dialog.ts | 1 +
.../Code/CodeButtons/CodeRunButton.vue | 52 ++-
.../Code/CodeButtons/Save/CodeSaveButton.vue | 44 ++-
.../Steps/Platforms/WindowsInstructions.vue | 6 +-
tests/unit/domain/ProjectInformation.spec.ts | 4 +-
.../PersistentDirectoryProvider.spec.ts | 2 +-
.../ScriptFileCreationOrchestrator.spec.ts | 60 ++--
.../NodeElectronSaveFileDialog.spec.ts | 111 ++++---
.../Dialog/Electron/NodeFileOperationsStub.ts | 29 --
.../Dialog/Electron/NodePathOperationsStub.ts | 21 ++
.../FileReadWriteOperationsStub.ts | 34 ++
.../NodeReadbackFileWriter.spec.ts | 299 ++++++++++++++++++
tests/unit/shared/Stubs/FileSystemOpsStub.ts | 8 -
.../shared/Stubs/ReadbackFileWriterStub.ts | 29 ++
23 files changed, 841 insertions(+), 142 deletions(-)
create mode 100644 2
create mode 100644 src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts
create mode 100644 src/infrastructure/ReadbackFileWriter/ReadbackFileWriter.ts
delete mode 100644 tests/unit/infrastructure/Dialog/Electron/NodeFileOperationsStub.ts
create mode 100644 tests/unit/infrastructure/Dialog/Electron/NodePathOperationsStub.ts
create mode 100644 tests/unit/infrastructure/ReadbackFileWriter/FileReadWriteOperationsStub.ts
create mode 100644 tests/unit/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.spec.ts
create mode 100644 tests/unit/shared/Stubs/ReadbackFileWriterStub.ts
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;
+ }
+}