diff --git a/.eslintrc b/.eslintrc index 995e5535d4..5137354140 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,7 +12,8 @@ "eslint:recommended" ], "ignorePatterns": [ - "node_modules/" + "node_modules/", + "database/data/" ], "rules": { "array-bracket-newline": [ diff --git a/.gitignore b/.gitignore index 4bc8190f02..b0b14bcc29 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ ### Node ### # Logs -logs *.log npm-debug.log* yarn-debug.log* diff --git a/lib/application/usecases/log/GetLogUseCase.js b/lib/application/usecases/log/GetLogUseCase.js new file mode 100644 index 0000000000..042f9da14b --- /dev/null +++ b/lib/application/usecases/log/GetLogUseCase.js @@ -0,0 +1,39 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { IUseCase } = require('../../interfaces'); +const { + repositories: { + LogRepository, + }, + utilities: { + TransactionHelper, + }, +} = require('../../../database'); + +/** + * GetLogUseCase + */ +class GetLogUseCase extends IUseCase { + /** + * Executes this use case. + * + * @param {Number} id ID of the entity to retrieve. + * @returns {Promise} Promise object represents the result of this use case. + */ + async execute({ id }) { + return TransactionHelper.provide(() => LogRepository.findOneById(id)); + } +} + +module.exports = GetLogUseCase; diff --git a/lib/application/usecases/log/index.js b/lib/application/usecases/log/index.js index d6e3f371e1..d5ae50a263 100644 --- a/lib/application/usecases/log/index.js +++ b/lib/application/usecases/log/index.js @@ -13,8 +13,10 @@ const CreateLogUseCase = require('./CreateLogUseCase'); const GetAllLogsUseCase = require('./GetAllLogsUseCase'); +const GetLogUseCase = require('./GetLogUseCase'); module.exports = { CreateLogUseCase, GetAllLogsUseCase, + GetLogUseCase, }; diff --git a/lib/database/repositories/LogRepository.js b/lib/database/repositories/LogRepository.js index 3c4a3c575e..715da6f9b6 100644 --- a/lib/database/repositories/LogRepository.js +++ b/lib/database/repositories/LogRepository.js @@ -37,6 +37,17 @@ class LogRepository extends ILogRepository { return Log.findAll().map(LogAdapter.toEntity); } + /** + * Returns a specific entity. + * + * @param {Number} id ID primary key of the entity to find. + * @returns {Promise|Null} Promise object representing the full mock data + */ + async findOneById(id) { + const result = await Log.findByPk(id); + return result ? LogAdapter.toEntity(result) : null; + } + /** * Insert entity. * diff --git a/lib/domain/dtos/GetLogDto.js b/lib/domain/dtos/GetLogDto.js new file mode 100644 index 0000000000..7771114dfb --- /dev/null +++ b/lib/domain/dtos/GetLogDto.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { attributes } = require('structure'); + +const GetLogDto = attributes({ + id: { + type: Number, + required: true, + integer: true, + positive: true, + }, +})(class GetLogDto {}); + +module.exports = GetLogDto; diff --git a/lib/domain/dtos/index.js b/lib/domain/dtos/index.js index 009eae24fb..ff549c7e49 100644 --- a/lib/domain/dtos/index.js +++ b/lib/domain/dtos/index.js @@ -12,7 +12,9 @@ */ const CreateLogDto = require('./CreateLogDto'); +const GetLogDto = require('./GetLogDto'); module.exports = { CreateLogDto, + GetLogDto, }; diff --git a/lib/public/Model.js b/lib/public/Model.js index 746af010f5..0fdf1f2fc2 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -18,7 +18,7 @@ import { sessionService, } from '/js/src/index.js'; -import Overview from './views/Overview/Overview.js'; +import Logs from './views/Logs/Logs.js'; /** * Root of model tree @@ -38,8 +38,9 @@ export default class Model extends Observable { this.loader = new Loader(this); this.loader.bubbleTo(this); - this.overview = new Overview(this); - this.overview.bubbleTo(this); + this.logs = new Logs(this); + this.logs.bubbleTo(this); + // Setup router this.router = new QueryRouter(); this.router.observe(this.handleLocationChange.bind(this)); @@ -55,8 +56,10 @@ export default class Model extends Observable { handleLocationChange() { switch (this.router.params.page) { case 'home': + this.logs.fetchAllLogs(); break; case 'entry': + this.logs.fetchOneLog(this.router.params.id); break; default: this.router.go('?page=home'); diff --git a/lib/public/components/Filters/index.js b/lib/public/components/Filters/index.js index bd449fa913..35efb1794d 100644 --- a/lib/public/components/Filters/index.js +++ b/lib/public/components/Filters/index.js @@ -27,8 +27,8 @@ const checkboxFilter = (model, tags) => { onclick: (e) => { const isChecked = e.target.checked; !isChecked - ? model.overview.removeFilter(tag) - : model.overview.addFilter(tag); + ? model.logs.removeFilter(tag) + : model.logs.addFilter(tag); }, id: `filtersCheckbox${index + 1}`, type: 'checkbox', diff --git a/lib/public/components/Table/index.js b/lib/public/components/Table/index.js index 78dbd4144b..9122e56e1c 100644 --- a/lib/public/components/Table/index.js +++ b/lib/public/components/Table/index.js @@ -36,8 +36,8 @@ const rowData = (data) => [h('td', data)]; const table = (data, headers, model) => h('table.table.shadow-level1.mh3', [ h('tr', [headers.map((header) => rowHeader(header))]), data.map((entry, index) => h(`tr#row${index + 1}`, { + style: 'cursor: pointer;', onclick: () => model.router.go(`?page=entry&id=${entry[0]}`), - }, [Object.keys(entry).map((subItem) => rowData(entry[subItem]))])), ]); diff --git a/lib/public/view.js b/lib/public/view.js index 914934cdce..6f1d5b50ea 100644 --- a/lib/public/view.js +++ b/lib/public/view.js @@ -13,8 +13,8 @@ import { h, switchCase } from '/js/src/index.js'; import NavBar from './components/NavBar/index.js'; -import GeneralOverview from './views/Overview/General/page.js'; -import DetailsView from './views/Overview/Details/page.js'; +import LogsOverview from './views/Logs/Overview/page.js'; +import LogDetailView from './views/Logs/Details/page.js'; /** * Main view layout @@ -23,11 +23,11 @@ import DetailsView from './views/Overview/Details/page.js'; */ export default (model) => { const navigationPages = { - home: GeneralOverview, + home: LogsOverview, }; const subPages = { - entry: DetailsView, + entry: LogDetailView, }; return [ diff --git a/lib/public/views/Overview/Details/page.js b/lib/public/views/Logs/Details/page.js similarity index 54% rename from lib/public/views/Overview/Details/page.js rename to lib/public/views/Logs/Details/page.js index 8c5c13c34e..b1a358fb78 100644 --- a/lib/public/views/Overview/Details/page.js +++ b/lib/public/views/Logs/Details/page.js @@ -18,18 +18,21 @@ import PostBox from '../../../components/Post/index.js'; * @param {object} model Pass the model to access the defined functions * @return {vnode} Return the view of the table with the filtering options */ -const overviewScreen = (model) => { - const data = model.overview.getData(); - const id = parseInt(model.router.params.id); - let posts; +const logDetailScreen = (model) => { + const data = model.logs.getData(); - data.forEach((entry) => { - if (entry.entryID === id) { - posts = entry.content; - } - }); - - return h('.w-100.flex-column', [posts.map((post, index) => h('.w-100', PostBox(post, index + 1)))]); + if (data && data.length !== 0) { + const id = parseInt(model.router.params.id); + const log = data.find((entry) => entry && entry.entryID === id); + return h('.w-100.flex-column', [log.content.map((post, index) => h('.w-100', PostBox(post, index + 1)))]); + } else { + return h('', [ + h('.danger', 'This log could not be found.'), + h('button.btn.btn-primary.mv3', { + onclick: () => model.router.go('?page=home'), + }, 'Return to Overview'), + ]); + } }; -export default (model) => [overviewScreen(model)]; +export default (model) => [logDetailScreen(model)]; diff --git a/lib/public/views/Overview/Overview.js b/lib/public/views/Logs/Logs.js similarity index 80% rename from lib/public/views/Overview/Overview.js rename to lib/public/views/Logs/Logs.js index 4df4c37814..d7e82bd49f 100644 --- a/lib/public/views/Overview/Overview.js +++ b/lib/public/views/Logs/Logs.js @@ -27,29 +27,57 @@ export default class Overview extends Observable { this.filterCriteria = []; this.data = []; this.filtered = []; + this.error = false; this.headers = ['ID', 'Author ID', 'Title', 'Creation Time']; - - this.fetchData(); } /** - * Get the headers from the Overview class - * @returns {Array} Returns the headers + * Retrieve every relevant log from the API + * @returns {undefined} Injects the data object with the response data */ - getHeaders() { - return this.headers; + async fetchAllLogs() { + const response = await fetchClient('/api/logs', { method: 'GET' }); + const result = await response.json(); + + if (result.data) { + this.data = result.data; + this.filtered = [...result.data]; + this.error = false; + } else { + this.error = true; + } + + this.notify(); } /** - * Fetch all relevant logs data from api + * Retrieve a specified log from the API + * @param {Number} id The ID of the log to be found * @returns {undefined} Injects the data object with the response data */ - async fetchData() { - const response = await fetchClient('/api/logs', { method: 'GET' }); - const result = await response.json(); - this.data = result.data; - this.filtered = [...result.data]; - this.notify(); + async fetchOneLog(id) { + // Do not call this endpoint again if we already have all the logs + if (this.data && this.data.length < 1) { + const response = await fetchClient(`/api/logs/${id}`, { method: 'GET' }); + const result = await response.json(); + + if (result.data) { + this.data = [result.data]; + this.error = false; + } else { + this.error = true; + } + + this.notify(); + } + } + + /** + * Get the headers from the Overview class + * @returns {Array} Returns the headers + */ + getHeaders() { + return this.headers; } /** @@ -57,7 +85,7 @@ export default class Overview extends Observable { * @returns {Array} Returns all of the data */ getData() { - return this.data; + return this.error ? null : this.data; } /** diff --git a/lib/public/views/Overview/General/page.js b/lib/public/views/Logs/Overview/page.js similarity index 80% rename from lib/public/views/Overview/General/page.js rename to lib/public/views/Logs/Overview/page.js index 9581f02cb1..7a4168a46b 100644 --- a/lib/public/views/Overview/General/page.js +++ b/lib/public/views/Logs/Overview/page.js @@ -19,10 +19,10 @@ import { table } from '../../../components/Table/index.js'; * @param {object} model Pass the model to access the defined functions * @return {vnode} Return the view of the table with the filtering options */ -const overviewScreen = (model) => { - const headers = model.overview.getHeaders(); - const data = model.overview.getDataWithoutTags(); - const tags = model.overview.getTagCounts(); +const logOverviewScreen = (model) => { + const headers = model.logs.getHeaders(); + const data = model.logs.getDataWithoutTags(); + const tags = model.logs.getTagCounts(); return h('.w-100.flex-row', [ filters(model, tags), @@ -30,4 +30,4 @@ const overviewScreen = (model) => { ]); }; -export default (model) => [overviewScreen(model)]; +export default (model) => [logOverviewScreen(model)]; diff --git a/lib/server/controllers/logs.controller.js b/lib/server/controllers/logs.controller.js index 753285575c..9ed158693d 100644 --- a/lib/server/controllers/logs.controller.js +++ b/lib/server/controllers/logs.controller.js @@ -11,8 +11,8 @@ * or submit itself to any jurisdiction. */ -const { log: { CreateLogUseCase, GetAllLogsUseCase } } = require('../../application/usecases'); -const { dtos: { CreateLogDto } } = require('../../domain'); +const { log: { CreateLogUseCase, GetAllLogsUseCase, GetLogUseCase } } = require('../../application/usecases'); +const { dtos: { CreateLogDto, GetLogDto } } = require('../../domain'); /** * Create a new log @@ -145,15 +145,39 @@ const patchRun = (request, response, next) => { * next middleware function. * @returns {undefined} */ -const read = (request, response, next) => { - response.status(501).json({ - errors: [ - { - status: '501', - title: 'Not implemented', - }, - ], - }); +const read = async (request, response, next) => { + const getLogDto = new GetLogDto(request.params); + const { valid, errors } = getLogDto.validate(); + + if (!valid) { + response.status(400).json({ + errors: errors.map((error) => ({ + status: '400', + source: { pointer: `/data/attributes/${error.path.join('/')}` }, + title: 'Invalid Attribute', + detail: error.message, + })), + }); + return; + } + + const log = await new GetLogUseCase() + .execute(getLogDto); + + if (log === null) { + response.status(404).json({ + errors: [ + { + status: '404', + title: `Log with this id (${request.params.id}) could not be found`, + }, + ], + }); + } else { + response.status(200).json({ + data: log, + }); + } }; /** diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 53c98f6482..42526a8195 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -37,6 +37,21 @@ paths: $ref: '#/components/responses/Created' '400': $ref: '#/components/responses/BadRequest' + /logs/:id: + get: + parameters: + - name: id + in: path + required: true + schema: + type: number + responses: + '200': + $ref: '#/components/responses/OK' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/BadRequest' components: schemas: @@ -54,7 +69,9 @@ components: type: object properties: data: - type: array + oneOf: + - type: array + - type: object required: - data DeployInformation: @@ -95,6 +112,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + OK: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DataResponse' Created: description: Created content: diff --git a/test/application/usecases/log/GetLogUseCase.test.js b/test/application/usecases/log/GetLogUseCase.test.js new file mode 100644 index 0000000000..bd9a30c743 --- /dev/null +++ b/test/application/usecases/log/GetLogUseCase.test.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { log: { GetLogUseCase } } = require('../../../../lib/application/usecases'); +const { dtos: { GetLogDto } } = require('../../../../lib/domain'); +const chai = require('chai'); + +const { expect } = chai; + +module.exports = () => { + let getLogDto; + + beforeEach(() => { + getLogDto = new GetLogDto({ id: 1 }); + }); + + it('should return an object that has the `entryID` property', async () => { + const result = await new GetLogUseCase() + .execute(getLogDto); + + expect(result).to.have.ownProperty('entryID'); + expect(result.entryID).to.equal(1); + }); +}; diff --git a/test/application/usecases/log/index.js b/test/application/usecases/log/index.js index 61a47ecde0..ebedba6f0c 100644 --- a/test/application/usecases/log/index.js +++ b/test/application/usecases/log/index.js @@ -13,8 +13,10 @@ const CreateLogUseCase = require('./CreateLogUseCase.test'); const GetAllLogsUseCase = require('./GetAllLogsUseCase.test'); +const GetLogUseCase = require('./GetLogUseCase.test'); module.exports = () => { describe('CreateLogUseCase', CreateLogUseCase); describe('GetAllLogsUseCase', GetAllLogsUseCase); + describe('GetLogUseCase', GetLogUseCase); }; diff --git a/test/e2e/logs.test.js b/test/e2e/logs.test.js index 6525020378..5e24312fbe 100644 --- a/test/e2e/logs.test.js +++ b/test/e2e/logs.test.js @@ -21,9 +21,9 @@ const { expect } = chai; chai.use(chaiResponseValidator(path.resolve(__dirname, '..', '..', 'spec', 'openapi.yaml'))); module.exports = () => { - describe('POST /api/logs', () => { - const { server } = require('../../lib/application'); + const { server } = require('../../lib/application'); + describe('POST /api/logs', () => { it('should return 400 if no title is provided', (done) => { request(server) .post('/api/logs') @@ -90,4 +90,104 @@ module.exports = () => { }); }); }); + + describe('GET /api/logs/:id', () => { + it('should return 400 if the log id is not a number', (done) => { + request(server) + .get('/api/logs/abc') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + // Response must satisfy the OpenAPI specification + expect(res).to.satisfyApiSpec; + + const titleError = res.body.errors.find((err) => err.source.pointer === '/data/attributes/id'); + expect(titleError.detail).to.equal('"id" must be a number'); + + done(); + }); + }); + + it('should return 400 if the log id is not positive', (done) => { + request(server) + .get('/api/logs/-1') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + // Response must satisfy the OpenAPI specification + expect(res).to.satisfyApiSpec; + + const titleError = res.body.errors.find((err) => err.source.pointer === '/data/attributes/id'); + expect(titleError.detail).to.equal('"id" must be a positive number'); + + done(); + }); + }); + + it('should return 400 if the log id is not a whole number', (done) => { + request(server) + .get('/api/logs/0.5') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + // Response must satisfy the OpenAPI specification + expect(res).to.satisfyApiSpec; + + const titleError = res.body.errors.find((err) => err.source.pointer === '/data/attributes/id'); + expect(titleError.detail).to.equal('"id" must be an integer'); + + done(); + }); + }); + + it('should return 404 if the log could not be found', (done) => { + request(server) + .get('/api/logs/999999999') + .expect(404) + .end((err, res) => { + if (err) { + done(err); + return; + } + + // Response must satisfy the OpenAPI specification + expect(res).to.satisfyApiSpec; + + expect(res.body.errors[0].title).to.equal('Log with this id (999999999) could not be found'); + + done(); + }); + }); + + it('should return 200 in all other cases', (done) => { + request(server) + .get('/api/logs/1') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + // Response must satisfy the OpenAPI specification + expect(res).to.satisfyApiSpec; + + expect(res.body.data.entryID).to.equal(1); + + done(); + }); + }); + }); }; diff --git a/test/public/index.js b/test/public/index.js index 0d5c94e652..84d69376f6 100644 --- a/test/public/index.js +++ b/test/public/index.js @@ -11,8 +11,8 @@ * or submit itself to any jurisdiction. */ -const OverviewSuite = require('./overview.test'); +const LogsSuite = require('./logs.test'); module.exports = () => { - describe('Overview', OverviewSuite); + describe('Logs', LogsSuite); }; diff --git a/test/public/overview.test.js b/test/public/logs.test.js similarity index 87% rename from test/public/overview.test.js rename to test/public/logs.test.js index 861abd72a9..0424e4314b 100644 --- a/test/public/overview.test.js +++ b/test/public/logs.test.js @@ -30,7 +30,7 @@ module.exports = function () { browser = await puppeteer.launch({ args: ['--no-sandbox'] }); page = await browser.newPage(); await Promise.all([ - page.coverage.startJSCoverage(), + page.coverage.startJSCoverage({ resetOnNavigation: false }), page.coverage.startCSSCoverage(), ]); @@ -109,4 +109,16 @@ module.exports = function () { const postExists = Boolean(await page.$('#post1')); expect(postExists).to.be.true; }); + + it('notifies if am specified log id is invalid', async () => { + // Navigate to a log detail view with an id that cannot exist + await page.goto(`${url}/?page=entry&id=abc`); + await page.waitFor(100); + + // We expect there to be an error message + const error = await page.$('.danger'); + expect(Boolean(error)).to.be.true; + const message = await page.evaluate((element) => element.innerText, error); + expect(message).to.equal('This log could not be found.'); + }); };