@defra-fish/pocl-job 1.55.0 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -6
- package/src/__tests__/config.spec.js +0 -31
- package/src/__tests__/pocl-processor.spec.js +1 -1
- package/src/config.js +0 -71
- package/src/io/__tests__/s3.spec.js +0 -2
- package/src/io/s3.js +1 -1
- package/src/transport/__tests__/storeS3MetaData.spec.js +64 -0
- package/src/transport/storeS3MetaData.js +20 -0
- package/src/__mocks__/ssh2-sftp-client.js +0 -11
- package/src/transport/__tests__/ftp-to-s3.spec.js +0 -145
- package/src/transport/ftp-to-s3.js +0 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/pocl-job",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.0",
|
|
4
4
|
"description": "Post Office Counter Licence sales processor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,16 +35,15 @@
|
|
|
35
35
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@defra-fish/business-rules-lib": "1.
|
|
39
|
-
"@defra-fish/connectors-lib": "1.
|
|
38
|
+
"@defra-fish/business-rules-lib": "1.56.0",
|
|
39
|
+
"@defra-fish/connectors-lib": "1.56.0",
|
|
40
40
|
"commander": "^7.2.0",
|
|
41
41
|
"debug": "^4.3.3",
|
|
42
42
|
"filesize": "^6.4.0",
|
|
43
43
|
"md5-file": "^5.0.0",
|
|
44
44
|
"moment": "^2.29.1",
|
|
45
45
|
"moment-timezone": "^0.5.34",
|
|
46
|
-
"sax-stream": "^1.3.0"
|
|
47
|
-
"ssh2-sftp-client": "^6.0.1"
|
|
46
|
+
"sax-stream": "^1.3.0"
|
|
48
47
|
},
|
|
49
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "4edcdd349b077ad0bdb3e7b4029df3aba7c04b49"
|
|
50
49
|
}
|
|
@@ -11,11 +11,6 @@ describe('config', () => {
|
|
|
11
11
|
process.env.POCL_RECORD_STAGING_TABLE = 'test-record-staging-table'
|
|
12
12
|
process.env.POCL_STAGING_TTL = 1234
|
|
13
13
|
|
|
14
|
-
process.env.POCL_FTP_HOST = 'test-host'
|
|
15
|
-
process.env.POCL_FTP_PORT = 2222
|
|
16
|
-
process.env.POCL_FTP_PATH = '/remote/share'
|
|
17
|
-
process.env.POCL_FTP_USERNAME = 'test-user'
|
|
18
|
-
process.env.POCL_FTP_KEY_SECRET_ID = 'test-secret-id'
|
|
19
14
|
process.env.POCL_S3_BUCKET = 'test-bucket'
|
|
20
15
|
await config.initialise()
|
|
21
16
|
})
|
|
@@ -38,32 +33,6 @@ describe('config', () => {
|
|
|
38
33
|
})
|
|
39
34
|
})
|
|
40
35
|
|
|
41
|
-
describe('ftp', () => {
|
|
42
|
-
it('provides properties relating the use of SFTP', async () => {
|
|
43
|
-
expect(config.ftp).toEqual(
|
|
44
|
-
expect.objectContaining({
|
|
45
|
-
host: 'test-host',
|
|
46
|
-
port: '2222',
|
|
47
|
-
path: '/remote/share',
|
|
48
|
-
username: 'test-user',
|
|
49
|
-
privateKey: 'test-ssh-key',
|
|
50
|
-
algorithms: { cipher: expect.any(Array), kex: expect.any(Array) },
|
|
51
|
-
// Wait up to 60 seconds for the SSH handshake
|
|
52
|
-
readyTimeout: expect.any(Number),
|
|
53
|
-
// Retry 5 times over a minute
|
|
54
|
-
retries: expect.any(Number),
|
|
55
|
-
retry_minTimeout: expect.any(Number),
|
|
56
|
-
debug: expect.any(Function)
|
|
57
|
-
})
|
|
58
|
-
)
|
|
59
|
-
})
|
|
60
|
-
it('defaults the sftp port to 22 if the environment variable is not configured', async () => {
|
|
61
|
-
delete process.env.POCL_FTP_PORT
|
|
62
|
-
await config.initialise()
|
|
63
|
-
expect(config.ftp.port).toEqual('22')
|
|
64
|
-
})
|
|
65
|
-
})
|
|
66
|
-
|
|
67
36
|
describe('s3', () => {
|
|
68
37
|
it('provides properties relating the use of Amazon S3', async () => {
|
|
69
38
|
expect(config.s3.bucket).toEqual('test-bucket')
|
|
@@ -41,7 +41,7 @@ jest.mock('../config.js', () => ({
|
|
|
41
41
|
bucket: 'testbucket'
|
|
42
42
|
}
|
|
43
43
|
}))
|
|
44
|
-
jest.mock('../transport/
|
|
44
|
+
jest.mock('../transport/storeS3MetaData.js')
|
|
45
45
|
jest.mock('../transport/s3-to-local.js')
|
|
46
46
|
jest.mock('../io/db.js')
|
|
47
47
|
jest.mock('../io/s3.js')
|
package/src/config.js
CHANGED
|
@@ -1,49 +1,5 @@
|
|
|
1
|
-
import { AWS } from '@defra-fish/connectors-lib'
|
|
2
|
-
import db from 'debug'
|
|
3
|
-
const { secretsManager } = AWS()
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Key exchange algorithms for public key authentication - in descending order of priority
|
|
7
|
-
* @type {string[]}
|
|
8
|
-
*/
|
|
9
|
-
export const SFTP_KEY_EXCHANGE_ALGORITHMS = [
|
|
10
|
-
'curve25519-sha256@libssh.org',
|
|
11
|
-
'curve25519-sha256',
|
|
12
|
-
'ecdh-sha2-nistp521',
|
|
13
|
-
'ecdh-sha2-nistp384',
|
|
14
|
-
'ecdh-sha2-nistp256',
|
|
15
|
-
'diffie-hellman-group-exchange-sha256',
|
|
16
|
-
'diffie-hellman-group14-sha256',
|
|
17
|
-
'diffie-hellman-group16-sha512',
|
|
18
|
-
'diffie-hellman-group18-sha512',
|
|
19
|
-
'diffie-hellman-group14-sha1',
|
|
20
|
-
'diffie-hellman-group-exchange-sha1',
|
|
21
|
-
'diffie-hellman-group1-sha1'
|
|
22
|
-
]
|
|
23
|
-
/**
|
|
24
|
-
* Ciphers for SFTP support - in descending order of priority
|
|
25
|
-
* @type {string[]}
|
|
26
|
-
*/
|
|
27
|
-
export const SFTP_CIPHERS = [
|
|
28
|
-
// http://tools.ietf.org/html/rfc4344#section-4
|
|
29
|
-
'aes256-ctr',
|
|
30
|
-
'aes192-ctr',
|
|
31
|
-
'aes128-ctr',
|
|
32
|
-
'aes256-gcm',
|
|
33
|
-
'aes256-gcm@openssh.com',
|
|
34
|
-
'aes128-gcm',
|
|
35
|
-
'aes128-gcm@openssh.com',
|
|
36
|
-
'aes256-cbc',
|
|
37
|
-
'aes192-cbc',
|
|
38
|
-
'aes128-cbc',
|
|
39
|
-
'blowfish-cbc',
|
|
40
|
-
'3des-cbc',
|
|
41
|
-
'cast128-cbc'
|
|
42
|
-
]
|
|
43
|
-
|
|
44
1
|
class Config {
|
|
45
2
|
_db
|
|
46
|
-
_ftp
|
|
47
3
|
_s3
|
|
48
4
|
|
|
49
5
|
async initialise () {
|
|
@@ -53,21 +9,6 @@ class Config {
|
|
|
53
9
|
stagingTtlDelta: Number.parseInt(process.env.POCL_STAGING_TTL || 60 * 60 * 168)
|
|
54
10
|
}
|
|
55
11
|
|
|
56
|
-
this.ftp = {
|
|
57
|
-
host: process.env.POCL_FTP_HOST,
|
|
58
|
-
port: process.env.POCL_FTP_PORT || '22',
|
|
59
|
-
path: process.env.POCL_FTP_PATH,
|
|
60
|
-
username: process.env.POCL_FTP_USERNAME,
|
|
61
|
-
privateKey: (await secretsManager.getSecretValue({ SecretId: process.env.POCL_FTP_KEY_SECRET_ID }).promise()).SecretString,
|
|
62
|
-
algorithms: { cipher: SFTP_CIPHERS, kex: SFTP_KEY_EXCHANGE_ALGORITHMS },
|
|
63
|
-
// Wait up to 60 seconds for the SSH handshake
|
|
64
|
-
readyTimeout: 60000,
|
|
65
|
-
// Retry 5 times over a minute
|
|
66
|
-
retries: 5,
|
|
67
|
-
retry_minTimeout: 12000,
|
|
68
|
-
debug: db('pocl:ftp')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
12
|
this.s3 = {
|
|
72
13
|
bucket: process.env.POCL_S3_BUCKET
|
|
73
14
|
}
|
|
@@ -85,18 +26,6 @@ class Config {
|
|
|
85
26
|
this._db = cfg
|
|
86
27
|
}
|
|
87
28
|
|
|
88
|
-
/**
|
|
89
|
-
* FTP configuration settings
|
|
90
|
-
* @type {object}
|
|
91
|
-
*/
|
|
92
|
-
get ftp () {
|
|
93
|
-
return this._ftp
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
set ftp (cfg) {
|
|
97
|
-
this._ftp = cfg
|
|
98
|
-
}
|
|
99
|
-
|
|
100
29
|
/**
|
|
101
30
|
* S3 configuration settings
|
|
102
31
|
* @type {object}
|
|
@@ -5,7 +5,6 @@ import { DYNAMICS_IMPORT_STAGE, FILE_STAGE, POST_OFFICE_DATASOURCE } from '../..
|
|
|
5
5
|
import { salesApi } from '@defra-fish/connectors-lib'
|
|
6
6
|
import fs from 'fs'
|
|
7
7
|
import AwsMock from 'aws-sdk'
|
|
8
|
-
import { mockedFtpMethods } from 'ssh2-sftp-client'
|
|
9
8
|
|
|
10
9
|
jest.mock('fs')
|
|
11
10
|
jest.mock('md5-file')
|
|
@@ -159,7 +158,6 @@ describe('s3 operations', () => {
|
|
|
159
158
|
})
|
|
160
159
|
|
|
161
160
|
it('skips file processing if a file has already been marked as processed in Dynamics', async () => {
|
|
162
|
-
mockedFtpMethods.list.mockResolvedValue([{ name: 'test-already-processed.xml' }])
|
|
163
161
|
fs.createReadStream.mockReturnValueOnce('teststream')
|
|
164
162
|
fs.statSync.mockReturnValueOnce({ size: 1024 })
|
|
165
163
|
salesApi.getTransactionFile.mockResolvedValueOnce({ status: { description: 'Processed' } })
|
package/src/io/s3.js
CHANGED
|
@@ -2,7 +2,7 @@ import moment from 'moment'
|
|
|
2
2
|
import filesize from 'filesize'
|
|
3
3
|
import config from '../config.js'
|
|
4
4
|
import { DYNAMICS_IMPORT_STAGE } from '../staging/constants.js'
|
|
5
|
-
import { storeS3Metadata } from '../transport/
|
|
5
|
+
import { storeS3Metadata } from '../transport/storeS3MetaData.js'
|
|
6
6
|
import { AWS, salesApi } from '@defra-fish/connectors-lib'
|
|
7
7
|
const { s3 } = AWS()
|
|
8
8
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import moment from 'moment'
|
|
2
|
+
import { salesApi } from '@defra-fish/connectors-lib'
|
|
3
|
+
import { DYNAMICS_IMPORT_STAGE, FILE_STAGE, POST_OFFICE_DATASOURCE } from '../../staging/constants.js'
|
|
4
|
+
import { updateFileStagingTable } from '../../io/db.js'
|
|
5
|
+
import { storeS3Metadata } from '../storeS3MetaData.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../io/db.js', () => ({
|
|
8
|
+
updateFileStagingTable: jest.fn()
|
|
9
|
+
}))
|
|
10
|
+
jest.mock('@defra-fish/connectors-lib', () => ({
|
|
11
|
+
salesApi: {
|
|
12
|
+
upsertTransactionFile: jest.fn()
|
|
13
|
+
}
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('storeS3Metadata', () => {
|
|
17
|
+
const md5 = 'mockMd5Hash'
|
|
18
|
+
const fileSize = 12345
|
|
19
|
+
const filename = 'testfile'
|
|
20
|
+
const s3Key = 'mock/s3/key/testfile'
|
|
21
|
+
const receiptMoment = new Date('2024-10-17T00:00:00Z')
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('console log should output "Storing metadata for s3Key"', async () => {
|
|
28
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn())
|
|
29
|
+
await storeS3Metadata(md5, fileSize, filename, s3Key, receiptMoment)
|
|
30
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(`Storing metadata for ${s3Key}`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should call updateFileStagingTable with correct arguments', async () => {
|
|
34
|
+
await storeS3Metadata(md5, fileSize, filename, s3Key, receiptMoment)
|
|
35
|
+
expect(updateFileStagingTable).toHaveBeenCalledWith({
|
|
36
|
+
filename,
|
|
37
|
+
md5,
|
|
38
|
+
fileSize,
|
|
39
|
+
s3Key,
|
|
40
|
+
stage: FILE_STAGE.Pending
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should call salesApi.upsertTransactionFile with correct arguments', async () => {
|
|
45
|
+
const expectedSalesDate = moment(receiptMoment).subtract(1, 'days').toISOString()
|
|
46
|
+
const expectedReceiptTimestamp = receiptMoment.toISOString()
|
|
47
|
+
|
|
48
|
+
await storeS3Metadata(md5, fileSize, filename, s3Key, receiptMoment)
|
|
49
|
+
expect(salesApi.upsertTransactionFile).toHaveBeenCalledWith(filename, {
|
|
50
|
+
status: DYNAMICS_IMPORT_STAGE.Pending,
|
|
51
|
+
dataSource: POST_OFFICE_DATASOURCE,
|
|
52
|
+
fileSize: fileSize,
|
|
53
|
+
salesDate: expectedSalesDate,
|
|
54
|
+
receiptTimestamp: expectedReceiptTimestamp,
|
|
55
|
+
notes: 'Retrieved from the remote server and awaiting processing'
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('should log "Stored metadata for s3Key"', async () => {
|
|
60
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn())
|
|
61
|
+
await storeS3Metadata(md5, fileSize, filename, s3Key, receiptMoment)
|
|
62
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(`Stored metadata for ${s3Key}`)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import moment from 'moment'
|
|
2
|
+
import { salesApi } from '@defra-fish/connectors-lib'
|
|
3
|
+
import { DYNAMICS_IMPORT_STAGE, FILE_STAGE, POST_OFFICE_DATASOURCE } from '../staging/constants.js'
|
|
4
|
+
import { updateFileStagingTable } from '../io/db.js'
|
|
5
|
+
|
|
6
|
+
export async function storeS3Metadata (md5, fileSize, filename, s3Key, receiptMoment) {
|
|
7
|
+
console.log(`Storing metadata for ${s3Key}`)
|
|
8
|
+
await updateFileStagingTable({ filename, md5, fileSize, s3Key, stage: FILE_STAGE.Pending })
|
|
9
|
+
|
|
10
|
+
await salesApi.upsertTransactionFile(filename, {
|
|
11
|
+
status: DYNAMICS_IMPORT_STAGE.Pending,
|
|
12
|
+
dataSource: POST_OFFICE_DATASOURCE,
|
|
13
|
+
fileSize: fileSize,
|
|
14
|
+
salesDate: moment(receiptMoment).subtract(1, 'days').toISOString(),
|
|
15
|
+
receiptTimestamp: receiptMoment.toISOString(),
|
|
16
|
+
notes: 'Retrieved from the remote server and awaiting processing'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
console.log(`Stored metadata for ${s3Key}`)
|
|
20
|
+
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
const ssh2sftpClient = jest.genMockFromModule('ssh2-sftp-client')
|
|
2
|
-
|
|
3
|
-
export const mockedFtpMethods = {
|
|
4
|
-
connect: jest.fn(),
|
|
5
|
-
list: jest.fn(),
|
|
6
|
-
fastGet: jest.fn(),
|
|
7
|
-
delete: jest.fn(),
|
|
8
|
-
end: jest.fn()
|
|
9
|
-
}
|
|
10
|
-
ssh2sftpClient.mockImplementation(() => mockedFtpMethods)
|
|
11
|
-
export default ssh2sftpClient
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { ftpToS3 } from '../ftp-to-s3.js'
|
|
2
|
-
import moment from 'moment'
|
|
3
|
-
import { updateFileStagingTable } from '../../io/db.js'
|
|
4
|
-
import { getTempDir } from '../../io/file.js'
|
|
5
|
-
import { DYNAMICS_IMPORT_STAGE, FILE_STAGE, POST_OFFICE_DATASOURCE } from '../../staging/constants.js'
|
|
6
|
-
import { salesApi } from '@defra-fish/connectors-lib'
|
|
7
|
-
import fs from 'fs'
|
|
8
|
-
import md5File from 'md5-file'
|
|
9
|
-
import AwsMock from 'aws-sdk'
|
|
10
|
-
import { mockedFtpMethods } from 'ssh2-sftp-client'
|
|
11
|
-
|
|
12
|
-
jest.mock('fs')
|
|
13
|
-
jest.mock('md5-file')
|
|
14
|
-
jest.mock('../../io/db.js')
|
|
15
|
-
jest.mock('../../io/file.js')
|
|
16
|
-
|
|
17
|
-
jest.mock('@defra-fish/connectors-lib', () => {
|
|
18
|
-
const actual = jest.requireActual('@defra-fish/connectors-lib')
|
|
19
|
-
return {
|
|
20
|
-
AWS: actual.AWS,
|
|
21
|
-
salesApi: {
|
|
22
|
-
...Object.keys(actual.salesApi).reduce((acc, k) => ({ ...acc, [k]: jest.fn(async () => {}) }), {})
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
jest.mock('../../config.js', () => ({
|
|
28
|
-
ftp: {
|
|
29
|
-
path: '/ftpservershare/'
|
|
30
|
-
},
|
|
31
|
-
s3: {
|
|
32
|
-
bucket: 'testbucket'
|
|
33
|
-
}
|
|
34
|
-
}))
|
|
35
|
-
|
|
36
|
-
describe('ftp-to-s3', () => {
|
|
37
|
-
beforeAll(() => {
|
|
38
|
-
getTempDir.mockReturnValue('/local/tmp')
|
|
39
|
-
md5File.mockResolvedValue('example-md5')
|
|
40
|
-
})
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
jest.clearAllMocks()
|
|
43
|
-
AwsMock.__resetAll()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('retrieves files from SFTP and stores in S3', async () => {
|
|
47
|
-
mockedFtpMethods.list.mockResolvedValue([{ name: 'test1.xml' }, { name: 'test2.xml' }])
|
|
48
|
-
fs.createReadStream.mockReturnValueOnce('test1stream')
|
|
49
|
-
fs.createReadStream.mockReturnValueOnce('test2stream')
|
|
50
|
-
fs.statSync.mockReturnValueOnce({ size: 1024 })
|
|
51
|
-
fs.statSync.mockReturnValueOnce({ size: 2048 })
|
|
52
|
-
await ftpToS3()
|
|
53
|
-
|
|
54
|
-
const localPath1 = '/local/tmp/test1.xml'
|
|
55
|
-
const localPath2 = '/local/tmp/test2.xml'
|
|
56
|
-
|
|
57
|
-
const s3Key1 = `${moment().format('YYYY-MM-DD')}/test1.xml`
|
|
58
|
-
const s3Key2 = `${moment().format('YYYY-MM-DD')}/test2.xml`
|
|
59
|
-
|
|
60
|
-
expect(mockedFtpMethods.fastGet).toHaveBeenNthCalledWith(1, '/ftpservershare/test1.xml', localPath1, {})
|
|
61
|
-
expect(mockedFtpMethods.fastGet).toHaveBeenNthCalledWith(2, '/ftpservershare/test2.xml', localPath2, {})
|
|
62
|
-
expect(AwsMock.S3.mockedMethods.putObject).toHaveBeenNthCalledWith(1, {
|
|
63
|
-
Bucket: 'testbucket',
|
|
64
|
-
Key: s3Key1,
|
|
65
|
-
Body: 'test1stream'
|
|
66
|
-
})
|
|
67
|
-
expect(AwsMock.S3.mockedMethods.putObject).toHaveBeenNthCalledWith(2, {
|
|
68
|
-
Bucket: 'testbucket',
|
|
69
|
-
Key: s3Key2,
|
|
70
|
-
Body: 'test2stream'
|
|
71
|
-
})
|
|
72
|
-
expect(updateFileStagingTable).toHaveBeenNthCalledWith(1, {
|
|
73
|
-
filename: 'test1.xml',
|
|
74
|
-
md5: 'example-md5',
|
|
75
|
-
fileSize: '1 KB',
|
|
76
|
-
stage: FILE_STAGE.Pending,
|
|
77
|
-
s3Key: s3Key1
|
|
78
|
-
})
|
|
79
|
-
expect(updateFileStagingTable).toHaveBeenNthCalledWith(2, {
|
|
80
|
-
filename: 'test2.xml',
|
|
81
|
-
md5: 'example-md5',
|
|
82
|
-
fileSize: '2 KB',
|
|
83
|
-
stage: FILE_STAGE.Pending,
|
|
84
|
-
s3Key: s3Key2
|
|
85
|
-
})
|
|
86
|
-
expect(salesApi.upsertTransactionFile).toHaveBeenNthCalledWith(1, 'test1.xml', {
|
|
87
|
-
status: DYNAMICS_IMPORT_STAGE.Pending,
|
|
88
|
-
dataSource: POST_OFFICE_DATASOURCE,
|
|
89
|
-
fileSize: '1 KB',
|
|
90
|
-
receiptTimestamp: expect.any(String),
|
|
91
|
-
salesDate: expect.any(String),
|
|
92
|
-
notes: 'Retrieved from the remote server and awaiting processing'
|
|
93
|
-
})
|
|
94
|
-
expect(salesApi.upsertTransactionFile).toHaveBeenNthCalledWith(2, 'test2.xml', {
|
|
95
|
-
status: DYNAMICS_IMPORT_STAGE.Pending,
|
|
96
|
-
dataSource: POST_OFFICE_DATASOURCE,
|
|
97
|
-
fileSize: '2 KB',
|
|
98
|
-
receiptTimestamp: expect.any(String),
|
|
99
|
-
salesDate: expect.any(String),
|
|
100
|
-
notes: 'Retrieved from the remote server and awaiting processing'
|
|
101
|
-
})
|
|
102
|
-
expect(fs.unlinkSync).toHaveBeenNthCalledWith(1, localPath1)
|
|
103
|
-
expect(fs.unlinkSync).toHaveBeenNthCalledWith(2, localPath2)
|
|
104
|
-
expect(mockedFtpMethods.end).toHaveBeenCalledTimes(1)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('moves the file to s3 but skips file processing if a file has already been marked as processed in Dynamics', async () => {
|
|
108
|
-
mockedFtpMethods.list.mockResolvedValue([{ name: 'test-already-processed.xml' }])
|
|
109
|
-
fs.createReadStream.mockReturnValueOnce('teststream')
|
|
110
|
-
fs.statSync.mockReturnValueOnce({ size: 1024 })
|
|
111
|
-
salesApi.getTransactionFile.mockResolvedValueOnce({ status: { description: 'Processed' } })
|
|
112
|
-
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
|
113
|
-
await ftpToS3()
|
|
114
|
-
const localPath = '/local/tmp/test-already-processed.xml'
|
|
115
|
-
const s3Key = `${moment().format('YYYY-MM-DD')}/test-already-processed.xml`
|
|
116
|
-
expect(mockedFtpMethods.fastGet).toHaveBeenCalledWith('/ftpservershare/test-already-processed.xml', localPath, {})
|
|
117
|
-
expect(AwsMock.S3.mockedMethods.putObject).toHaveBeenCalledWith({
|
|
118
|
-
Bucket: 'testbucket',
|
|
119
|
-
Key: s3Key,
|
|
120
|
-
Body: 'teststream'
|
|
121
|
-
})
|
|
122
|
-
expect(updateFileStagingTable).not.toHaveBeenCalled()
|
|
123
|
-
expect(salesApi.upsertTransactionFile).not.toHaveBeenCalled()
|
|
124
|
-
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
125
|
-
expect(fs.unlinkSync).toHaveBeenCalledWith(localPath)
|
|
126
|
-
expect(mockedFtpMethods.end).toHaveBeenCalledTimes(1)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('logs and propogates errors back up the stack', async () => {
|
|
130
|
-
const testError = new Error('Test error')
|
|
131
|
-
mockedFtpMethods.list.mockRejectedValue(testError)
|
|
132
|
-
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
133
|
-
await expect(ftpToS3).rejects.toThrow(testError)
|
|
134
|
-
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('ignores non-xml files', async () => {
|
|
138
|
-
mockedFtpMethods.list.mockResolvedValue([{ name: 'test1.pdf' }, { name: 'test2.md' }])
|
|
139
|
-
await ftpToS3()
|
|
140
|
-
expect(mockedFtpMethods.fastGet).not.toHaveBeenCalled()
|
|
141
|
-
expect(AwsMock.S3.mockedMethods.putObject).not.toHaveBeenCalled()
|
|
142
|
-
expect(fs.unlinkSync).not.toHaveBeenCalled()
|
|
143
|
-
expect(mockedFtpMethods.end).toHaveBeenCalledTimes(1)
|
|
144
|
-
})
|
|
145
|
-
})
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import FtpClient from 'ssh2-sftp-client'
|
|
2
|
-
import moment from 'moment'
|
|
3
|
-
import Path from 'path'
|
|
4
|
-
import fs from 'fs'
|
|
5
|
-
import db from 'debug'
|
|
6
|
-
import md5File from 'md5-file'
|
|
7
|
-
import filesize from 'filesize'
|
|
8
|
-
import config from '../config.js'
|
|
9
|
-
import { getTempDir } from '../io/file.js'
|
|
10
|
-
import { DYNAMICS_IMPORT_STAGE, FILE_STAGE, POST_OFFICE_DATASOURCE } from '../staging/constants.js'
|
|
11
|
-
import { AWS, salesApi } from '@defra-fish/connectors-lib'
|
|
12
|
-
import { updateFileStagingTable } from '../io/db.js'
|
|
13
|
-
const { s3 } = AWS()
|
|
14
|
-
|
|
15
|
-
const debug = db('pocl:transport')
|
|
16
|
-
const sftp = new FtpClient()
|
|
17
|
-
|
|
18
|
-
export async function ftpToS3 () {
|
|
19
|
-
try {
|
|
20
|
-
debug('Connecting to SFTP endpoint at sftp://%s:%s%s', config.ftp.host, config.ftp.port, config.ftp.path)
|
|
21
|
-
await sftp.connect(config.ftp)
|
|
22
|
-
const fileList = await sftp.list(config.ftp.path)
|
|
23
|
-
debug('Discovered the following files on the SFTP server: %o', fileList)
|
|
24
|
-
const xmlFiles = fileList.filter(f => Path.extname(f.name).toLowerCase() === '.xml')
|
|
25
|
-
|
|
26
|
-
if (!xmlFiles.length) {
|
|
27
|
-
debug('No XML files were waiting to be processed on the SFTP server.')
|
|
28
|
-
} else {
|
|
29
|
-
await retrieveAllFiles(xmlFiles)
|
|
30
|
-
}
|
|
31
|
-
} catch (e) {
|
|
32
|
-
console.error('Error migrating files from the SFTP endpoint', e)
|
|
33
|
-
throw e
|
|
34
|
-
} finally {
|
|
35
|
-
debug('Closing SFTP connection.')
|
|
36
|
-
await sftp.end()
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function storeS3Metadata (md5, fileSize, filename, s3Key, receiptMoment) {
|
|
41
|
-
console.log(`Storing metadata for ${s3Key}`)
|
|
42
|
-
await updateFileStagingTable({ filename, md5, fileSize, s3Key, stage: FILE_STAGE.Pending })
|
|
43
|
-
|
|
44
|
-
await salesApi.upsertTransactionFile(filename, {
|
|
45
|
-
status: DYNAMICS_IMPORT_STAGE.Pending,
|
|
46
|
-
dataSource: POST_OFFICE_DATASOURCE,
|
|
47
|
-
fileSize: fileSize,
|
|
48
|
-
salesDate: moment(receiptMoment).subtract(1, 'days').toISOString(),
|
|
49
|
-
receiptTimestamp: receiptMoment.toISOString(),
|
|
50
|
-
notes: 'Retrieved from the remote server and awaiting processing'
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
console.log(`Stored metadata for ${s3Key}`)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const retrieveAllFiles = async xmlFiles => {
|
|
57
|
-
const tempDir = getTempDir('ftp')
|
|
58
|
-
|
|
59
|
-
for (const fileEntry of xmlFiles) {
|
|
60
|
-
const filename = fileEntry.name
|
|
61
|
-
const remoteFilePath = Path.join(config.ftp.path, filename)
|
|
62
|
-
const localFilePath = Path.resolve(tempDir, filename)
|
|
63
|
-
|
|
64
|
-
// Retrieve from FTP server to local temporary directory
|
|
65
|
-
debug('Transferring %s to %s', remoteFilePath, localFilePath)
|
|
66
|
-
await sftp.fastGet(remoteFilePath, localFilePath, {})
|
|
67
|
-
|
|
68
|
-
// Transfer to S3
|
|
69
|
-
const receiptMoment = moment()
|
|
70
|
-
const s3Key = Path.join(receiptMoment.format('YYYY-MM-DD'), filename)
|
|
71
|
-
debug('Transferring file to S3 bucket %s with key %s', config.s3.bucket, s3Key)
|
|
72
|
-
await s3.putObject({ Bucket: config.s3.bucket, Key: s3Key, Body: fs.createReadStream(localFilePath) }).promise()
|
|
73
|
-
|
|
74
|
-
const dynamicsRecord = await salesApi.getTransactionFile(filename)
|
|
75
|
-
if (dynamicsRecord && DYNAMICS_IMPORT_STAGE.isAlreadyProcessed(dynamicsRecord.status.description)) {
|
|
76
|
-
console.error(
|
|
77
|
-
'Retrieved file %s from SFTP and stored in S3, however an entry already exists in Dynamics with this filename. Skipping import.',
|
|
78
|
-
filename
|
|
79
|
-
)
|
|
80
|
-
} else {
|
|
81
|
-
const md5 = await md5File(localFilePath)
|
|
82
|
-
const fileSize = filesize(fs.statSync(localFilePath).size)
|
|
83
|
-
await storeS3Metadata(md5, fileSize, filename, s3Key, receiptMoment)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Remove from FTP server and local tmp
|
|
87
|
-
debug('Removing remote file %s', remoteFilePath)
|
|
88
|
-
await sftp.delete(remoteFilePath)
|
|
89
|
-
fs.unlinkSync(localFilePath)
|
|
90
|
-
}
|
|
91
|
-
}
|