Skip to content
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

SIMSBIOHUB-632: Display Markdown in Vote Dialogs #1418

Merged
merged 14 commits into from
Nov 25, 2024
3,680 changes: 1,632 additions & 2,048 deletions api/package-lock.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions api/src/models/markdown-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from 'zod';

export const MarkdownObject = z.object({
markdown_id: z.number(),
markdown_type_id: z.number(),
data: z.string(),
participated: z.boolean()
});

export type MarkdownObject = z.infer<typeof MarkdownObject>;

export const MarkdownUserObject = z.object({
markdown_user_id: z.number(),
system_user_id: z.number(),
markdown_id: z.number()
});

export type MarkdownUserObject = z.infer<typeof MarkdownUserObject>;

export interface MarkdownQueryObject {
system_user_id: number;
markdown_type_name: string;
}
32 changes: 32 additions & 0 deletions api/src/openapi/schemas/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { OpenAPIV3 } from 'openapi-types';

/**
* Schema for markdown records used in versioned help dialogs
*
*/
export const markdownSchema: OpenAPIV3.SchemaObject = {
type: 'object',
description: 'Schema for get markdown response',
additionalProperties: false,
properties: {
markdown: {
type: 'object',
description: 'Markdown record',
required: ['markdown_id', 'markdown_type_id', 'data', 'participated'],
additionalProperties: false,
properties: {
markdown_id: { type: 'number', description: 'Primary key of the markdown record', minimum: 1 },
markdown_type_id: {
type: 'number',
description: 'Type of the markdown record, used to identify which records correspond to which dialogs',
minimum: 1
},
data: { type: 'string', description: 'Markdown string to display' },
participated: {
type: 'boolean',
description: 'True if the user has already scored the markdown record, otherwise false.'
}
}
}
}
};
93 changes: 93 additions & 0 deletions api/src/paths/markdown/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { getMarkdownByTypeName } from '.';
import * as db from '../../database/db';
import { HTTPError } from '../../errors/http-error';
import { MarkdownService } from '../../services/markdown-service';
import { KeycloakUserInformation } from '../../utils/keycloak-utils';
import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db';

chai.use(sinonChai);

describe('getMarkdown', () => {
afterEach(() => {
sinon.restore();
});

it('successfully retrieves markdown', async () => {
const mockMarkdownResponse = {
markdown_id: 1,
markdown_type_id: 1,
data: 'Sample markdown content',
participated: false
};

const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
systemUserId: () => 20
});

sinon.stub(db, 'getDBConnection').returns(mockDBConnection);

const getMarkdownStub = sinon
.stub(MarkdownService.prototype, 'getMarkdownByTypeName')
.resolves(mockMarkdownResponse);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = { typeName: 'help' };
mockReq.keycloak_token = {} as KeycloakUserInformation;

const requestHandler = getMarkdownByTypeName();

await requestHandler(mockReq, mockRes, mockNext);

expect(mockDBConnection.open).to.have.been.calledOnce;
expect(mockDBConnection.commit).to.have.been.calledOnce;
expect(getMarkdownStub).to.have.been.calledOnceWith({
markdown_type_name: 'help',
system_user_id: 20
});
expect(mockRes.jsonValue.markdown).to.eql(mockMarkdownResponse);
expect(mockDBConnection.release).to.have.been.calledOnce;
});

it('handles errors gracefully', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
rollback: sinon.stub(),
release: sinon.stub(),
systemUserId: () => 20
});

sinon.stub(db, 'getDBConnection').returns(mockDBConnection);

const getMarkdownStub = sinon
.stub(MarkdownService.prototype, 'getMarkdownByTypeName')
.rejects(new Error('a test error'));

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = { typeName: 'help' };
mockReq.keycloak_token = {} as KeycloakUserInformation;

const requestHandler = getMarkdownByTypeName();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail('Expected error was not thrown');
} catch (actualError) {
expect(mockDBConnection.open).to.have.been.calledOnce;
expect(getMarkdownStub).to.have.been.calledOnceWith({
markdown_type_name: 'help',
system_user_id: 20
});
expect(mockDBConnection.rollback).to.have.been.calledOnce;
expect(mockDBConnection.release).to.have.been.calledOnce;

expect((actualError as HTTPError).message).to.equal('a test error');
}
});
});
108 changes: 108 additions & 0 deletions api/src/paths/markdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { SYSTEM_ROLE } from '../../constants/roles';
import { getDBConnection } from '../../database/db';
import { markdownSchema } from '../../openapi/schemas/markdown';
import { authorizeRequestHandler } from '../../request-handlers/security/authorization';
import { MarkdownService } from '../../services/markdown-service';
import { getLogger } from '../../utils/logger';

const defaultLog = getLogger('paths/markdown/index');

export const GET: Operation = [
authorizeRequestHandler(() => {
return {

Check warning on line 14 in api/src/paths/markdown/index.ts

View check run for this annotation

Codecov / codecov/patch

api/src/paths/markdown/index.ts#L14

Added line #L14 was not covered by tests
and: [
{
validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR, SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR],
discriminator: 'SystemRole'
}
]
};
}),
getMarkdownByTypeName()
];

GET.apiDoc = {
description: 'Gets a markdown record to display in a help dialog.',
tags: ['markdown'],
security: [
{
Bearer: []
}
],
parameters: [
{
in: 'query',
name: 'typeName',
description: 'The name of a markdown type to retrieve the latest markdown record for',
required: true,
schema: {
type: 'string'
}
}
],
responses: {
200: {
description: 'Markdown response object.',
content: {
'application/json': {
schema: markdownSchema
}
}
},
400: {
$ref: '#/components/responses/400'
},
401: {
$ref: '#/components/responses/401'
},
403: {
$ref: '#/components/responses/403'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Get the latest markdown text for a given markdown type
*
* @returns {RequestHandler}
*/
export function getMarkdownByTypeName(): RequestHandler {
return async (req, res) => {
defaultLog.debug({ label: 'getMarkdownByTypeName' });

const connection = getDBConnection(req.keycloak_token);

try {
await connection.open();

const systemUserId = connection.systemUserId();

const markdownTypeName = req.query.typeName as string;

const markdownService = new MarkdownService(connection);

const markdown = await markdownService.getMarkdownByTypeName({
markdown_type_name: markdownTypeName,
system_user_id: systemUserId
});

await connection.commit();

return res.status(200).json({ markdown });
} catch (error) {
defaultLog.error({ label: 'getMarkdown', message: 'error', error });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
117 changes: 117 additions & 0 deletions api/src/paths/markdown/{markdownId}/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import * as db from '../../../database/db';
import { MarkdownService } from '../../../services/markdown-service';
import { KeycloakUserInformation } from '../../../utils/keycloak-utils';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db';
import { scoreMarkdown } from './index';

chai.use(sinonChai);

describe('scoreMarkdown', () => {
afterEach(() => {
sinon.restore();
});

it('successfully submits a score for a markdown record', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
systemUserId: () => 20
});

sinon.stub(db, 'getDBConnection').returns(mockDBConnection);

const handleScoreChangeStub = sinon.stub(MarkdownService.prototype, 'handleScoreChange').resolves(10);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.params = { markdownId: '1' };
mockReq.body = { score: -1 };
mockReq.keycloak_token = {} as KeycloakUserInformation;

const requestHandler = scoreMarkdown();

await requestHandler(mockReq, mockRes, mockNext);

expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, -1);

expect(mockRes.status).to.have.been.calledWith(200);
expect(mockRes.json).to.have.been.calledOnce;

expect(mockDBConnection.open).to.have.been.calledOnce;
expect(mockDBConnection.commit).to.have.been.calledOnce;
expect(mockDBConnection.release).to.have.been.calledOnce;
});

it('returns a 500 error if the user has already scored the markdown record', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
rollback: sinon.stub(),
release: sinon.stub(),
systemUserId: () => 20
});

sinon.stub(db, 'getDBConnection').returns(mockDBConnection);

const handleScoreChangeStub = sinon.stub(MarkdownService.prototype, 'handleScoreChange').resolves(null);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.params = { markdownId: '1' };
mockReq.body = { score: -1 };
mockReq.keycloak_token = {} as KeycloakUserInformation;

const requestHandler = scoreMarkdown();

await requestHandler(mockReq, mockRes, mockNext);

expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, -1);

expect(mockRes.status).to.have.been.calledWith(500);
expect(mockRes.json).to.have.been.calledOnce;

expect(mockDBConnection.open).to.have.been.calledOnce;
expect(mockDBConnection.commit).to.have.been.calledOnce;
expect(mockDBConnection.release).to.have.been.calledOnce;
});

it('handles errors gracefully', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
rollback: sinon.stub(),
release: sinon.stub(),
systemUserId: () => 20
});

sinon.stub(db, 'getDBConnection').returns(mockDBConnection);

const handleScoreChangeStub = sinon
.stub(MarkdownService.prototype, 'handleScoreChange')
.rejects(new Error('a test error'));

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.params = { markdownId: '1' };
mockReq.body = { score: 1 };
mockReq.keycloak_token = {} as KeycloakUserInformation;

const requestHandler = scoreMarkdown();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail('Expected error was not thrown');
} catch (actualError) {
expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, 1);

expect(mockDBConnection.open).to.have.been.calledOnce;
expect(mockDBConnection.rollback).to.have.been.calledOnce;
expect(mockDBConnection.release).to.have.been.calledOnce;

expect((actualError as Error).message).to.equal('a test error');
}
});
});
Loading
Loading