diff --git a/.prettierignore b/.prettierignore index 805dd09..d30b4be 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ coverage helm reports .release-please-manifest.json +HANDOFF.md diff --git a/openapi3.yaml b/openapi3.yaml index 710048e..74a26a0 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -7,71 +7,136 @@ info: name: MIT url: https://opensource.org/licenses/MIT paths: - /anotherResource: - get: - operationId: getAnotherResource + /record: + post: + operationId: createRecord tags: - - anotherResource - summary: gets the resource + - record + summary: Start an ingestion process flow + description: Validates the request (light & fast) and creates a Jobnik ingestion job. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ingestionPayload' + responses: + 201: + description: Ingestion job created + content: + application/json: + schema: + $ref: '#/components/schemas/jobResponse' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/{id}: + delete: + operationId: deleteRecord + tags: + - record + summary: Start a delete process flow + description: Validates the record can be deleted and creates a Jobnik delete job. + parameters: + - $ref: '#/components/parameters/recordId' responses: 200: - description: OK + description: Delete job created content: application/json: schema: - $ref: '#/components/schemas/anotherResource' + $ref: '#/components/schemas/jobResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' - /resourceName: - get: - operationId: getResourceName + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + patch: + operationId: updateRecord tags: - - resourceName - summary: gets the resource + - record + summary: Update metadata for a record + parameters: + - $ref: '#/components/parameters/recordId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updatePayload' responses: 200: - description: OK + description: Metadata updated content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/ackResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' - post: - operationId: createResource + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' + /record/status/{id}: + patch: + operationId: updateRecordStatus tags: - - resourceName - summary: creates a new record of type resource + - record + summary: Update the publish/unpublish status of a record + parameters: + - $ref: '#/components/parameters/recordId' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/statusPayload' responses: - 201: - description: created + 200: + description: Status updated content: application/json: schema: - $ref: '#/components/schemas/resource' + $ref: '#/components/schemas/ackResponse' 400: description: Bad Request content: application/json: schema: $ref: '#/components/schemas/error' + 404: + description: Record not found + content: + application/json: + schema: + $ref: '#/components/schemas/error' security: - {} components: + parameters: + recordId: + name: id + in: path + required: true + description: The record identifier + schema: + type: string schemas: error: type: object @@ -80,27 +145,51 @@ components: properties: message: type: string - resource: + ingestionPayload: type: object required: - - id - - name - - description + - modelPath + - tilesetFilename + - metadata properties: - id: - type: number - format: int64 - name: + modelPath: type: string - description: + description: Path on the shared storage to the 3DTiles folder or 3TZ archive + tilesetFilename: type: string - anotherResource: + description: The tileset entry name (e.g. tileset.json) + metadata: + type: object + description: 3D record metadata (business validation applied downstream) + additionalProperties: true + updatePayload: + type: object + description: Partial metadata fields to update + additionalProperties: true + statusPayload: + type: object + required: + - status + properties: + status: + type: string + enum: + - PUBLISHED + - UNPUBLISHED + jobResponse: + type: object + required: + - jobId + - status + properties: + jobId: + type: string + status: + type: string + ackResponse: type: object required: - - kind - - isAlive + - message properties: - kind: + message: type: string - isAlive: - type: boolean diff --git a/src/anotherResource/controllers/anotherResourceController.ts b/src/anotherResource/controllers/anotherResourceController.ts deleted file mode 100644 index 56f3879..0000000 --- a/src/anotherResource/controllers/anotherResourceController.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { type Registry, Counter } from 'prom-client'; -import httpStatus from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; -import { AnotherResourceManager } from '../models/anotherResourceManager'; - -@injectable() -export class AnotherResourceController { - private readonly getResourceCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(AnotherResourceManager) private readonly manager: AnotherResourceManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.getResourceCounter = new Counter({ - name: 'get_resource', - help: 'number of get resource requests', - registers: [this.metricsRegistry], - }); - } - - public getResource: TypedRequestHandlers['getAnotherResource'] = (req, res) => { - this.getResourceCounter.inc(1); - return res.status(httpStatus.OK).json(this.manager.getResource()); - }; -} diff --git a/src/anotherResource/models/anotherResourceManager.ts b/src/anotherResource/models/anotherResourceManager.ts deleted file mode 100644 index f54fcde..0000000 --- a/src/anotherResource/models/anotherResourceManager.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import { components } from '@src/openapi'; -import { SERVICES } from '@common/constants'; - -const resourceInstance: IAnotherResourceModel = { - kind: 'avi', - isAlive: false, -}; - -export type IAnotherResourceModel = components['schemas']['anotherResource']; - -@injectable() -export class AnotherResourceManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - public getResource(): IAnotherResourceModel { - this.logger.info('logging'); - return resourceInstance; - } -} diff --git a/src/anotherResource/routes/anotherResourceRouter.ts b/src/anotherResource/routes/anotherResourceRouter.ts deleted file mode 100644 index 937ba6d..0000000 --- a/src/anotherResource/routes/anotherResourceRouter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { AnotherResourceController } from '../controllers/anotherResourceController'; - -const anotherResourceRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(AnotherResourceController); - - router.get('/', controller.getResource); - - return router; -}; - -export const ANOTHER_RESOURCE_ROUTER_SYMBOL = Symbol('anotherResourceRouterFactory'); - -export { anotherResourceRouterFactory }; diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 455054c..1f49da0 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -2,3 +2,9 @@ export interface IConfig { get: (setting: string) => T; has: (setting: string) => boolean; } + +export interface LogContext { + fileName: string; + class: string; + function?: string; +} diff --git a/src/containerConfig.ts b/src/containerConfig.ts index ecd03fa..afe665b 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -6,8 +6,7 @@ import { jsLogger } from '@map-colonies/js-logger'; import { type InjectionObject, registerDependencies } from '@common/dependencyRegistration'; import { SERVICES, SERVICE_NAME } from '@common/constants'; import { getTracing } from '@common/tracing'; -import { resourceNameRouterFactory, RESOURCE_NAME_ROUTER_SYMBOL } from './resourceName/routes/resourceNameRouter'; -import { anotherResourceRouterFactory, ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; +import { recordRouterFactory, RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; import { getConfig } from './common/config'; export interface RegisterOptions { @@ -31,8 +30,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise { token: SERVICES.LOGGER, provider: { useValue: logger } }, { token: SERVICES.TRACER, provider: { useValue: tracer } }, { token: SERVICES.METRICS, provider: { useValue: metricsRegistry } }, - { token: RESOURCE_NAME_ROUTER_SYMBOL, provider: { useFactory: resourceNameRouterFactory } }, - { token: ANOTHER_RESOURCE_ROUTER_SYMBOL, provider: { useFactory: anotherResourceRouterFactory } }, + { token: RECORD_ROUTER_SYMBOL, provider: { useFactory: recordRouterFactory } }, { token: 'onSignal', provider: { diff --git a/src/openapi.d.ts b/src/openapi.d.ts index eb9ba76..ca8c610 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -4,39 +4,62 @@ import type { TypedRequestHandlers as ImportedTypedRequestHandlers } from '@map-colonies/openapi-express-types'; export type paths = { - '/anotherResource': { + '/record': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getAnotherResource']; + get?: never; put?: never; - post?: never; + /** + * Start an ingestion process flow + * @description Validates the request (light & fast) and creates a Jobnik ingestion job. + */ + post: operations['createRecord']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/resourceName': { + '/record/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** gets the resource */ - get: operations['getResourceName']; + get?: never; put?: never; - /** creates a new record of type resource */ - post: operations['createResource']; + post?: never; + /** + * Start a delete process flow + * @description Validates the record can be deleted and creates a Jobnik delete job. + */ + delete: operations['deleteRecord']; + options?: never; + head?: never; + /** Update metadata for a record */ + patch: operations['updateRecord']; + trace?: never; + }; + '/record/status/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Update the publish/unpublish status of a record */ + patch: operations['updateRecordStatus']; trace?: never; }; }; @@ -46,41 +69,95 @@ export type components = { error: { message: string; }; - resource: { - /** Format: int64 */ - id: number; - name: string; - description: string; + ingestionPayload: { + /** @description Path on the shared storage to the 3DTiles folder or 3TZ archive */ + modelPath: string; + /** @description The tileset entry name (e.g. tileset.json) */ + tilesetFilename: string; + /** @description 3D record metadata (business validation applied downstream) */ + metadata: { + [key: string]: unknown; + }; + }; + /** @description Partial metadata fields to update */ + updatePayload: { + [key: string]: unknown; + }; + statusPayload: { + /** @enum {string} */ + status: 'PUBLISHED' | 'UNPUBLISHED'; }; - anotherResource: { - kind: string; - isAlive: boolean; + jobResponse: { + jobId: string; + status: string; + }; + ackResponse: { + message: string; }; }; responses: never; - parameters: never; + parameters: { + /** @description The record identifier */ + recordId: string; + }; requestBodies: never; headers: never; pathItems: never; }; export type $defs = Record; export interface operations { - getAnotherResource: { + createRecord: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + requestBody: { + content: { + 'application/json': components['schemas']['ingestionPayload']; + }; + }; + responses: { + /** @description Ingestion job created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['jobResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; + }; + }; + deleteRecord: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; + cookie?: never; + }; requestBody?: never; responses: { - /** @description OK */ + /** @description Delete job created */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['anotherResource']; + 'application/json': components['schemas']['jobResponse']; }; }; /** @description Bad Request */ @@ -92,24 +169,40 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; - getResourceName: { + updateRecord: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['updatePayload']; + }; + }; responses: { - /** @description OK */ + /** @description Metadata updated */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['ackResponse']; }; }; /** @description Bad Request */ @@ -121,28 +214,40 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; - createResource: { + updateRecordStatus: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description The record identifier */ + id: components['parameters']['recordId']; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['statusPayload']; }; }; responses: { - /** @description created */ - 201: { + /** @description Status updated */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['resource']; + 'application/json': components['schemas']['ackResponse']; }; }; /** @description Bad Request */ @@ -154,6 +259,15 @@ export interface operations { 'application/json': components['schemas']['error']; }; }; + /** @description Record not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['error']; + }; + }; }; }; } diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts new file mode 100644 index 0000000..65a4bce --- /dev/null +++ b/src/record/controllers/recordController.ts @@ -0,0 +1,78 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { injectable, inject } from 'tsyringe'; +import { type Registry, Counter } from 'prom-client'; +import type { TypedRequestHandlers } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { RecordManager } from '../models/recordManager'; + +@injectable() +export class RecordController { + private readonly logContext: LogContext; + private readonly ingestionJobCounter: Counter; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(RecordManager) private readonly manager: RecordManager, + @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry + ) { + this.logContext = { + fileName: __filename, + class: RecordController.name, + }; + this.ingestionJobCounter = new Counter({ + name: 'ingestion_jobs_created', + help: 'number of ingestion jobs created', + registers: [this.metricsRegistry], + }); + } + + public createRecord: TypedRequestHandlers['createRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.createRecord.name }; + try { + const job = this.manager.createIngestion(req.body); + this.ingestionJobCounter.inc(1); + return res.status(StatusCodes.CREATED).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create ingestion job', logContext, err }); + return next(err); + } + }; + + public deleteRecord: TypedRequestHandlers['deleteRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.deleteRecord.name }; + const { id } = req.params; + try { + const job = this.manager.deleteRecord(id); + return res.status(StatusCodes.OK).json(job); + } catch (err) { + this.logger.error({ msg: 'failed to create delete job', logContext, err, recordId: id }); + return next(err); + } + }; + + public updateRecord: TypedRequestHandlers['updateRecord'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateRecord.name }; + const { id } = req.params; + try { + const ack = this.manager.updateMetadata(id, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record metadata', logContext, err, recordId: id }); + return next(err); + } + }; + + public updateRecordStatus: TypedRequestHandlers['updateRecordStatus'] = (req, res, next) => { + const logContext = { ...this.logContext, function: this.updateRecordStatus.name }; + const { id } = req.params; + try { + const ack = this.manager.updateStatus(id, req.body); + return res.status(StatusCodes.OK).json(ack); + } catch (err) { + this.logger.error({ msg: 'failed to update record status', logContext, err, recordId: id }); + return next(err); + } + }; +} diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts new file mode 100644 index 0000000..a152175 --- /dev/null +++ b/src/record/models/recordManager.ts @@ -0,0 +1,47 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { inject, injectable } from 'tsyringe'; +import type { components } from '@openapi'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; + +export type IngestionPayload = components['schemas']['ingestionPayload']; +export type UpdatePayload = components['schemas']['updatePayload']; +export type StatusPayload = components['schemas']['statusPayload']; +export type JobResponse = components['schemas']['jobResponse']; +export type AckResponse = components['schemas']['ackResponse']; + +@injectable() +export class RecordManager { + private readonly logContext: LogContext; + + public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + this.logContext = { + fileName: __filename, + class: RecordManager.name, + }; + } + + public createIngestion(payload: IngestionPayload): JobResponse { + const logContext = { ...this.logContext, function: this.createIngestion.name }; + this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); + return { jobId: 'stub-ingestion-job-id', status: 'PENDING' }; + } + + public deleteRecord(id: string): JobResponse { + const logContext = { ...this.logContext, function: this.deleteRecord.name }; + this.logger.info({ msg: 'creating delete job', logContext, recordId: id }); + return { jobId: 'stub-delete-job-id', status: 'PENDING' }; + } + + public updateMetadata(id: string, update: UpdatePayload): AckResponse { + const logContext = { ...this.logContext, function: this.updateMetadata.name }; + this.logger.info({ msg: 'updating record metadata', logContext, recordId: id, fields: Object.keys(update) }); + return { message: `metadata update accepted for record ${id}` }; + } + + public updateStatus(id: string, payload: StatusPayload): AckResponse { + const logContext = { ...this.logContext, function: this.updateStatus.name }; + this.logger.info({ msg: 'updating record status', logContext, recordId: id, status: payload.status }); + return { message: `status ${payload.status} accepted for record ${id}` }; + } +} diff --git a/src/record/routes/recordRouter.ts b/src/record/routes/recordRouter.ts new file mode 100644 index 0000000..4a5b495 --- /dev/null +++ b/src/record/routes/recordRouter.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import type { FactoryFunction } from 'tsyringe'; +import { RecordController } from '../controllers/recordController'; + +const recordRouterFactory: FactoryFunction = (dependencyContainer) => { + const router = Router(); + const controller = dependencyContainer.resolve(RecordController); + + router.post('/', controller.createRecord); + router.patch('/status/:id', controller.updateRecordStatus); + router.delete('/:id', controller.deleteRecord); + router.patch('/:id', controller.updateRecord); + + return router; +}; + +export const RECORD_ROUTER_SYMBOL = Symbol('recordRouterFactory'); + +export { recordRouterFactory }; diff --git a/src/resourceName/controllers/resourceNameController.ts b/src/resourceName/controllers/resourceNameController.ts deleted file mode 100644 index c194ea6..0000000 --- a/src/resourceName/controllers/resourceNameController.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import httpStatus from 'http-status-codes'; -import { injectable, inject } from 'tsyringe'; -import { type Registry, Counter } from 'prom-client'; -import type { TypedRequestHandlers } from '@openapi'; -import { SERVICES } from '@common/constants'; - -import { ResourceNameManager } from '../models/resourceNameManager'; - -@injectable() -export class ResourceNameController { - private readonly createdResourceCounter: Counter; - - public constructor( - @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(ResourceNameManager) private readonly manager: ResourceNameManager, - @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry - ) { - this.createdResourceCounter = new Counter({ - name: 'created_resource', - help: 'number of created resources', - registers: [this.metricsRegistry], - }); - } - - public getResource: TypedRequestHandlers['getResourceName'] = (req, res) => { - return res.status(httpStatus.OK).json(this.manager.getResource()); - }; - - public createResource: TypedRequestHandlers['POST /resourceName'] = (req, res) => { - const createdResource = this.manager.createResource(req.body); - this.createdResourceCounter.inc(1); - return res.status(httpStatus.CREATED).json(createdResource); - }; -} diff --git a/src/resourceName/models/resourceNameManager.ts b/src/resourceName/models/resourceNameManager.ts deleted file mode 100644 index be5aa5a..0000000 --- a/src/resourceName/models/resourceNameManager.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Logger } from '@map-colonies/js-logger'; -import { inject, injectable } from 'tsyringe'; -import type { components } from '@openapi'; -import { SERVICES } from '@common/constants'; - -const resourceInstance: IResourceNameModel = { - id: 1, - name: 'ronin', - description: 'can you do a logistics run?', -}; - -function generateRandomId(): number { - const rangeOfIds = 100; - return Math.floor(Math.random() * rangeOfIds); -} - -export type IResourceNameModel = components['schemas']['resource']; - -@injectable() -export class ResourceNameManager { - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) {} - - public getResource(): IResourceNameModel { - this.logger.info({ msg: 'getting resource', resourceId: resourceInstance.id }); - - return resourceInstance; - } - - public createResource(resource: IResourceNameModel): IResourceNameModel { - const resourceId = generateRandomId(); - - this.logger.info({ msg: 'creating resource', resourceId }); - - return { ...resource, id: resourceId }; - } -} diff --git a/src/resourceName/routes/resourceNameRouter.ts b/src/resourceName/routes/resourceNameRouter.ts deleted file mode 100644 index a0e3f4a..0000000 --- a/src/resourceName/routes/resourceNameRouter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import type { FactoryFunction } from 'tsyringe'; -import { ResourceNameController } from '../controllers/resourceNameController'; - -const resourceNameRouterFactory: FactoryFunction = (dependencyContainer) => { - const router = Router(); - const controller = dependencyContainer.resolve(ResourceNameController); - - router.get('/', controller.getResource); - router.post('/', controller.createResource); - - return router; -}; - -export const RESOURCE_NAME_ROUTER_SYMBOL = Symbol('resourceNameRouterFactory'); - -export { resourceNameRouterFactory }; diff --git a/src/serverBuilder.ts b/src/serverBuilder.ts index 5554627..3e2fc98 100644 --- a/src/serverBuilder.ts +++ b/src/serverBuilder.ts @@ -10,8 +10,7 @@ import { collectMetricsExpressMiddleware } from '@map-colonies/prometheus'; import { Registry } from 'prom-client'; import type { ConfigType } from '@common/config'; import { SERVICES } from '@common/constants'; -import { RESOURCE_NAME_ROUTER_SYMBOL } from './resourceName/routes/resourceNameRouter'; -import { ANOTHER_RESOURCE_ROUTER_SYMBOL } from './anotherResource/routes/anotherResourceRouter'; +import { RECORD_ROUTER_SYMBOL } from './record/routes/recordRouter'; @injectable() export class ServerBuilder { @@ -21,8 +20,7 @@ export class ServerBuilder { @inject(SERVICES.CONFIG) private readonly config: ConfigType, @inject(SERVICES.LOGGER) private readonly logger: Logger, @inject(SERVICES.METRICS) private readonly metricsRegistry: Registry, - @inject(RESOURCE_NAME_ROUTER_SYMBOL) private readonly resourceNameRouter: Router, - @inject(ANOTHER_RESOURCE_ROUTER_SYMBOL) private readonly anotherResourceRouter: Router + @inject(RECORD_ROUTER_SYMBOL) private readonly recordRouter: Router ) { this.serverInstance = express(); } @@ -45,8 +43,7 @@ export class ServerBuilder { } private buildRoutes(): void { - this.serverInstance.use('/resourceName', this.resourceNameRouter); - this.serverInstance.use('/anotherResource', this.anotherResourceRouter); + this.serverInstance.use('/record', this.recordRouter); this.buildDocsRoutes(); } diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts deleted file mode 100644 index fce250d..0000000 --- a/tests/integration/anotherResource/anotherResourceName.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; -import type { paths, operations } from '@openapi'; -import { getApp } from '@src/app'; -import { SERVICES } from '@src/common/constants'; -import { initConfig } from '@src/common/config'; - -describe('anotherResourceName', function () { - let requestSender: RequestSender; - - beforeAll(async function () { - await initConfig(true); - }); - - beforeEach(async function () { - const [app] = await getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = await createRequestSender('openapi3.yaml', app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getAnotherResource(); - - expect(response.status).toBe(httpStatusCodes.OK); - expect(response).toSatisfyApiSpec(); - - const resource = response.body as paths['/anotherResource']['get']['responses'][200]['content']['application/json']; - - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); - - describe('Bad Path', function () { - // All requests with status code of 400 - it('should in theory test 400 status code', function () { - expect(true).toBe(true); - }); - }); - - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - it('should in theory test 500 status code', function () { - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts new file mode 100644 index 0000000..9ac5a72 --- /dev/null +++ b/tests/integration/record/record.spec.ts @@ -0,0 +1,101 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; +import type { paths, operations } from '@openapi'; +import { getApp } from '@src/app'; +import { SERVICES } from '@common/constants'; +import { initConfig } from '@src/common/config'; + +const validIngestionPayload = { + modelPath: '/shared/models/afula', + tilesetFilename: 'tileset.json', + metadata: { productName: 'afula', productType: 'PHOTO_REALISTIC' }, +}; + +describe('record', function () { + let requestSender: RequestSender; + + beforeAll(async function () { + await initConfig(true); + }); + + beforeEach(async function () { + const [app] = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = await createRequestSender('openapi3.yaml', app); + }); + + describe('POST /record', function () { + it('should return 201 and a job response for a valid ingestion request', async function () { + const response = await requestSender.createRecord({ requestBody: validIngestionPayload }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.CREATED); + + const body = response.body as paths['/record']['post']['responses']['201']['content']['application/json']; + + expect(body.jobId).toBeTypeOf('string'); + expect(body.status).toBeTypeOf('string'); + }); + + it('should return 400 when a required field is missing', async function () { + const response = await requestSender.createRecord({ + // @ts-expect-error intentionally invalid: missing tilesetFilename and metadata + requestBody: { modelPath: '/shared/models/afula' }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); + }); + + describe('DELETE /record/{id}', function () { + it('should return 200 and a job response', async function () { + const response = await requestSender.deleteRecord({ pathParams: { id: 'rec-1' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + + const body = response.body as paths['/record/{id}']['delete']['responses']['200']['content']['application/json']; + + expect(body.jobId).toBeTypeOf('string'); + }); + }); + + describe('PATCH /record/{id}', function () { + it('should return 200 and an ack for a metadata update', async function () { + const response = await requestSender.updateRecord({ pathParams: { id: 'rec-1' }, requestBody: { description: 'updated' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + expect(response.body.message).toBeTypeOf('string'); + }); + }); + + describe('PATCH /record/status/{id}', function () { + it('should return 200 and an ack for a valid status change', async function () { + const response = await requestSender.updateRecordStatus({ pathParams: { id: 'rec-1' }, requestBody: { status: 'PUBLISHED' } }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + }); + + it('should return 400 for an invalid status value', async function () { + const response = await requestSender.updateRecordStatus({ + pathParams: { id: 'rec-1' }, + // @ts-expect-error intentionally invalid status enum value + requestBody: { status: 'NOT_A_STATUS' }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); + }); +}); diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts deleted file mode 100644 index be26f69..0000000 --- a/tests/integration/resourceName/resourceName.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; -import type { paths, operations } from '@openapi'; -import { getApp } from '@src/app'; -import { SERVICES } from '@common/constants'; -import { initConfig } from '@src/common/config'; - -describe('resourceName', function () { - let requestSender: RequestSender; - - beforeAll(async function () { - await initConfig(true); - }); - - beforeEach(async function () { - const [app] = await getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = await createRequestSender('openapi3.yaml', app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getResourceName(); - - expect(response.status).toBe(httpStatusCodes.OK); - - const resource = response.body as paths['/resourceName']['get']['responses'][200]['content']['application/json']; - - expect(response).toSatisfyApiSpec(); - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - - it('should return 200 status code and create the resource', async function () { - const response = await requestSender.createResource({ - requestBody: { - description: 'aaa', - id: 1, - name: 'aaa', - }, - }); - - expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.CREATED); - }); - }); - - describe('Bad Path', function () { - // All requests with status code of 400 - it('should in theory test 400 status code', function () { - expect(true).toBe(true); - }); - }); - - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - it('should in theory test 500 status code', function () { - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts b/tests/unit/anotherResource/models/anotherResourceManager.spec.ts deleted file mode 100644 index c086bb4..0000000 --- a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect } from 'vitest'; -import { AnotherResourceManager } from '@src/anotherResource/models/anotherResourceManager'; - -let anotherResourceManager: AnotherResourceManager; - -describe('ResourceNameManager', () => { - beforeEach(async function () { - anotherResourceManager = new AnotherResourceManager(await jsLogger({ enabled: false })); - }); - - describe('#getResource', () => { - it('should return resource of kind avi', function () { - // action - const resource = anotherResourceManager.getResource(); - - // expectation - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); -}); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts new file mode 100644 index 0000000..a6d87ab --- /dev/null +++ b/tests/unit/record/models/recordManager.spec.ts @@ -0,0 +1,51 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; + +describe('RecordManager', function () { + let manager: RecordManager; + + beforeEach(async function () { + manager = new RecordManager(await jsLogger({ enabled: false })); + }); + + describe('createIngestion', function () { + it('should return a job response with a jobId and status', function () { + const payload: IngestionPayload = { + modelPath: '/shared/models/afula', + tilesetFilename: 'tileset.json', + metadata: { productName: 'afula' }, + }; + + const result = manager.createIngestion(payload); + + expect(result.jobId).toBeTypeOf('string'); + expect(result.status).toBeTypeOf('string'); + }); + }); + + describe('deleteRecord', function () { + it('should return a job response', function () { + const result = manager.deleteRecord('rec-1'); + + expect(result.jobId).toBeTypeOf('string'); + expect(result.status).toBeTypeOf('string'); + }); + }); + + describe('updateMetadata', function () { + it('should return an ack referencing the record id', function () { + const result = manager.updateMetadata('rec-1', { description: 'x' }); + + expect(result.message).toContain('rec-1'); + }); + }); + + describe('updateStatus', function () { + it('should return an ack referencing the requested status', function () { + const result = manager.updateStatus('rec-1', { status: 'PUBLISHED' }); + + expect(result.message).toContain('PUBLISHED'); + }); + }); +}); diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts deleted file mode 100644 index 61d9bbe..0000000 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect } from 'vitest'; -import { ResourceNameManager } from '@src/resourceName/models/resourceNameManager'; - -let resourceNameManager: ResourceNameManager; - -describe('ResourceNameManager', () => { - beforeEach(async function () { - resourceNameManager = new ResourceNameManager(await jsLogger({ enabled: false })); - }); - - describe('#getResource', () => { - it('should return the resource of id 1', function () { - // action - const resource = resourceNameManager.getResource(); - - // expectation - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - }); - - describe('#createResource', () => { - it('should return the resource of id 1', function () { - // action - const resource = resourceNameManager.createResource({ description: 'meow', id: 1, name: 'cat' }); - - // expectation - expect(resource.id).toBeLessThanOrEqual(100); - expect(resource.id).toBeGreaterThanOrEqual(0); - expect(resource).toHaveProperty('name', 'cat'); - expect(resource).toHaveProperty('description', 'meow'); - }); - }); -});