Fix file retention after updates on macOS #417

This fixes issue #417 where autoupdate installer files were not deleted
on macOS, leading to accumulation of old installers.

Key changes:

- Store update files in application-specific directory
- Clear update files directory on every app launch

Other supporting changes:

- Refactor file system operations to be more testable and reusable
- Improve separation of concerns in directory management
- Enhance dependency injection for auto-update logic
- Fix async completion to support `await` operations
- Add additional logging and revise some log messages during updates
This commit is contained in:
undergroundwires
2024-10-07 17:33:47 +02:00
parent 4e06d543b3
commit 2f31bc7b06
44 changed files with 1484 additions and 590 deletions

View File

@@ -0,0 +1,294 @@
import { describe, it, expect } from 'vitest';
import type { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import type { FunctionKeys } from '@/TypeHelpers';
import { sequenceEqual } from '@/application/Common/Array';
import type { FileWriteErrorType } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
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 FileSystemOperationsStub();
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 FileSystemOperationsStub();
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 FileSystemOperationsStub();
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 FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath);
// act
await context.writeAndVerifyFile();
// assert
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'isFileAvailable');
expect(accessCalls).to.have.lengthOf(1);
const [actualFilePath] = accessCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
});
describe('content verification', () => {
it('reads from correct path', async () => {
// arrange
const expectedFilePath = 'expected-file-path.txt';
const fileSystemStub = new FileSystemOperationsStub();
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 FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
expect(fileReadCalls).to.have.lengthOf(1);
const [,actualEncoding] = fileReadCalls[0].args;
expect(actualEncoding).to.equal(expectedEncoding);
});
});
it('executes file system operations in correct sequence', async () => {
// arrange
const expectedOrder: ReadonlyArray<FunctionKeys<FileSystemOperations>> = [
'writeFile',
'isFileAvailable',
'readFile',
];
const fileSystemStub = new FileSystemOperationsStub();
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 FileSystemOperationsStub();
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'existence verification throws error',
expectedErrorType: 'FileExistenceVerificationFailed',
expectedErrorMessage: 'Access denied',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.isFileAvailable = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'existence verification returnf alse',
expectedErrorType: 'FileExistenceVerificationFailed',
expectedErrorMessage: 'File does not exist.',
buildFaultyContext: (setup) => {
const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.isFileAvailable = () => Promise.resolve(false);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'reading failure',
expectedErrorType: 'ReadVerificationFailed',
expectedErrorMessage: 'Read error',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOperationsStub();
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 FileSystemOperationsStub();
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: FileSystemOperations = new FileSystemOperationsStub();
private filePath = '/test/file/path.txt';
private fileContents = 'test file contents';
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
public withFileSystem(fileSystem: FileSystemOperations): this {
this.fileSystem = fileSystem;
return this;
}
public withFilePath(filePath: string): this {
this.filePath = filePath;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public writeAndVerifyFile(): ReturnType<NodeReadbackFileWriter['writeAndVerifyFile']> {
const writer = new NodeReadbackFileWriter(
this.logger,
this.fileSystem,
);
return writer.writeAndVerifyFile(
this.filePath,
this.fileContents,
);
}
}