diff --git a/migrations/20240508094320_add_update_index.js b/migrations/20240508094320_add_update_index.js new file mode 100644 index 00000000..d7968c8a --- /dev/null +++ b/migrations/20240508094320_add_update_index.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + await knex.schema.alterTable('connection', (def) => { + def.index('updated_at', 'idx_connection_updated_at') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function (knex) { + await knex.schema.alterTable('connection', (def) => { + def.dropIndex('updated_at', 'idx_connection_updated_at') + }) +} diff --git a/package-lock.json b/package-lock.json index a696bdda..345f52f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.1.5", + "version": "0.1.6", "license": "Apache-2.0", "dependencies": { "@kitajs/html": "^4.1.0", diff --git a/package.json b/package.json index c4ebdac9..a8cc8440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.1.5", + "version": "0.1.6", "description": "UI for Veritable", "main": "src/index.ts", "type": "commonjs", diff --git a/src/controllers/__tests__/example.test.ts b/src/controllers/__tests__/example.test.ts index 726e0ce5..9304d9d0 100644 --- a/src/controllers/__tests__/example.test.ts +++ b/src/controllers/__tests__/example.test.ts @@ -5,8 +5,11 @@ import { counterMock, mockLogger, templateMock, toHTMLString } from './helpers' import { RootController } from '../example' -describe('ExampleController', async () => { - let { expect } = await import('chai') +describe('ExampleController', () => { + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) afterEach(() => { sinon.restore() diff --git a/src/controllers/connection/__tests__/helpers.ts b/src/controllers/connection/__tests__/helpers.ts new file mode 100644 index 00000000..067fb465 --- /dev/null +++ b/src/controllers/connection/__tests__/helpers.ts @@ -0,0 +1,30 @@ +import { Readable } from 'node:stream' + +import pino from 'pino' + +import Database from '../../../models/db/index.js' +import ConnectionTemplates from '../../../views/connection' + +export const withMocks = () => { + const templateMock = { + listPage: (connections) => `list_${connections.map((c) => `${c.company_name}-${c.status}`).join('_')}_list`, + } as ConnectionTemplates + const mockLogger = pino({ level: 'silent' }) + const dbMock = { + get: () => Promise.resolve([{ company_name: 'foo', status: 'verified' }]), + } as unknown as Database + + return { + templateMock, + mockLogger, + dbMock, + } +} + +export const toHTMLString = async (stream: Readable) => { + const chunks: Uint8Array[] = [] + for await (const chunk of stream) { + chunks.push(chunk as Uint8Array) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/controllers/connection/__tests__/index.test.ts b/src/controllers/connection/__tests__/index.test.ts new file mode 100644 index 00000000..98d3812e --- /dev/null +++ b/src/controllers/connection/__tests__/index.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'mocha' +import sinon from 'sinon' + +import { toHTMLString, withMocks } from './helpers' + +import { ConnectionController } from '../index.js' + +describe('ConnectionController', () => { + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) + + afterEach(() => { + sinon.restore() + }) + + describe('listConnections', () => { + it('should return rendered list template', async () => { + let { dbMock, mockLogger, templateMock } = withMocks() + const controller = new ConnectionController(dbMock, templateMock, mockLogger) + const result = await controller.listConnections().then(toHTMLString) + expect(result).to.equal('list_foo-verified_list') + }) + + it('should call db as expected', async () => { + let { dbMock, mockLogger, templateMock } = withMocks() + const controller = new ConnectionController(dbMock, templateMock, mockLogger) + const spy = sinon.spy(dbMock, 'get') + await controller.listConnections().then(toHTMLString) + expect(spy.calledWith('connection', {}, [['updated_at', 'desc']])).to.equal(true) + }) + }) +}) diff --git a/src/controllers/connection/index.ts b/src/controllers/connection/index.ts new file mode 100644 index 00000000..a70e80ea --- /dev/null +++ b/src/controllers/connection/index.ts @@ -0,0 +1,33 @@ +import { Get, Produces, Route, SuccessResponse } from 'tsoa' +import { inject, injectable, singleton } from 'tsyringe' + +import { Logger, type ILogger } from '../../logger.js' +import Database from '../../models/db/index.js' +import ConnectionTemplates from '../../views/connection.js' +import { HTML, HTMLController } from '../HTMLController.js' + +@singleton() +@injectable() +@Route('/connection') +@Produces('text/html') +export class ConnectionController extends HTMLController { + constructor( + private db: Database, + private connectionTemplates: ConnectionTemplates, + @inject(Logger) private logger: ILogger + ) { + super() + this.logger = logger.child({ controller: '/' }) + } + + /** + * Retrieves the connection list page + */ + @SuccessResponse(200) + @Get('/') + public async listConnections(): Promise { + this.logger.debug('connections page requested') + const connections = await this.db.get('connection', {}, [['updated_at', 'desc']]) + return this.html(this.connectionTemplates.listPage(connections)) + } +} diff --git a/src/models/__tests__/counter.test.ts b/src/models/__tests__/counter.test.ts index ee4cc12d..dc6de884 100644 --- a/src/models/__tests__/counter.test.ts +++ b/src/models/__tests__/counter.test.ts @@ -2,8 +2,11 @@ import { describe, it } from 'mocha' import Counter from '../counter' -describe('Counter', async () => { - let { expect } = await import('chai') +describe('Counter', () => { + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) describe('get', () => { it('should return counter state 0 initially', async () => { diff --git a/src/views/__tests__/connection.test.ts b/src/views/__tests__/connection.test.ts new file mode 100644 index 00000000..2da8707a --- /dev/null +++ b/src/views/__tests__/connection.test.ts @@ -0,0 +1,71 @@ +import { describe, it } from 'mocha' + +import ConnectionTemplates from '../connection' + +describe('ConnectionTemplates', () => { + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) + + describe('listPage', () => { + it('should render with no connections', async () => { + const templates = new ConnectionTemplates() + const rendered = await templates.listPage([]) + expect(rendered).to.matchSnapshot() + }) + + it('should render with single connection', async () => { + const templates = new ConnectionTemplates() + const rendered = await templates.listPage([ + { + company_name: 'Company A', + status: 'disconnected', + }, + ]) + expect(rendered).to.matchSnapshot() + }) + + it('should escape html in name', async () => { + const templates = new ConnectionTemplates() + const rendered = await templates.listPage([ + { + company_name: '
I own you
', + status: 'verified_both', + }, + ]) + expect(rendered).to.matchSnapshot() + }) + + it('should render multiple with each status', async () => { + const templates = new ConnectionTemplates() + const rendered = await templates.listPage([ + { + company_name: 'Company A', + status: 'disconnected', + }, + { + company_name: 'Company B', + status: 'pending', + }, + { + company_name: 'Company C', + status: 'unverified', + }, + { + company_name: 'Company D', + status: 'verified_both', + }, + { + company_name: 'Company E', + status: 'verified_them', + }, + { + company_name: 'Company F', + status: 'verified_us', + }, + ]) + expect(rendered).to.matchSnapshot() + }) + }) +}) diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/__tests__/connection.test.ts.snap new file mode 100644 index 00000000..1c7b62c1 --- /dev/null +++ b/src/views/__tests__/connection.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections
<div>I own you</div>
verified_both
"`; + +exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections
Company A
disconnected
Company B
pending
Company C
unverified
Company D
verified_both
Company E
verified_them
Company F
verified_us
"`; + +exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections"`; + +exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections
Company A
disconnected
"`; diff --git a/src/views/__tests__/example.test.ts b/src/views/__tests__/example.test.ts index 0dc399b4..0044707a 100644 --- a/src/views/__tests__/example.test.ts +++ b/src/views/__tests__/example.test.ts @@ -3,7 +3,10 @@ import { describe, it } from 'mocha' import ExampleTemplates from '../example' describe('RootTemplates', async () => { - let { expect } = await import('chai') + let expect: Chai.ExpectStatic + before(async () => { + expect = (await import('chai')).expect + }) describe('Root', () => { it('should render root page', async () => { diff --git a/src/views/__tests__/example.test.ts.snap b/src/views/__tests__/example.test.ts.snap index 7ccb4a0b..e56e32a3 100644 --- a/src/views/__tests__/example.test.ts.snap +++ b/src/views/__tests__/example.test.ts.snap @@ -1,15 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Button should render the disabled button 1`] = `"
"`; +exports[`RootTemplates Button should render the disabled button 1`] = `"
"`; -exports[`Button should render the enabled button 1`] = `"
"`; +exports[`RootTemplates Button should render the enabled button 1`] = `"
"`; -exports[`Counter should render the counter on second call with value 1 1`] = `"
1
"`; +exports[`RootTemplates Counter should render the counter on second call with value 1 1`] = `"
1
"`; -exports[`Counter should render the counter with value 0 1`] = `"
0
"`; +exports[`RootTemplates Counter should render the counter with value 0 1`] = `"
0
"`; -exports[`Root should escape html in title 1`] = `"<div>Malicious Content</div>
0
"`; +exports[`RootTemplates Root should escape html in title 1`] = `"<div>Malicious Content</div>
0
"`; -exports[`Root should render counter 1 on second load 1`] = `"title
1
"`; +exports[`RootTemplates Root should render counter 1 on second load 1`] = `"title
1
"`; -exports[`Root should render root page 1`] = `"title
0
"`; +exports[`RootTemplates Root should render root page 1`] = `"title
0
"`; diff --git a/src/views/connection.tsx b/src/views/connection.tsx new file mode 100644 index 00000000..f73653b0 --- /dev/null +++ b/src/views/connection.tsx @@ -0,0 +1,26 @@ +import Html from '@kitajs/html' +import { singleton } from 'tsyringe' +import { Page } from './common' + +interface connection { + company_name: string + status: 'pending' | 'unverified' | 'verified_them' | 'verified_us' | 'verified_both' | 'disconnected' +} + +@singleton() +export default class ConnectionTemplates { + constructor() {} + + public listPage = (connections: connection[]) => { + return ( + + {connections.map((connection) => ( +
+
{Html.escapeHtml(connection.company_name)}
+
{connection.status}
+
+ ))} +
+ ) + } +} diff --git a/tsconfig.json b/tsconfig.json index df86bcf8..da631c97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,6 @@ "target": "ESNext", "module": "CommonJS", "moduleResolution": "node", - "typeRoots": ["types/*", "node_modules/@types"], "forceConsistentCasingInFileNames": true, "strict": true, "sourceMap": true, @@ -21,5 +20,5 @@ "resolveJsonModule": true }, "include": ["src", "src/swagger.json"], - "exclude": ["**/__tests__"] + "exclude": ["test", "**/__tests__"] }