diff --git a/lib/database/adapters/AttachmentAdapter.js b/lib/database/adapters/AttachmentAdapter.js index 5f1de2bcb4..79b29e876f 100644 --- a/lib/database/adapters/AttachmentAdapter.js +++ b/lib/database/adapters/AttachmentAdapter.js @@ -39,8 +39,6 @@ class AttachmentAdapter { path, encoding, logId, - createdAt, - updatedAt, } = databaseObject; return { @@ -52,8 +50,6 @@ class AttachmentAdapter { path, encoding, logId, - createdAt: new Date(createdAt).getTime(), - updatedAt: new Date(updatedAt).getTime(), }; } diff --git a/lib/domain/dtos/CreateAttachmentDto.js b/lib/domain/dtos/CreateAttachmentDto.js index 7348ab19e6..7ab696a1e6 100644 --- a/lib/domain/dtos/CreateAttachmentDto.js +++ b/lib/domain/dtos/CreateAttachmentDto.js @@ -19,7 +19,7 @@ const BodyDto = Joi.object({ log: EntityIdDto, }); -const FilesDto = Joi.array().items(FileDto); +const FilesDto = Joi.array().items(FileDto).min(1); const QueryDto = Joi.object({ token: Joi.string(), diff --git a/lib/domain/dtos/GetAttachmentDto.js b/lib/domain/dtos/GetAttachmentDto.js index 27ab33bebc..f5600c01c8 100644 --- a/lib/domain/dtos/GetAttachmentDto.js +++ b/lib/domain/dtos/GetAttachmentDto.js @@ -22,10 +22,10 @@ const ParamsDto = Joi.object({ attachmentId: EntityIdDto, }); -const GetLogAttachmentDto = Joi.object({ +const GetAttachmentDto = Joi.object({ body: Joi.object({}), query: QueryDto, params: ParamsDto, }); -module.exports = GetLogAttachmentDto; +module.exports = GetAttachmentDto; diff --git a/lib/domain/entities/Attachment.js b/lib/domain/entities/Attachment.js index 841ca8f9ee..b09e165db2 100644 --- a/lib/domain/entities/Attachment.js +++ b/lib/domain/entities/Attachment.js @@ -22,6 +22,4 @@ * @property {string} path * @property {string} encoding * @property {number} logId - * @property {number} createdAt - * @property {number} updatedAt */ diff --git a/lib/server/api-docs/API.md b/lib/server/api-docs/API.md index 40491c9aa2..83227440ca 100644 --- a/lib/server/api-docs/API.md +++ b/lib/server/api-docs/API.md @@ -593,3 +593,56 @@ curl -g -X POST "$BOOKKEEPING_URL/api/eos-report?reportType=ECS&token=$TOKEN" \ -H 'content-type: application/json' \ -d '{"shifterName":"John Doe","shiftFlow":"The shift flow","infoForNextShifter":"Info for the next shifter","typeSpecific":{"environmentComments":{"ENV1":"A comment about the environment"},"runComments":{"200":"A comment about the run"}}}' ``` + +## Attachments + +Files attached to log entries. + +### `POST /attachments` - Upload a file as an attachment (not for LLM use) + +Operation id: `createAttachment` + +Uploads **one** file (multipart request) and stores it as an attachment, linked to a log if `log` is given. + +The files of a request are all written on the server, but only the first one is stored as an attachment. A request without any file is rejected with a `400`. +Logs and their attachments are usually created together with `POST /logs`. + +**Not meant for LLM or MCP tool use** (`x-llm-exclude`). + +Request body (multipart/form-data): + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `log` | integer | no | Identifier of the log to attach the file to. | +| `attachments` | string (binary) | yes | The file to upload. | + +Responses: + +- `201`: The stored attachment. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. + +```bash +curl -g -X POST "$BOOKKEEPING_URL/api/attachments?token=$TOKEN" \ + -F 'log=1' \ + -F 'attachments=@/path/to/file.png' +``` + +### `GET /attachments/{attachmentId}` - Download the file of an attachment + +Operation id: `getAttachmentFile` + +Returns the content of the file, with the type of the file as `Content-Type`. Use `curl -OJ` to save it with the name given by the server. + +| Parameter | In | Type | Required | Notes | +| --- | --- | --- | --- | --- | +| `attachmentId` | path | integer | yes | Attachment identifier. | + +Responses: + +- `200`: The file. +- `400`: The request failed validation. HTTP status is 400 although each error's `status` field is `"422"`. `source.pointer` has the form `/data/attributes//`. +- `404`: No attachment has this id. + +```bash +curl -g -OJ "$BOOKKEEPING_URL/api/attachments/1?token=$TOKEN" +``` diff --git a/lib/server/api-docs/index.html b/lib/server/api-docs/index.html index d0f22f83d4..79ac65edd1 100644 --- a/lib/server/api-docs/index.html +++ b/lib/server/api-docs/index.html @@ -1085,6 +1085,81 @@

End of shift reports

-d '{"shifterName":"John Doe","shiftFlow":"The shift flow","infoForNextShifter":"Info for the next shifter","typeSpecific":{"environmentComments":{"ENV1":"A comment about the environment"},"runComments":{"200":"A comment about the run"}}}' +

Attachments

+

Files attached to log entries.

+
+

POST /attachments - Upload a file as an attachment not for LLM use

+ +

Operation id: createAttachment

+

Uploads one file (multipart request) and stores it as an attachment, linked to a log if log is given.

+

The files of a request are all written on the server, but only the first one is stored as an attachment. A request without any file is rejected with a 400. +Logs and their attachments are usually created together with POST /logs.

+

Not meant for LLM or MCP tool use (x-llm-exclude).

+

Request body (multipart/form-data):

+ + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredNotes
logintegernoIdentifier of the log to attach the file to.
attachmentsstring (binary)yesThe file to upload.
+

Responses:

+
    +
  • 201: The stored attachment.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
+
curl -g -X POST "$BOOKKEEPING_URL/api/attachments?token=$TOKEN" \
+  -F 'log=1' \
+  -F 'attachments=@/path/to/file.png'
+
+
+
+

GET /attachments/{attachmentId} - Download the file of an attachment

+ +

Operation id: getAttachmentFile

+

Returns the content of the file, with the type of the file as Content-Type. Use curl -OJ to save it with the name given by the server.

+ + + + + + + + + + + + + + + + + +
ParameterInTypeRequiredNotes
attachmentIdpathintegeryesAttachment identifier.
+

Responses:

+
    +
  • 200: The file.
  • +
  • 400: The request failed validation. HTTP status is 400 although each error's status field is "422". source.pointer has the form /data/attributes/<query|params|body>/<field>.
  • +
  • 404: No attachment has this id.
  • +
+
curl -g -OJ "$BOOKKEEPING_URL/api/attachments/1?token=$TOKEN"
+
+
Generated from openapi.yaml by npm run docs:api · ALICE Bookkeeping · internal use only
diff --git a/lib/server/api-docs/openapi.yaml b/lib/server/api-docs/openapi.yaml index 229601b0e2..4bae027099 100644 --- a/lib/server/api-docs/openapi.yaml +++ b/lib/server/api-docs/openapi.yaml @@ -57,6 +57,8 @@ tags: description: Detectors whose QC flags contribute to the global aggregated quality (GAQ) of a run in a data pass. - name: End of shift reports description: End-of-shift (EoS) reports written by the shifters. + - name: Attachments + description: Files attached to log entries. paths: /tags: @@ -736,6 +738,60 @@ paths: errors: - { status: 404, title: Not found, detail: User with this external id (1) could not be found } + /attachments: + post: + tags: [Attachments] + operationId: createAttachment + x-llm-exclude: true + summary: Upload a file as an attachment + description: | + Uploads **one** file (multipart request) and stores it as an attachment, linked to a log if `log` is given. + + The files of a request are all written on the server, but only the first one is stored as an attachment. A request without any file is rejected with a `400`. + Logs and their attachments are usually created together with `POST /logs`. + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: '#/components/schemas/CreateAttachment' } + example: + log: 1 + attachments: '@/path/to/file.png' + responses: + '201': + description: The stored attachment. + content: + application/json: + schema: { $ref: '#/components/schemas/StoredAttachmentResponse' } + '400': { $ref: '#/components/responses/ValidationError' } + + /attachments/{attachmentId}: + get: + tags: [Attachments] + operationId: getAttachmentFile + summary: Download the file of an attachment + description: Returns the content of the file, with the type of the file as `Content-Type`. Use `curl -OJ` to save it with the name given by the server. + parameters: + - name: attachmentId + in: path + required: true + description: Attachment identifier. + schema: { type: integer, minimum: 1 } + responses: + '200': + description: The file. + content: + application/octet-stream: + schema: { type: string, format: binary } + '400': { $ref: '#/components/responses/ValidationError' } + '404': + description: No attachment has this id. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + example: + errors: [{ status: 404, title: Not found, detail: Attachment with this id (999999999) could not be found }] + components: securitySchemes: jwtQuery: @@ -1152,3 +1208,26 @@ components: id: { type: integer } title: { type: string, description: 'Title of the report, e.g. `End of shift report - ECS - 17/03/2023 Morning`.' } additionalProperties: true + CreateAttachment: + type: object + required: [attachments] + properties: + log: { type: integer, minimum: 1, description: Identifier of the log to attach the file to. } + attachments: { type: string, format: binary, description: The file to upload. } + StoredAttachment: + type: object + description: An attachment as stored on the server. + properties: + id: { type: integer } + fileName: { type: string, description: Name of the file on the server (the upload time is prepended to the original name). } + originalName: { type: string } + mimeType: { type: string, examples: [image/png] } + encoding: { type: string, examples: [7bit] } + size: { type: integer, description: Size of the file in bytes. } + path: { type: string, description: Path of the file on the server. } + logId: { type: [integer, 'null'] } + StoredAttachmentResponse: + type: object + required: [data] + properties: + data: { $ref: '#/components/schemas/StoredAttachment' } diff --git a/lib/server/controllers/attachments.controller.js b/lib/server/controllers/attachments.controller.js index 2fcf734bb0..fc7e608ceb 100644 --- a/lib/server/controllers/attachments.controller.js +++ b/lib/server/controllers/attachments.controller.js @@ -10,9 +10,10 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -const { attachment: { CreateAttachmentUseCase, GetAttachmentUseCase } } = require('../../usecases'); const { dtos: { CreateAttachmentDto, GetAttachmentDto } } = require('../../domain'); const { dtoValidator } = require('../utilities'); +const { attachmentService } = require('../services/attachment/AttachmentService.js'); +const { updateExpressResponseFromNativeError } = require('../express/updateExpressResponseFromNativeError.js'); /** * Create new attachment on log @@ -21,7 +22,7 @@ const { dtoValidator } = require('../utilities'); * string, parameters, body, HTTP headers, and so on. * @param {Object} response The *response* object represents the HTTP response that an Express app sends when it gets an * HTTP request. - * @returns {undefined} + * @returns {Promise} resolves once the response has been sent */ const createAttachment = async (request, response) => { const value = await dtoValidator(CreateAttachmentDto, request, response); @@ -29,44 +30,36 @@ const createAttachment = async (request, response) => { return; } - const attachment = await new CreateAttachmentUseCase().execute(value); - - response.status(201).json({ - data: attachment, - }); + try { + const { files: [file], body: { log: logId } } = value; + response.status(201).json({ data: await attachmentService.create(file, logId) }); + } catch (error) { + updateExpressResponseFromNativeError(response, error); + } }; /** - * Find all Attachments that belong to a certain log item + * Send the file of an attachment * * @param {Object} request The *request* object represents the HTTP request and has properties for the request query * string, parameters, body, HTTP headers, and so on. * @param {Object} response The *response* object represents the HTTP response that an Express app sends when it gets an * HTTP request. - * @param {Function} next The *next* object represents the next middleware function which is used to pass control to the - * next middleware function. - * @returns {undefined} + * @returns {Promise} resolves once the response has been sent */ -const readAttachment = async (request, response, next) => { +const readAttachment = async (request, response) => { const value = await dtoValidator(GetAttachmentDto, request, response); if (!value) { return; } - const { attachment } = await new GetAttachmentUseCase() - .execute(value); - if (!attachment) { - response.status(400).json({ - errors: [ - { - status: '404', - title: `Attachment with this id (${request.params.attachmentId}) could not be found`, - }, - ], - }); - return; + try { + const { params: { attachmentId } } = value; + const attachment = await attachmentService.getOneOrFail(attachmentId); + response.status(200).sendFile(attachment.path); + } catch (error) { + updateExpressResponseFromNativeError(response, error); } - response.status(200).sendFile(attachment.path); }; module.exports = { diff --git a/lib/server/services/attachment/AttachmentService.js b/lib/server/services/attachment/AttachmentService.js new file mode 100644 index 0000000000..aa88ed74b6 --- /dev/null +++ b/lib/server/services/attachment/AttachmentService.js @@ -0,0 +1,50 @@ +/** + * @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 { attachmentAdapter } = require('../../../database/adapters/index.js'); +const { dataSource } = require('../../../database/DataSource.js'); +const { createAttachment } = require('./createAttachment.js'); +const { getAttachmentOrFail } = require('./getAttachmentOrFail.js'); + +/** + * Global service to handle attachments + */ +class AttachmentService { + /** + * Return the attachment matching the given id, or reject if there is none + * + * @param {number} id the id of the attachment to find + * @return {Promise} resolves with the attachment found or rejects with a NotFoundError + */ + async getOneOrFail(id) { + return attachmentAdapter.toEntity(await getAttachmentOrFail(id)); + } + + /** + * Store a file as an attachment, optionally linked to a log + * + * @param {Object} file the file as provided by the upload middleware (multer) + * @param {number} [logId] the id of the log to link the attachment to + * @return {Promise} resolves with the created attachment + */ + async create(file, logId) { + return dataSource.transaction(async () => { + const attachmentId = await createAttachment({ ...attachmentAdapter.toDatabase(file), logId }); + return attachmentAdapter.toEntity(await getAttachmentOrFail(attachmentId)); + }); + } +} + +exports.AttachmentService = AttachmentService; + +exports.attachmentService = new AttachmentService(); diff --git a/lib/server/services/attachment/createAttachment.js b/lib/server/services/attachment/createAttachment.js new file mode 100644 index 0000000000..5fd110e47d --- /dev/null +++ b/lib/server/services/attachment/createAttachment.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 { AttachmentRepository } = require('../../../database/repositories/index.js'); + +/** + * Create an attachment in the database and return the auto generated id + * + * @param {Partial} attachment the attachment to create + * @return {Promise} resolve once the creation is done providing the id of the attachment that have been (or will be) created + */ +exports.createAttachment = async (attachment) => { + const { /** @type {number} id */ id } = await AttachmentRepository.insert(attachment); + return id; +}; diff --git a/lib/server/services/attachment/getAttachment.js b/lib/server/services/attachment/getAttachment.js new file mode 100644 index 0000000000..775ac4417c --- /dev/null +++ b/lib/server/services/attachment/getAttachment.js @@ -0,0 +1,32 @@ +/** + * @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 { utilities: { QueryBuilder } } = require('../../../database'); +const { AttachmentRepository } = require('../../../database/repositories/index.js'); + +/** + * Find and return an attachment model by its id + * + * @param {number} id the id of the attachment to find + * @param {Function|null} qbConfiguration function called with the attachment find query builder as parameter to add specific configuration to + * the query + * @return {Promise} the attachment found or null + */ +exports.getAttachment = (id, qbConfiguration = null) => { + const queryBuilder = new QueryBuilder().where('id').is(id); + + if (qbConfiguration) { + qbConfiguration(queryBuilder); + } + return AttachmentRepository.findOne(queryBuilder); +}; diff --git a/lib/server/services/attachment/getAttachmentOrFail.js b/lib/server/services/attachment/getAttachmentOrFail.js new file mode 100644 index 0000000000..fdf677e35b --- /dev/null +++ b/lib/server/services/attachment/getAttachmentOrFail.js @@ -0,0 +1,29 @@ +/** + * @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 { NotFoundError } = require('../../errors/NotFoundError.js'); +const { getAttachment } = require('./getAttachment.js'); + +/** + * Find an attachment model by its id and reject with a NotFoundError if none is found + * + * @param {number} id the id of the attachment to find + * @return {Promise} resolve with the attachment model found or reject with a NotFoundError + */ +exports.getAttachmentOrFail = async (id) => { + const attachmentModel = await getAttachment(id); + if (attachmentModel === null) { + throw new NotFoundError(`Attachment with this id (${id}) could not be found`); + } + return attachmentModel; +}; diff --git a/lib/usecases/attachment/CreateAttachmentUseCase.js b/lib/usecases/attachment/CreateAttachmentUseCase.js deleted file mode 100644 index 0b943c3e9f..0000000000 --- a/lib/usecases/attachment/CreateAttachmentUseCase.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @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 { - repositories: { - AttachmentRepository, - }, - utilities: { - TransactionHelper, - }, -} = require('../../database'); -const { attachmentAdapter } = require('../../database/adapters/index.js'); - -/** - * CreateAttachmentUseCase - */ -class CreateAttachmentUseCase { - /** - * Executes this use case. - * - * @param {Object} dto The CreateLogDto containing all data. - * @returns {Promise} Promise object represents the result of this use case. - */ - execute(dto) { - const { files, body } = dto; - return TransactionHelper.provide(() => AttachmentRepository.insert({ ...attachmentAdapter.toDatabase(files[0]), logId: body.log })); - } -} - -module.exports = CreateAttachmentUseCase; diff --git a/lib/usecases/attachment/GetAttachmentUseCase.js b/lib/usecases/attachment/GetAttachmentUseCase.js deleted file mode 100644 index 3b1f7a15c3..0000000000 --- a/lib/usecases/attachment/GetAttachmentUseCase.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @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 { - repositories: { - AttachmentRepository, - }, - utilities: { - QueryBuilder, - TransactionHelper, - }, -} = require('../../database'); - -/** - * GetAttachment - */ -class GetAttachment { - /** - * Executes this use case. - * - * @param {Object} dto The CreateLogDto containing all data. - * @returns {Promise} Promise object represents the result of this use case. - */ - async execute(dto) { - const queryBuilder = new QueryBuilder(); - const { params } = dto; - const { attachmentId } = params; - - queryBuilder.where('id').is(attachmentId); - - return TransactionHelper - .provide(async () => ({ attachment: await AttachmentRepository.findOne(queryBuilder) })); - } -} - -module.exports = GetAttachment; diff --git a/lib/usecases/index.js b/lib/usecases/index.js index 4c1009a9db..fe043e6a26 100644 --- a/lib/usecases/index.js +++ b/lib/usecases/index.js @@ -11,7 +11,6 @@ * or submit itself to any jurisdiction. */ -const attachment = require('./attachment'); const environment = require('./environment'); const flp = require('./flp'); const lhcFill = require('./lhcFill'); @@ -22,7 +21,6 @@ const status = require('./status'); const tag = require('./tag'); module.exports = { - attachment, environment, flp, lhcFill, diff --git a/scripts/build-api-docs.js b/scripts/build-api-docs.js index 2e4107fa4c..56ced9acbc 100644 --- a/scripts/build-api-docs.js +++ b/scripts/build-api-docs.js @@ -151,6 +151,10 @@ const sampleValue = (parameter) => { * @return {string} the markdown */ const examplesOf = (method, route, operation, parameters) => { + // A response that is not JSON (a file for example) is saved with the name given by the server instead of being printed in the terminal + const successContentTypes = Object.keys(operation.responses?.['200']?.content ?? {}); + const isFileDownload = successContentTypes.length > 0 && !successContentTypes.includes('application/json'); + const url = route.replace(/{(\w+)}/g, (_, name) => sampleValue(parameters.find((parameter) => parameter.name === name))); const queryParameters = parameters .filter(({ in: location, name, required }) => location === 'query' && (required || name === 'page[limit]')); @@ -158,10 +162,13 @@ const examplesOf = (method, route, operation, parameters) => { // $BOOKKEEPING_URL and $TOKEN are meant to be expanded by the shell, anything coming from the spec is not const address = `${shellDoubleQuoteEscape(`/api${url}?${[...query, 'token='].join('&')}`)}$TOKEN`; const body = operation.requestBody?.content?.['application/json']?.example; + // For a multipart body, the example maps each field to its value, a file being given as `@path` + const formFields = operation.requestBody?.content?.['multipart/form-data']?.example; const curlLines = [ - `curl -g${method === 'get' ? '' : ` -X ${method.toUpperCase()}`} "$BOOKKEEPING_URL${address}"`, + `curl -g${isFileDownload ? ' -OJ' : ''}${method === 'get' ? '' : ` -X ${method.toUpperCase()}`} "$BOOKKEEPING_URL${address}"`, ...body === undefined ? [] : [' -H \'content-type: application/json\'', ` -d ${shellSingleQuote(JSON.stringify(body))}`], + ...Object.entries(formFields ?? {}).map(([field, value]) => ` -F ${shellSingleQuote(`${field}=${value}`)}`), ]; return [ @@ -211,9 +218,11 @@ const renderOperation = (method, route, operation, sharedParameters) => { ); } - const requestSchema = operation.requestBody?.content?.['application/json']?.schema; + const requestContent = operation.requestBody?.content ?? {}; + const requestSchema = requestContent['application/json']?.schema ?? requestContent['multipart/form-data']?.schema; if (requestSchema) { - out.push('Request body (JSON):', '', propertiesTable(requestSchema), ''); + const bodyFormat = requestContent['application/json'] ? 'JSON' : 'multipart/form-data'; + out.push(`Request body (${bodyFormat}):`, '', propertiesTable(requestSchema), ''); } out.push( diff --git a/test/api/attachments.test.js b/test/api/attachments.test.js index 4c39bd1000..72135bd508 100644 --- a/test/api/attachments.test.js +++ b/test/api/attachments.test.js @@ -17,6 +17,16 @@ const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js') const { server } = require('../../lib/application'); const { expect } = require('chai'); +/** + * Return the validation errors of a response as an object mapping the pointer of each error to its detail + * + * @param {object} response the response + * @return {Object} the details of the errors by pointer + */ +const getValidationErrors = (response) => Object.fromEntries( + response.body.errors.map(({ source: { pointer }, detail }) => [pointer.replace('/data/attributes/', ''), detail]), +); + module.exports = () => { before(resetDatabaseContent); @@ -27,6 +37,22 @@ module.exports = () => { .field('log', '1') .attach('attachments', path.resolve(__dirname, '..', 'assets', '1200px-CERN_logo.png')); expect(response.status).to.equal(201); + expect(response.body.data).to.have.all.keys('id', 'fileName', 'size', 'mimeType', 'originalName', 'path', 'encoding', 'logId'); + expect(response.body.data.logId).to.equal(1); + }); + + it('should return 400 if no file is given', async () => { + const response = await request(server).post('/api/attachments').field('log', '1'); + + expect(response.status).to.equal(400); + expect(getValidationErrors(response)).to.eql({ files: '"files" must contain at least 1 items' }); + }); + + it('should return 400 if the log id is not a positive integer', async () => { + const response = await request(server).post('/api/attachments').field('log', 'abc'); + + expect(response.status).to.equal(400); + expect(getValidationErrors(response)['body/log']).to.equal('"body.log" must be a number'); }); }); @@ -35,5 +61,21 @@ module.exports = () => { const response = await request(server).get('/api/attachments/1'); expect(response.status).to.equal(200); }); + + it('should return 404 if the attachment does not exist', async () => { + const response = await request(server).get('/api/attachments/999999999'); + + expect(response.status).to.equal(404); + expect(response.body.errors).to.eql([ + { status: 404, title: 'Not found', detail: 'Attachment with this id (999999999) could not be found' }, + ]); + }); + + it('should return 400 if the attachment id is not a positive integer', async () => { + const response = await request(server).get('/api/attachments/0'); + + expect(response.status).to.equal(400); + expect(getValidationErrors(response)).to.eql({ 'params/attachmentId': '"params.attachmentId" must be a positive number' }); + }); }); }; diff --git a/test/lib/server/services/attachment/AttachmentService.test.js b/test/lib/server/services/attachment/AttachmentService.test.js new file mode 100644 index 0000000000..eb05c1a388 --- /dev/null +++ b/test/lib/server/services/attachment/AttachmentService.test.js @@ -0,0 +1,56 @@ +/** + * @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 { expect } = require('chai'); +const { attachmentService } = require('../../../../../lib/server/services/attachment/AttachmentService.js'); +const { NotFoundError } = require('../../../../../lib/server/errors/NotFoundError.js'); + +module.exports = () => { + it('should successfully get an attachment by its id, without timestamps', async () => { + const attachment = await attachmentService.getOneOrFail(1); + expect(attachment.id).to.equal(1); + expect(attachment).to.have.all.keys('id', 'fileName', 'size', 'mimeType', 'originalName', 'path', 'encoding', 'logId'); + }); + + it('should fail to get an attachment that does not exist', async () => { + let error; + try { + await attachmentService.getOneOrFail(999999999); + } catch (e) { + error = e; + } + expect(error).to.be.instanceOf(NotFoundError); + expect(error.message).to.equal('Attachment with this id (999999999) could not be found'); + }); + + it('should successfully create an attachment linked to a log', async () => { + const attachment = await attachmentService.create({ + size: 12, + path: '/tmp/attachment-service-test.png', + filename: 'attachment-service-test.png', + originalname: 'test.png', + mimetype: 'image/png', + encoding: '7bit', + }, 1); + + expect(attachment).to.deep.include({ + fileName: 'attachment-service-test.png', + originalName: 'test.png', + mimeType: 'image/png', + encoding: '7bit', + size: 12, + logId: 1, + }); + expect(await attachmentService.getOneOrFail(attachment.id)).to.eql(attachment); + }); +}; diff --git a/lib/usecases/attachment/index.js b/test/lib/server/services/attachment/index.js similarity index 70% rename from lib/usecases/attachment/index.js rename to test/lib/server/services/attachment/index.js index 7d2e69bec9..7ed9766c0f 100644 --- a/lib/usecases/attachment/index.js +++ b/test/lib/server/services/attachment/index.js @@ -11,10 +11,8 @@ * or submit itself to any jurisdiction. */ -const CreateAttachmentUseCase = require('./CreateAttachmentUseCase'); -const GetAttachmentUseCase = require('./GetAttachmentUseCase'); +const AttachmentServiceTest = require('./AttachmentService.test.js'); -module.exports = { - CreateAttachmentUseCase, - GetAttachmentUseCase, +module.exports = () => { + describe('AttachmentService', AttachmentServiceTest); }; diff --git a/test/lib/server/services/index.js b/test/lib/server/services/index.js index 7ec4e2d9c6..0dd92a62dc 100644 --- a/test/lib/server/services/index.js +++ b/test/lib/server/services/index.js @@ -24,6 +24,7 @@ const FlpRoleSuite = require('./flpRole'); const LhcFillSuite = require('./lhcFill'); const LogSuite = require('./log'); const RunSuite = require('./run/index.js'); +const AttachmentSuite = require('./attachment/index.js'); const RunTypeSuite = require('./runType/index.js'); const ShiftSuite = require('./shift/index.js'); const StatisticsSuite = require('./statistics/index.js'); @@ -51,6 +52,7 @@ module.exports = () => { describe('GaqDetector', GaqDetectorSuite); describe('LHC fill suite', LhcFillSuite); describe('Logs', LogSuite); + describe('Attachment', AttachmentSuite); describe('RunType', RunTypeSuite); describe('Run', RunSuite); describe('Shift', ShiftSuite);