-
-
Notifications
You must be signed in to change notification settings - Fork 591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor app-simplefin.ts to make it easily testable #440
base: master
Are you sure you want to change the base?
Changes from 20 commits
8d4360b
44ed9bc
917d978
f356e17
0d37fd6
c445adf
2a3bdc3
de74065
fd8445b
8bc98de
4632ca1
d94d4fe
e8d04c1
ec4db13
4c2228c
d41ef51
17e18d5
1c19a69
dbbf1b6
a20957a
6a4f958
3470da0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,16 +5,24 @@ | |
"description": "actual syncing server", | ||
"type": "module", | ||
"scripts": { | ||
"start": "node app", | ||
"start": "tsx app", | ||
"docker:artifacts": "chmod +x docker/download-artifacts.sh && ./docker/download-artifacts.sh", | ||
"docker:build-common": "yarn docker:artifacts && docker build --build-arg GITHUB_TOKEN=$(gh auth token) -t actual-server-dev", | ||
"docker:build-edge": "yarn docker:build-common -f docker/edge-ubuntu.Dockerfile .", | ||
"docker:build-edge-alpine": "yarn docker:build-common -f docker/edge-alpine.Dockerfile .", | ||
"docker:build-stable": "yarn docker:build-common -f docker/stable-ubuntu.Dockerfile .", | ||
"docker:build-stable-alpine": "yarn docker:build-common -f docker/stable-alpine.Dockerfile .", | ||
"docker:run": "docker run --rm -p 5006:5006 actual-server-dev", | ||
"lint": "eslint . --max-warnings 0", | ||
"lint:fix": "eslint . --fix", | ||
"build": "tsc", | ||
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage", | ||
"test": "NODE_ENV=test NODE_NO_WARNINGS=1 NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest", | ||
"test:coverage": "NODE_ENV=test NODE_NO_WARNINGS=1 NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage", | ||
"db:migrate": "NODE_ENV=development node src/run-migrations.js up", | ||
"db:downgrade": "NODE_ENV=development node src/run-migrations.js down", | ||
"db:test-migrate": "NODE_ENV=test node src/run-migrations.js up", | ||
"db:test-downgrade": "NODE_ENV=test node src/run-migrations.js down", | ||
"types": "tsc --noEmit --incremental", | ||
"types": "tsc --emitDeclarationOnly --incremental", | ||
"verify": "yarn lint && yarn types", | ||
"reset-password": "node src/scripts/reset-password.js", | ||
"health-check": "node src/scripts/health-check.js" | ||
|
@@ -36,6 +44,7 @@ | |
"jws": "^4.0.0", | ||
"migrate": "^2.0.1", | ||
"nordigen-node": "^1.4.0", | ||
"tsx": "^4.17.0", | ||
"uuid": "^9.0.0", | ||
"winston": "^3.14.2" | ||
}, | ||
|
@@ -50,14 +59,14 @@ | |
"@types/node": "^17.0.45", | ||
"@types/supertest": "^2.0.12", | ||
"@types/uuid": "^9.0.0", | ||
"@typescript-eslint/eslint-plugin": "^5.51.0", | ||
"@typescript-eslint/parser": "^5.51.0", | ||
"@typescript-eslint/eslint-plugin": "^8.2", | ||
"@typescript-eslint/parser": "^8.2", | ||
"eslint": "^8.33.0", | ||
"eslint-plugin-prettier": "^4.2.1", | ||
"jest": "^29.3.1", | ||
"prettier": "^2.8.3", | ||
"supertest": "^6.3.1", | ||
"typescript": "^4.9.5" | ||
"typescript": "^5.5.4" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needed to update typescript to have certain flags in |
||
}, | ||
"engines": { | ||
"node": ">=18.0.0" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,21 @@ | ||
import express from 'express'; | ||
import { inspect } from 'util'; | ||
import https from 'https'; | ||
import { SecretName, secretsService } from '../services/secrets-service.js'; | ||
import { handleError } from '../app-gocardless/util/handle-error.js'; | ||
import { requestLoggerMiddleware } from '../util/middlewares.js'; | ||
import { SimpleFinService } from './services/simplefin-service.ts'; | ||
import { SimplefinApi } from './services/simplefin-api.ts'; | ||
import { HttpsClient } from './httpClient.ts'; | ||
|
||
const app = express(); | ||
export { app as handlers }; | ||
app.use(express.json()); | ||
app.use(requestLoggerMiddleware); | ||
|
||
const simplefinService = new SimpleFinService( | ||
new SimplefinApi(new HttpsClient()), | ||
); | ||
Comment on lines
+15
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. dependency injection, makes it easier to test There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to mock There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was able to test that, if the |
||
|
||
app.post( | ||
'/status', | ||
handleError(async (req, res) => { | ||
|
@@ -36,13 +42,14 @@ | |
if (token == null || token === 'Forbidden') { | ||
throw new Error('No token'); | ||
} else { | ||
accessKey = await getAccessKey(token); | ||
accessKey = await simplefinService.getAccessKey(token); | ||
secretsService.set(SecretName.simplefin_accessKey, accessKey); | ||
if (accessKey == null || accessKey === 'Forbidden') { | ||
throw new Error('No access key'); | ||
} | ||
} | ||
} | ||
//eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
} catch (error) { | ||
invalidToken(res); | ||
return; | ||
|
@@ -53,7 +60,11 @@ | |
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); | ||
|
||
try { | ||
const accounts = await getAccounts(accessKey, startDate, endDate); | ||
const accounts = await simplefinService.getAccounts( | ||
accessKey, | ||
startDate, | ||
endDate, | ||
); | ||
|
||
res.send({ | ||
status: 'ok', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've gone over the code in the frontend that uses the accounts from the server, and made sure that the variables used there had the same name as the ones in |
||
|
@@ -82,15 +93,17 @@ | |
|
||
let results; | ||
try { | ||
results = await getTransactions(accessKey, new Date(startDate)); | ||
results = await simplefinService.getTransactions( | ||
accessKey, | ||
new Date(startDate), | ||
); | ||
} catch (e) { | ||
serverDown(e, res); | ||
return; | ||
} | ||
|
||
try { | ||
const account = | ||
!results?.accounts || results.accounts.find((a) => a.id === accountId); | ||
const account = results.accounts.find((a) => a.id === accountId); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If no error is caught on line 100, |
||
if (!account) { | ||
console.log( | ||
`The account "${accountId}" was not found. Here were the accounts returned:`, | ||
|
@@ -131,8 +144,7 @@ | |
const response = {}; | ||
|
||
const balance = parseInt(account.balance.replace('.', '')); | ||
const date = new Date(account['balance-date'] * 1000) | ||
.toISOString() | ||
const date = account.balanceDate.toISOString() | ||
.split('T')[0]; | ||
|
||
response.balances = [ | ||
|
@@ -164,20 +176,20 @@ | |
|
||
let dateToUse = 0; | ||
|
||
if (trans.posted == 0) { | ||
if (trans.isPending()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
newTrans.booked = false; | ||
dateToUse = trans.transacted_at; | ||
dateToUse = trans.transacted_at.getTime() / 1000; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't want to change the logic further down in this function, so I just reverted the |
||
} else { | ||
newTrans.booked = true; | ||
dateToUse = trans.posted; | ||
dateToUse = trans.posted.getTime() / 1000; | ||
} | ||
|
||
newTrans.bookingDate = new Date(dateToUse * 1000) | ||
.toISOString() | ||
.split('T')[0]; | ||
|
||
newTrans.date = new Date(dateToUse * 1000).toISOString().split('T')[0]; | ||
newTrans.payeeName = trans.payee; | ||
// newTrans.payeeName = trans.payee; TODO: Is this used? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, seems this was changed not too long ago, so I guess the Simplefin docs are not quite up to date :/ |
||
newTrans.remittanceInformationUnstructured = trans.description; | ||
newTrans.transactionAmount = { amount: trans.amount, currency: 'USD' }; | ||
newTrans.transactionId = trans.id; | ||
|
@@ -240,106 +252,3 @@ | |
}, | ||
}); | ||
} | ||
|
||
function parseAccessKey(accessKey) { | ||
let scheme = null; | ||
let rest = null; | ||
let auth = null; | ||
let username = null; | ||
let password = null; | ||
let baseUrl = null; | ||
[scheme, rest] = accessKey.split('//'); | ||
[auth, rest] = rest.split('@'); | ||
[username, password] = auth.split(':'); | ||
baseUrl = `${scheme}//${rest}`; | ||
return { | ||
baseUrl: baseUrl, | ||
username: username, | ||
password: password, | ||
}; | ||
} | ||
|
||
async function getAccessKey(base64Token) { | ||
const token = Buffer.from(base64Token, 'base64').toString(); | ||
const options = { | ||
method: 'POST', | ||
port: 443, | ||
headers: { 'Content-Length': 0 }, | ||
}; | ||
return new Promise((resolve, reject) => { | ||
const req = https.request(new URL(token), options, (res) => { | ||
res.on('data', (d) => { | ||
resolve(d.toString()); | ||
}); | ||
}); | ||
req.on('error', (e) => { | ||
reject(e); | ||
}); | ||
req.end(); | ||
}); | ||
} | ||
|
||
async function getTransactions(accessKey, startDate, endDate) { | ||
const now = new Date(); | ||
startDate = startDate || new Date(now.getFullYear(), now.getMonth(), 1); | ||
endDate = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 1); | ||
console.log( | ||
`${startDate.toISOString().split('T')[0]} - ${ | ||
endDate.toISOString().split('T')[0] | ||
}`, | ||
); | ||
return await getAccounts(accessKey, startDate, endDate); | ||
} | ||
|
||
function normalizeDate(date) { | ||
return (date.valueOf() - date.getTimezoneOffset() * 60 * 1000) / 1000; | ||
} | ||
|
||
async function getAccounts(accessKey, startDate, endDate) { | ||
const sfin = parseAccessKey(accessKey); | ||
const options = { | ||
headers: { | ||
Authorization: `Basic ${Buffer.from( | ||
`${sfin.username}:${sfin.password}`, | ||
).toString('base64')}`, | ||
}, | ||
}; | ||
const params = []; | ||
let queryString = ''; | ||
if (startDate) { | ||
params.push(`start-date=${normalizeDate(startDate)}`); | ||
} | ||
if (endDate) { | ||
params.push(`end-date=${normalizeDate(endDate)}`); | ||
} | ||
|
||
params.push(`pending=1`); | ||
|
||
if (params.length > 0) { | ||
queryString += '?' + params.join('&'); | ||
} | ||
return new Promise((resolve, reject) => { | ||
const req = https.request( | ||
new URL(`${sfin.baseUrl}/accounts${queryString}`), | ||
options, | ||
(res) => { | ||
let data = ''; | ||
res.on('data', (d) => { | ||
data += d; | ||
}); | ||
res.on('end', () => { | ||
try { | ||
resolve(JSON.parse(data)); | ||
} catch (e) { | ||
console.log(`Error parsing JSON response: ${data}`); | ||
reject(e); | ||
} | ||
}); | ||
}, | ||
); | ||
req.on('error', (e) => { | ||
reject(e); | ||
}); | ||
req.end(); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export class GenericSimplefinError extends Error { | ||
details: object; | ||
constructor(data = {}) { | ||
super('GoCardless returned error'); | ||
this.details = data; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
export interface CustomRequestOptions { | ||
headers?: { [key: string]: string | number }; | ||
port: number; | ||
method: string; | ||
} | ||
|
||
export interface HttpClient { | ||
request(url: string, options: CustomRequestOptions): Promise<string>; | ||
} | ||
|
||
import https from 'https'; | ||
|
||
export class HttpsClient implements HttpClient { | ||
request(url: string, options: CustomRequestOptions): Promise<string> { | ||
return new Promise((resolve, reject) => { | ||
const req = https.request(new URL(url), options, (response) => { | ||
// reject on bad status | ||
if (response.statusCode < 200 || response.statusCode >= 300) { | ||
return reject( | ||
new Error(`${response.statusCode} ${response.statusMessage}`), | ||
); | ||
} | ||
let data = ''; | ||
response.on('data', (d: Buffer) => { | ||
data += d.toString(); | ||
}); | ||
response.on('end', () => { | ||
resolve(data); | ||
}); | ||
}); | ||
req.on('error', (e: Error) => { | ||
reject(e); | ||
}); | ||
req.end(); | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I built all the docker images and ran them, making sure the server ran and was accessible with
curl
.