diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..8367c5f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,46 @@ +name: frameworkTesting +run-name: Test Converters + +on: + push: + branches: + - main + - feature/* + pull_request: + branches: + - main + + +jobs: + run-converter-tests: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: '20' + + - run: npm install + + - run: npm run test + + - name: Convert code coverage results + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage/*.xml + badge: true + format: 'markdown' + output: 'file' + + - name: Add code coverage PR comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + path: code-coverage-results.md + + - name: Write to job summary + run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..1d9a50f --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from '@jest/types'; + +// Sync object +const config: Config.InitialOptions = { + verbose: true, + testTimeout: 30000, + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + coverageDirectory: 'coverage', + collectCoverageFrom: ['src/**/*.ts'], + coverageReporters: ['text', 'cobertura', 'html'] +}; + +export default config; diff --git a/package.json b/package.json index f2d3d1d..e3c8bcb 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { - "name": "trading212-to-ghostfolio", + "name": "export-to-ghostfolio", "version": "1.0.0", - "description": "Convert Trading 212 export to Ghostfolio import", + "description": "Convert multiple broker exports to Ghostfolio import", "main": "index.js", "scripts": { - "start": "nodemon" + "start": "nodemon", + "test": "jest --coverage" }, "author": "Dick Wolff", "repository": { @@ -12,8 +13,11 @@ }, "license": "Apache-2.0", "devDependencies": { + "@types/jest": "^29.5.11", "@types/node": "^20.10.4", + "jest": "^29.7.0", "nodemon": "^3.0.2", + "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "typescript": "^5.3.3" }, diff --git a/src/converters/abstractconverter.ts b/src/converters/abstractconverter.ts index 67bf054..cc93a52 100644 --- a/src/converters/abstractconverter.ts +++ b/src/converters/abstractconverter.ts @@ -1,3 +1,4 @@ +import * as fs from "fs"; import * as cliProgress from "cli-progress"; export abstract class AbstractConverter { @@ -14,21 +15,41 @@ export abstract class AbstractConverter { cliProgress.Presets.shades_classic); } + /** + * Read and process the file. + * + * @param inputFile The file to convert. + * @param successCallback A callback to execute after processing has succeeded. + * @param errorCallback A callback to execute after processing has failed. + */ + public readAndProcessFile(inputFile: string, successCallback: CallableFunction, errorCallback: CallableFunction) { + + // If the file does not exist, throw error. + if (!fs.existsSync(inputFile)) { + return errorCallback(new Error(`File ${inputFile} does not exist!`)); + } + + const contents = fs.readFileSync(inputFile, "utf-8"); + + this.processFileContents(contents,successCallback, errorCallback); + } + /** * Check if a record should be ignored from processing. * * @param record The record to check * @returns true if the record should be skipped, false otherwise. */ - abstract isIgnoredRecord(record: any): boolean; + abstract isIgnoredRecord(record: any): boolean; /** - * Process an export file. + * Process export file contents. * - * @param inputFile The file to convert. - * @param callback A callback to execute after processing has succeeded. + * @param input The file contents to convert. + * @param successCallback A callback to execute after processing has succeeded. + * @param errorCallback A callback to execute after processing has failed. */ - abstract processFile(inputFile: string, callback: any): void; + abstract processFileContents(input: string, successCallback: CallableFunction, errorCallback: CallableFunction): void; /** * Retrieve headers from the input file. diff --git a/src/converters/degiroConverter.test.ts b/src/converters/degiroConverter.test.ts new file mode 100644 index 0000000..08b1dfc --- /dev/null +++ b/src/converters/degiroConverter.test.ts @@ -0,0 +1,13 @@ +import { DeGiroConverter } from "./degiroConverter"; + +describe("degiroConverter", () => { + + it("should construct", () => { + + // Act + const sut = new DeGiroConverter(); + + // Asssert + expect(sut).toBeTruthy(); + }); +}); diff --git a/src/converters/degiroConverter.ts b/src/converters/degiroConverter.ts index 67b9e47..2f71f5b 100644 --- a/src/converters/degiroConverter.ts +++ b/src/converters/degiroConverter.ts @@ -1,4 +1,3 @@ -import * as fs from "fs"; import dayjs from "dayjs"; import { parse } from "csv-parse"; import { DeGiroRecord } from "../models/degiroRecord"; @@ -24,16 +23,13 @@ export class DeGiroConverter extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { - - // Read file contents of the CSV export. - const csvFile = fs.readFileSync(inputFile, "utf-8"); + public processFileContents(input: string, successCallback: any, errorCallback: any): void { // Parse the CSV and convert to Ghostfolio import format. - parse(csvFile, { + parse(input, { delimiter: ",", fromLine: 2, - columns: this.processHeaders(csvFile), + columns: this.processHeaders(input), cast: (columnValue, context) => { // Custom mapping below. @@ -41,8 +37,13 @@ export class DeGiroConverter extends AbstractConverter { return columnValue; } }, async (_, records: DeGiroRecord[]) => { + + // If records is empty, parsing failed.. + if (records === undefined) { + return errorCallback(new Error("An error ocurred while parsing!")); + } - console.log(`[i] Read CSV file ${inputFile}. Start processing..`); + console.log("[i] Read CSV file. Start processing.."); const result: GhostfolioExport = { meta: { date: new Date(), @@ -88,7 +89,8 @@ export class DeGiroConverter extends AbstractConverter { this.progress); } catch (err) { - throw err; + this.logQueryError(record.isin || record.product, idx); + return errorCallback(err); } // Log whenever there was no match found. @@ -232,7 +234,7 @@ export class DeGiroConverter extends AbstractConverter { this.progress.stop() - callback(result); + successCallback(result); }); } diff --git a/src/converters/degiroConverterV2.test.ts b/src/converters/degiroConverterV2.test.ts new file mode 100644 index 0000000..a034063 --- /dev/null +++ b/src/converters/degiroConverterV2.test.ts @@ -0,0 +1,13 @@ +import { DeGiroConverterV2 } from "./degiroConverterV2"; + +describe("degiroConverter", () => { + + it("should construct", () => { + + // Act + const sut = new DeGiroConverterV2(); + + // Asssert + expect(sut).toBeTruthy(); + }); +}); diff --git a/src/converters/degiroConverterV2.ts b/src/converters/degiroConverterV2.ts index 113ba8f..b27ee06 100644 --- a/src/converters/degiroConverterV2.ts +++ b/src/converters/degiroConverterV2.ts @@ -24,7 +24,7 @@ export class DeGiroConverterV2 extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { + public processFileContents(inputFile: string, successCallback: any, errorCallback: any): void { // Read file contents of the CSV export. const csvFile = fs.readFileSync(inputFile, "utf-8"); @@ -101,7 +101,7 @@ export class DeGiroConverterV2 extends AbstractConverter { } catch (err) { this.logQueryError(record.isin || record.product, idx); - throw err; + return errorCallback(err); } // Log whenever there was no match found. @@ -145,7 +145,7 @@ export class DeGiroConverterV2 extends AbstractConverter { this.progress.stop(); - callback(result); + successCallback(result); }); } diff --git a/src/converters/finpensionConverter.test.ts b/src/converters/finpensionConverter.test.ts new file mode 100644 index 0000000..1be624a --- /dev/null +++ b/src/converters/finpensionConverter.test.ts @@ -0,0 +1,13 @@ +import { FinpensionConverter } from "./finpensionConverter"; + +describe("finpensionConverter", () => { + + it("should construct", () => { + + // Act + const sut = new FinpensionConverter(); + + // Asssert + expect(sut).toBeTruthy(); + }); +}); diff --git a/src/converters/finpensionConverter.ts b/src/converters/finpensionConverter.ts index ee4a00c..2cfe1e4 100644 --- a/src/converters/finpensionConverter.ts +++ b/src/converters/finpensionConverter.ts @@ -1,4 +1,3 @@ -import * as fs from "fs"; import dayjs from "dayjs"; import { parse } from "csv-parse"; import { AbstractConverter } from "./abstractconverter"; @@ -21,16 +20,13 @@ export class FinpensionConverter extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { - - // Read file contents of the CSV export. - const csvFile = fs.readFileSync(inputFile, "utf-8"); + public processFileContents(input: string, successCallback: any, errorCallback: any): void { // Parse the CSV and convert to Ghostfolio import format. - const parser = parse(csvFile, { + const parser = parse(input, { delimiter: ";", fromLine: 2, - columns: this.processHeaders(csvFile, ";"), + columns: this.processHeaders(input, ";"), cast: (columnValue, context) => { // Custom mapping below. @@ -66,10 +62,10 @@ export class FinpensionConverter extends AbstractConverter { // If records is empty, parsing failed.. if (records === undefined) { - throw new Error(`An error ocurred while parsing ${inputFile}...`); + return errorCallback(new Error("An error ocurred while parsing!")); } - - console.log(`Read CSV file ${inputFile}. Start processing..`); + + console.log("[i] Read CSV file. Start processing.."); const result: GhostfolioExport = { meta: { date: new Date(), @@ -124,7 +120,7 @@ export class FinpensionConverter extends AbstractConverter { } catch (err) { this.logQueryError(record.isin || record.assetName, idx + 2); - throw err; + return errorCallback(err); } // Log whenever there was no match found. @@ -163,7 +159,7 @@ export class FinpensionConverter extends AbstractConverter { this.progress.stop() - callback(result); + successCallback(result); }); // Catch any error. diff --git a/src/converters/schwabConverter.ts b/src/converters/schwabConverter.ts index dff687d..6547efd 100644 --- a/src/converters/schwabConverter.ts +++ b/src/converters/schwabConverter.ts @@ -24,7 +24,7 @@ export class SchwabConverter extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { + public processFileContents(inputFile: string, successCallback: any, errorCallback: any): void { // Read file contents of the CSV export. const csvFile = fs.readFileSync(inputFile, "utf-8"); @@ -145,7 +145,7 @@ export class SchwabConverter extends AbstractConverter { } catch (err) { this.logQueryError(record.symbol || record.description, idx + 2); - throw err; + return errorCallback(err); } // Log whenever there was no match found. @@ -187,7 +187,7 @@ export class SchwabConverter extends AbstractConverter { this.progress.stop() - callback(result); + successCallback(result); }); // Catch any error. diff --git a/src/converters/swissquoteConverter.test.ts b/src/converters/swissquoteConverter.test.ts new file mode 100644 index 0000000..e967d67 --- /dev/null +++ b/src/converters/swissquoteConverter.test.ts @@ -0,0 +1,87 @@ +import { SwissquoteConverter } from "./swissquoteConverter"; +import { GhostfolioExport } from "../models/ghostfolioExport"; + +describe("swissquoteConverter", () => { + + it("should construct", () => { + + // Act + const sut = new SwissquoteConverter(); + + // Asssert + expect(sut).toBeTruthy(); + }); + + it("should process sample CSV file", (done) => { + + // Act + const sut = new SwissquoteConverter(); + const inputFile = "sample-swissquote-export.csv"; + + // Act + sut.readAndProcessFile(inputFile, (actualExport: GhostfolioExport) => { + + // Assert + expect(actualExport).toBeTruthy(); + + // Finish the test + done(); + }, () => { fail("Should not have an error!"); }); + }); + + describe("should throw an error if", () => { + it("the input file does not exist", (done) => { + + // Act + const sut = new SwissquoteConverter(); + + let tempFileName = "tmp/testinput/swissquote-filedoesnotexist.csv"; + + // Act + sut.readAndProcessFile(tempFileName, () => { fail("Should not succeed!"); }, (err: Error) => { + + // Assert + expect(err).toBeTruthy(); + done(); + }); + }); + + it("the input file is empty", (done) => { + + // Act + const sut = new SwissquoteConverter(); + + // Create temp file. + let tempFileContent = ""; + tempFileContent += "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency\n"; + + // Act + sut.processFileContents(tempFileContent, () => { fail("Should not succeed!"); }, (err: Error) => { + + // Assert + expect(err).toBeTruthy(); + expect(err.message).toContain("An error ocurred while parsing") + done(); + }); + }); + + it("Yahoo Finance got empty input for query", (done) => { + + // Act + const sut = new SwissquoteConverter(); + + // Create temp file. + let tempFileContent = ""; + tempFileContent += "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency\n"; + tempFileContent += "10-08-2022 15:30:02;113947121;Buy;;;;200.0;19.85;5.96;0.00;-3975.96;168660.08;USD"; + + // Act + sut.processFileContents(tempFileContent, () => { fail("Should not succeed!"); }, (err) => { + + // Assert + expect(err).toBeTruthy(); + done(); + }); + }); + }); +}); diff --git a/src/converters/swissquoteConverter.ts b/src/converters/swissquoteConverter.ts index 55453a3..3301673 100644 --- a/src/converters/swissquoteConverter.ts +++ b/src/converters/swissquoteConverter.ts @@ -1,4 +1,3 @@ -import * as fs from "fs"; import dayjs from "dayjs"; import { parse } from "csv-parse"; import { AbstractConverter } from "./abstractconverter"; @@ -24,16 +23,13 @@ export class SwissquoteConverter extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { - - // Read file contents of the CSV export. - const csvFile = fs.readFileSync(inputFile, "utf-8"); + public processFileContents(input: string, successCallback: any, errorCallback: any): void { // Parse the CSV and convert to Ghostfolio import format. - const parser = parse(csvFile, { + const parser = parse(input, { delimiter: ";", fromLine: 2, - columns: this.processHeaders(csvFile, ";"), + columns: this.processHeaders(input, ";"), cast: (columnValue, context) => { // Custom mapping below. @@ -73,11 +69,11 @@ export class SwissquoteConverter extends AbstractConverter { }, async (_, records: SwissquoteRecord[]) => { // If records is empty, parsing failed.. - if (records === undefined) { - throw new Error(`An error ocurred while parsing ${inputFile}...`); + if (records === undefined || records.length === 0) { + return errorCallback(new Error("An error ocurred while parsing!")); } - - console.log(`Read CSV file ${inputFile}. Start processing..`); + + console.log("Read CSV file. Start processing.."); const result: GhostfolioExport = { meta: { date: new Date(), @@ -88,10 +84,10 @@ export class SwissquoteConverter extends AbstractConverter { // Populate the progress bar. const bar1 = this.progress.create(records.length, 0); - + for (let idx = 0; idx < records.length; idx++) { const record = records[idx]; - + // Check if the record should be ignored. if (this.isIgnoredRecord(record)) { bar1.increment(); @@ -133,8 +129,8 @@ export class SwissquoteConverter extends AbstractConverter { this.progress); } catch (err) { - this.logQueryError(record.isin || record.symbol || record.name, idx + 2); - throw err; + this.logQueryError(record.isin || record.symbol || record.name, idx + 2); + return errorCallback(err); } // Log whenever there was no match found. @@ -165,14 +161,8 @@ export class SwissquoteConverter extends AbstractConverter { this.progress.stop() - callback(result); - }); - - // Catch any error. - parser.on('error', function (err) { - console.log("[i] An error ocurred while processing the input file! See error below:") - console.error("[e]", err.message); - }); + successCallback(result); + }); } /** diff --git a/src/converters/trading212Converter.test.ts b/src/converters/trading212Converter.test.ts new file mode 100644 index 0000000..c375877 --- /dev/null +++ b/src/converters/trading212Converter.test.ts @@ -0,0 +1,13 @@ +import { Trading212Converter } from "./trading212Converter"; + +describe("trading212Converter", () => { + + it("should construct", () => { + + // Act + const sut = new Trading212Converter(); + + // Asssert + expect(sut).toBeTruthy(); + }); +}); diff --git a/src/converters/trading212Converter.ts b/src/converters/trading212Converter.ts index ac53339..16ae9ae 100644 --- a/src/converters/trading212Converter.ts +++ b/src/converters/trading212Converter.ts @@ -1,4 +1,3 @@ -import * as fs from "fs"; import dayjs from "dayjs"; import { parse } from "csv-parse"; import { AbstractConverter } from "./abstractconverter"; @@ -21,16 +20,13 @@ export class Trading212Converter extends AbstractConverter { /** * @inheritdoc */ - public processFile(inputFile: string, callback: any): void { - - // Read file contents of the CSV export. - const csvFile = fs.readFileSync(inputFile, "utf-8"); + public processFileContents(input: string, successCallback: any, errorCallback: any): void { // Parse the CSV and convert to Ghostfolio import format. - parse(csvFile, { + parse(input, { delimiter: ",", fromLine: 2, - columns: this.processHeaders(csvFile), + columns: this.processHeaders(input), cast: (columnValue, context) => { // Custom mapping below. @@ -72,7 +68,12 @@ export class Trading212Converter extends AbstractConverter { } }, async (_, records: Trading212Record[]) => { - console.log(`[i] Read CSV file ${inputFile}. Start processing..`); + // If records is empty, parsing failed.. + if (records === undefined) { + return errorCallback(new Error("An error ocurred while parsing!")); + } + + console.log("[i] Read CSV file. Start processing.."); const result: GhostfolioExport = { meta: { date: new Date(), @@ -127,7 +128,7 @@ export class Trading212Converter extends AbstractConverter { } catch (err) { this.logQueryError(record.isin || record.ticker || record.name, idx + 2); - throw err; + return errorCallback(err); } // Log whenever there was no match found. @@ -156,7 +157,7 @@ export class Trading212Converter extends AbstractConverter { this.progress.stop() - callback(result); + successCallback(result); }); } diff --git a/src/index.ts b/src/index.ts index b64cf7d..f1da9c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,7 @@ switch (process.argv[2].toLocaleLowerCase()) { } // Map the file to a Ghostfolio import. -converter.processFile(inputFile, (result: GhostfolioExport) => { +converter.readAndProcessFile(inputFile, (result: GhostfolioExport) => { console.log("[i] Processing complete, writing to file..") @@ -64,4 +64,4 @@ converter.processFile(inputFile, (result: GhostfolioExport) => { fs.writeFileSync(`ghostfolio-${process.argv[2].toLocaleLowerCase()}.json`, fileContents, { encoding: "utf-8" }); console.log(`[i] Wrote data to 'ghostfolio-${process.argv[2].toLocaleLowerCase()}.json'!`); -}); +}, () => {}); diff --git a/src/testUtils.ts b/src/testUtils.ts new file mode 100644 index 0000000..18d0255 --- /dev/null +++ b/src/testUtils.ts @@ -0,0 +1,11 @@ +/* istanbul ignore */ + +import * as fs from "fs"; +import { GhostfolioExport } from "./models/ghostfolioExport"; + +export function getResultFile(fileName: string): GhostfolioExport { + + const contents = fs.readFileSync(fileName, "utf-8"); + + return JSON.parse(contents); +}