Skip to content

Commit

Permalink
feat(cb2-16021): generate ants file (#104)
Browse files Browse the repository at this point in the history
* feat(cb2-16021): added logic for ants endpoint

* feat(cb2-16021): tidied up code

* feat(cb2-16021): updated formatting of dates in csv

* feat(cb2-16021): added unit tests for ants ep

* feat(cb2-16021): further tests for coverage

* feat(cb2-16021): further tests for coverage
  • Loading branch information
cb-cs authored Feb 10, 2025
1 parent 574dcf3 commit 51b4a91
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 8 deletions.
10 changes: 10 additions & 0 deletions src/app/antsFeedQueryFunctionFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import DatabaseService from "../interfaces/DatabaseService";
import { FeedName } from "../interfaces/FeedTypes";
import logger from "../utils/logger";
import { getFeed } from "./databaseService";
import AntsFeedData from "../interfaces/queryResults/antsFeedData";

export default (): ((databaseService: DatabaseService, feedName: FeedName) => Promise<AntsFeedData[]>) => {
logger.debug('redirecting to getAntsFeed using ants factory');
return getFeed as (databaseService: DatabaseService, feedName: FeedName) => Promise<AntsFeedData[]>;
};
32 changes: 28 additions & 4 deletions src/app/databaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import TflFeedData from '../interfaces/queryResults/tflFeedData';
import { TFL_QUERY } from './queries/tflQuery';
import { FeedName } from '../interfaces/FeedTypes';
import { readAndUpsert } from '../infrastructure/s3BucketService';
import AntsFeedData from "../interfaces/queryResults/antsFeedData";
import { ANTS_QUERY } from "./queries/antsQuery";

async function getTechnicalRecordDetails(
technicalRecordQueryResult: TechnicalRecordQueryResult,
Expand Down Expand Up @@ -220,8 +222,12 @@ function getEvlFeedByVrmDetails(queryResult: QueryOutput): EvlFeedData {
return evlFeedQueryResult;
}

function getFeedDetails(queryResult: QueryOutput, feedName: FeedName): EvlFeedData[] | TflFeedData[] {
const feedQueryResults: EvlFeedData[] | TflFeedData[] = feedName === FeedName.EVL ? (queryResult[0] as EvlFeedData[]) : (queryResult[0] as TflFeedData[]);
function getFeedDetails(queryResult: QueryOutput, feedName: FeedName): EvlFeedData[] | TflFeedData[] | AntsFeedData[] {
const feedQueryResults: EvlFeedData[] | TflFeedData[] | AntsFeedData[] =
feedName === FeedName.EVL ? (queryResult[0] as EvlFeedData[])
: feedName === FeedName.TFL
? (queryResult[0] as TflFeedData[])
: (queryResult[0] as AntsFeedData[]);
if (feedQueryResults === undefined || feedQueryResults.length === 0) {
throw new NotFoundError('No tests found');
}
Expand All @@ -241,6 +247,7 @@ async function getEvlFeedByVrm(databaseService: DatabaseServiceInterface, event:
const getQueryMap: { [key in FeedName]: string } = {
EVL: EVL_QUERY,
TFL: TFL_QUERY,
ANTS: ANTS_QUERY,
};

async function getLastTFLFileDate(): Promise<string> {
Expand All @@ -254,15 +261,30 @@ async function getLastTFLFileDate(): Promise<string> {
}/${date.getUTCFullYear()} ${date.getUTCHours()}:${date.getUTCMinutes()}:${date.getUTCSeconds()}`;
}

async function getLastAntsFileData(): Promise<string> {
const fileName = 'ANTS_LATEST_VALID_FROM_DATE.txt';
const twoWeeksAgo = new Date();
twoWeeksAgo.setUTCDate(new Date().getUTCDate() - 14);
const latestDate = await readAndUpsert(fileName, new Date().toISOString(), twoWeeksAgo.toISOString());
const date = new Date(latestDate);
return `${date.getUTCDate()}/${
date.getUTCMonth() + 1
}/${date.getUTCFullYear()} ${date.getUTCHours()}:${date.getUTCMinutes()}:${date.getUTCSeconds()}`;
}

async function getFeed(
databaseService: DatabaseServiceInterface,
feedName: FeedName,
): Promise<EvlFeedData[] | TflFeedData[]> {
): Promise<EvlFeedData[] | TflFeedData[] | AntsFeedData[]> {
logger.info(`Using get${feedName}Feed`);
// eslint-disable-next-line security/detect-object-injection
const query = getQueryMap[feedName];
logger.debug(`calling database with ${feedName} query ${query}`);
const parameter = feedName === FeedName.TFL ? [await getLastTFLFileDate()] : [];
const parameter = feedName === FeedName.TFL
? [await getLastTFLFileDate()]
: feedName === FeedName.ANTS
? [await getLastAntsFileData()]
: [];
const queryResult = await databaseService.get(query, parameter);
const result = getFeedDetails(queryResult, feedName);
logger.debug(`result from database: ${JSON.stringify(result)}`);
Expand All @@ -280,6 +302,8 @@ export {
getEvlFeedByVrm,
getEvlFeedByVrmDetails,
getFeed,
getLastTFLFileDate,
getLastAntsFileData
};

interface VehicleQueryResult extends RowDataPacket {
Expand Down
14 changes: 14 additions & 0 deletions src/app/queries/antsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ANTS_QUERY = `
SELECT
vrm_trm,
make,
model,
wheelplan,
test_date,
weight_before_test,
weight_after_test,
DOE_reference,
tech_record_date
FROM vw_dvla_ants
WHERE test_date >= STR_TO_DATE(?, '%d/%m/%Y %T')
ORDER BY test_date ASC;`;
6 changes: 4 additions & 2 deletions src/domain/enquiryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import EvlFeedData from '../interfaces/queryResults/evlFeedData';
import TflFeedData from '../interfaces/queryResults/tflFeedData';
import tflFeedQueryFunctionFactory from '../app/tflFeedQueryFunctionFactory';
import { FeedName } from '../interfaces/FeedTypes';
import antsFeedQueryFunctionFactory from "../app/antsFeedQueryFunctionFactory";
import AntsFeedData from "../interfaces/queryResults/antsFeedData";

const getVehicleDetails = async (
event: VehicleEvent,
Expand All @@ -35,11 +37,11 @@ const getResultsDetails = async (
};

const getFeedDetails = async (
queryFuncFactory: typeof evlFeedQueryFunctionFactory | typeof tflFeedQueryFunctionFactory,
queryFuncFactory: typeof evlFeedQueryFunctionFactory | typeof tflFeedQueryFunctionFactory | typeof antsFeedQueryFunctionFactory,
feedName: FeedName,
dbService: DatabaseService,
event: EvlEvent = null,
): Promise<EvlFeedData[] | TflFeedData[]> => {
): Promise<EvlFeedData[] | TflFeedData[] | AntsFeedData[]> => {
const query = queryFuncFactory(event);
return query(dbService, feedName, event);
};
Expand Down
50 changes: 50 additions & 0 deletions src/infrastructure/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { processTFLFeedData } from '../../utils/tflHelpers';
import { FeedName } from '../../interfaces/FeedTypes';
import EvlFeedData from '../../interfaces/queryResults/evlFeedData';
import TflFeedData from '../../interfaces/queryResults/tflFeedData';
import AntsFeedData from "../../interfaces/queryResults/antsFeedData";
import { processAntsFeedData } from "../../utils/antsHelpers";
import antsFeedQueryFunctionFactory from "../../app/antsFeedQueryFunctionFactory";

const app = express();
const router: Router = express.Router();
Expand Down Expand Up @@ -208,6 +211,53 @@ router.get('/tfl', (_req, res) => {
});
});

router.get('/ants', (_req, res) => {
let secretsManager: SecretsManagerServiceInterface;
if (process.env.IS_OFFLINE === 'true') {
logger.debug('configuring local secret manager');
secretsManager = new LocalSecretsManagerService();
} else {
logger.debug('configuring aws secret manager');
secretsManager = new SecretsManagerService(new SecretsManager());
}
DatabaseService.build(secretsManager, mysql)
.then((dbService) => getFeedDetails(antsFeedQueryFunctionFactory, FeedName.ANTS, dbService))
.then(async (result: AntsFeedData[]) => {
const fileName = `ANTS_vehicle_weight_changes_${moment(Date.now()).format('YYYY-MM-DD')}.csv`;
logger.debug(`creating file for ANTS feed called: ${fileName}`);
logger.info('Generating ANTS File Data');
const processedResult = result.map((entry) => processAntsFeedData(entry));
const antsFeedProcessedData: string = processedResult
.map(
(entry) => `${entry.vrm_trm},${entry.make},${entry.model}, ${entry.wheelplan},${moment(entry.test_date).format('DD-MM-YYYY')},${entry.weight_before_test},${entry.weight_after_test},${entry.DOE_reference},${moment(entry.tech_record_date).format('DD-MM-YYYY')}`,
)
.join('\n');
logger.debug(`\nData captured for file generation: ${antsFeedProcessedData} \n\n`);
await uploadToS3(antsFeedProcessedData, fileName, () => {
logger.info(`Successfully uploaded ${fileName} to S3`);
res.status(200);
res.contentType('json').send();
});
})
.catch(async (e: Error) => {
if (e instanceof ParametersError) {
res.status(400);
res.send(`Error Generating ANTS Feed Data: ${e.message}`);
} else if (e instanceof NotFoundError) {
const fileName = `ANTS_vehicle_weight_changes_${moment(Date.now()).format('YYYY-MM-DD')}.csv`; // TODO agree default filename
await uploadToS3(' , ,', fileName, () => {
logger.info(`Successfully uploaded ${fileName} to S3`);
res.status(200);
res.contentType('json').send();
});
} else {
res.status(500);
res.send(`Error Generating ANTS Feed Data: ${e.message}`);
}
logger.error(`Error occurred with message ${e.message}. Stack Trace: ${e.stack}`);
});
});

router.all(/testResults|vehicle/, (_request, res) => {
res.status(405).send();
});
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/FeedTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum FeedName {
EVL = 'EVL',
TFL = 'TFL',
ANTS = 'ANTS',
}
11 changes: 11 additions & 0 deletions src/interfaces/queryResults/antsFeedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default interface AntsFeedData {
vrm_trm?: string | undefined;
make?: string | undefined;
model?: string | undefined;
wheelplan?: string | undefined;
test_date?: string | undefined;
weight_before_test?: bigint | undefined;
weight_after_test?: bigint | undefined;
DOE_reference?: string | undefined;
tech_record_date?: string | undefined;
}
9 changes: 9 additions & 0 deletions src/utils/antsHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import AntsFeedData from "../interfaces/queryResults/antsFeedData";
import { escapeString } from "./tflHelpers";

export function processAntsFeedData(data: AntsFeedData): AntsFeedData {
return Object.assign(
{},
...Object.keys(data).map((key) => ({ [key]: escapeString(data[key as keyof AntsFeedData]) })),
) as AntsFeedData;
}
20 changes: 20 additions & 0 deletions tests/unit/app/antsFeedQueryFunctionFactory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import queryFunctionFactory from '../../../src/app/antsFeedQueryFunctionFactory';
import * as dbFunctions from "../../../src/app/databaseService";
import DatabaseService from "../../../src/infrastructure/databaseService";
import { FeedName } from "../../../src/interfaces/FeedTypes";

jest.mock('../../../src/app/databaseService');
jest.mock('../../../src/infrastructure/databaseService');

const dbFunctionsMock = jest.mocked(dbFunctions);
const dbServiceMock = (jest.mocked(DatabaseService) as unknown) as DatabaseService;

describe('Query Function Factory', () => {
it('returns the correct function when passed no parameters', () => {
dbFunctionsMock.getFeed = jest.fn().mockReturnValue('Success');

const func = queryFunctionFactory();

expect(func(dbServiceMock, FeedName.ANTS)).toEqual('Success');
});
});
70 changes: 68 additions & 2 deletions tests/unit/app/databaseService.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { FieldPacket, RowDataPacket } from 'mysql2/promise';
import {
getTestResultsByVin,
getFeed,
getLastAntsFileData,
getLastTFLFileDate,
getTestResultsByTestId,
getTestResultsByVin,
getTestResultsByVrm,
getVehicleDetailsByTrailerId,
getVehicleDetailsByVin,
getVehicleDetailsByVrm,
getVehicleDetailsByVrm
} from '../../../src/app/databaseService';
import * as technicalQueries from '../../../src/app/queries/technicalRecord';
import * as testQueries from '../../../src/app/queries/testResults';
import { QueryOutput } from '../../../src/interfaces/DatabaseService';
import * as s3BucketService from '../../../src/infrastructure/s3BucketService';
import { TFL_QUERY } from "../../../src/app/queries/tflQuery";
import { ANTS_QUERY } from "../../../src/app/queries/antsQuery";
import { FeedName } from "../../../src/interfaces/FeedTypes";

describe('Database Service', () => {
describe('Get vehicle details', () => {
Expand Down Expand Up @@ -368,4 +375,63 @@ describe('Database Service', () => {
expect(response[0].defects?.[1].defect?.imDescription).toEqual('Test defect 2');
});
});

describe('get TFL Data', () => {
it('correctly retrieves the last ANTS file date', async () => {
const mockReadAndUpsert = jest.spyOn(s3BucketService, 'readAndUpsert').mockResolvedValue('2025-10-01T12:00:00Z');
const result = await getLastTFLFileDate();

expect(mockReadAndUpsert).toHaveBeenCalledWith(
'TFL_LATEST_VALID_FROM_DATE.txt',
expect.any(String),
expect.any(String),
);
expect(result).toEqual('1/10/2025 12:0:0');
});
});

describe('get ANTS Data', () => {
it('correctly retrieves the last ANTS file date', async () => {
const mockReadAndUpsert = jest.spyOn(s3BucketService, 'readAndUpsert').mockResolvedValue('2025-10-01T12:00:00Z');
const result = await getLastAntsFileData();

expect(mockReadAndUpsert).toHaveBeenCalledWith(
'ANTS_LATEST_VALID_FROM_DATE.txt',
expect.any(String),
expect.any(String),
);
expect(result).toEqual('1/10/2025 12:0:0');
});
});

describe('get Feed', () => {
it('correctly retrieves TFL feed data', async () => {
const mockDbService = {
get: jest.fn<Promise<[RowDataPacket[], FieldPacket[]]>, [query: string, params: string[]]>()
.mockResolvedValueOnce([[
{ tflData: 'TFL123' } as RowDataPacket], []]),
};

jest.spyOn(s3BucketService, 'readAndUpsert').mockResolvedValue('2025-10-01T12:00:00Z');

const result = await getFeed(mockDbService, FeedName.TFL);

expect(mockDbService.get).toHaveBeenCalledWith(TFL_QUERY, ['1/10/2025 12:0:0']);
expect(result).toHaveLength(1);
});

it('correctly retrieves ANTS feed data', async () => {
const mockDbService = {
get: jest.fn<Promise<[RowDataPacket[], FieldPacket[]]>, [query: string, params: string[]]>()
.mockResolvedValueOnce([[{ antsData: 'ANTS123' } as RowDataPacket], []]),
};

jest.spyOn(s3BucketService, 'readAndUpsert').mockResolvedValue('2025-10-01T12:00:00Z');

const result = await getFeed(mockDbService, FeedName.ANTS);

expect(mockDbService.get).toHaveBeenCalledWith(ANTS_QUERY, ['1/10/2025 12:0:0']);
expect(result).toHaveLength(1);
});
});
});
57 changes: 57 additions & 0 deletions tests/unit/infrastructure/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NotFoundError from '../../../../src/errors/NotFoundError';
import EvlFeedData from '../../../../src/interfaces/queryResults/evlFeedData';
import * as upload from '../../../../src/infrastructure/s3BucketService';
import TflFeedData from '../../../../src/interfaces/queryResults/tflFeedData';
import AntsFeedData from "../../../../src/interfaces/queryResults/antsFeedData";

// TODO Define Mock strategy
describe('API', () => {
Expand Down Expand Up @@ -284,4 +285,60 @@ describe('API', () => {
expect(result.status).toEqual(500);
});
});

describe('ANTS Feed', () => {
it('returns the db query result if there are no errors', async () => {
const antsFeedData: AntsFeedData = {
vrm_trm: 'abc123',
make: 'make',
model: 'model',
wheelplan: 'wheelplan',
test_date: '2025-02-01',
// @ts-ignore
weight_before_test: 1000,
// @ts-ignore
weight_after_test: 1500,
DOE_reference: 'reference',
tech_record_date: '2025-01-01'
};
DatabaseService.build = jest.fn().mockResolvedValue({} as DatabaseServiceInterface);
jest.spyOn(upload, 'uploadToS3').mockImplementation((_data, _fileName, callback) => new Promise((resolve) => resolve(callback())));
jest.spyOn(enquiryService, 'getFeedDetails').mockResolvedValue([antsFeedData]);
const result = await supertest(app).get('/v1/enquiry/ants');
expect(result.status).toEqual(200);
});

it('returns the error message if there is an error', async () => {
DatabaseService.build = jest.fn().mockResolvedValue({} as DatabaseServiceInterface);
jest.spyOn(enquiryService, 'getFeedDetails').mockRejectedValue(new Error('This is an error'));
const result = await supertest(app).get('/v1/enquiry/ants');

expect(result.text).toEqual('Error Generating ANTS Feed Data: This is an error');
});

it('sets the status to 400 for a parameters error', async () => {
DatabaseService.build = jest.fn().mockResolvedValue({} as DatabaseServiceInterface);
jest.spyOn(enquiryService, 'getFeedDetails').mockRejectedValue(new ParametersError('This is an error'));
const result = await supertest(app).get('/v1/enquiry/ants');

expect(result.status).toEqual(400);
});

it('sets the status to 404 for a not found error', async () => {
DatabaseService.build = jest.fn().mockResolvedValue({} as DatabaseServiceInterface);
jest.spyOn(upload, 'uploadToS3').mockImplementation((_data, _fileName, callback) => new Promise((resolve) => resolve(callback())));
jest.spyOn(enquiryService, 'getFeedDetails').mockRejectedValue(new NotFoundError('This is an error'));
const result = await supertest(app).get('/v1/enquiry/ants');

expect(result.status).toEqual(200);
});

it('sets the status to 500 for a generic error', async () => {
DatabaseService.build = jest.fn().mockResolvedValue({} as DatabaseServiceInterface);
jest.spyOn(enquiryService, 'getFeedDetails').mockRejectedValue(new Error('This is an error'));
const result = await supertest(app).get('/v1/enquiry/ants');

expect(result.status).toEqual(500);
});
});
});
Loading

0 comments on commit 51b4a91

Please sign in to comment.