diff --git a/lib/modules/asset/AssetModule.ts b/lib/modules/asset/AssetModule.ts index 5864542d..4cb7f1d1 100644 --- a/lib/modules/asset/AssetModule.ts +++ b/lib/modules/asset/AssetModule.ts @@ -3,6 +3,7 @@ import { Module } from "../shared/Module"; import { AssetHistoryService } from "./AssetHistoryService"; import { AssetsController } from "./AssetsController"; import { AssetService } from "./AssetService"; +import { AssetsGroupsController } from "./AssetsGroupsController"; import { RoleAssetsAdmin } from "./roles/RoleAssetsAdmin"; import { RoleAssetsReader } from "./roles/RoleAssetsReader"; @@ -10,11 +11,16 @@ export class AssetModule extends Module { private assetService: AssetService; private assetHistoryService: AssetHistoryService; private assetController: AssetsController; + private assetGroupsController: AssetsGroupsController; public async init(): Promise { this.assetHistoryService = new AssetHistoryService(this.plugin); this.assetService = new AssetService(this.plugin, this.assetHistoryService); this.assetController = new AssetsController(this.plugin, this.assetService); + this.assetGroupsController = new AssetsGroupsController(this.plugin); + + this.plugin.api["device-manager/assetsGroup"] = + this.assetGroupsController.definition; this.plugin.api["device-manager/assets"] = this.assetController.definition; diff --git a/lib/modules/asset/AssetsGroupsController.ts b/lib/modules/asset/AssetsGroupsController.ts new file mode 100644 index 00000000..283f1428 --- /dev/null +++ b/lib/modules/asset/AssetsGroupsController.ts @@ -0,0 +1,467 @@ +import { + BadRequestError, + ControllerDefinition, + EmbeddedSDK, + KuzzleRequest, + NameGenerator, + User, +} from "kuzzle"; + +import { DeviceManagerPlugin, InternalCollection } from "../plugin"; +import { AssetContent } from "./exports"; +import { + AssetsGroupContent, + AssetsGroupsBody, +} from "./types/AssetGroupContent"; +import { + ApiGroupAddAssetsRequest, + ApiGroupAddAssetsResult, + ApiGroupCreateResult, + ApiGroupDeleteResult, + ApiGroupGetResult, + ApiGroupRemoveAssetsRequest, + ApiGroupRemoveAssetsResult, + ApiGroupSearchResult, + ApiGroupUpdateResult, + AssetsGroupsBodyRequest, +} from "./types/AssetGroupsApi"; + +export class AssetsGroupsController { + definition: ControllerDefinition; + + constructor(private plugin: DeviceManagerPlugin) { + /* eslint-disable sort-keys */ + this.definition = { + actions: { + create: { + handler: this.create.bind(this), + http: [ + { + path: "device-manager/:engineId/assetsGroups/:_id", + verb: "post", + }, + ], + }, + get: { + handler: this.get.bind(this), + http: [ + { path: "device-manager/:engineId/assetsGroups/:_id", verb: "get" }, + ], + }, + update: { + handler: this.update.bind(this), + http: [ + { path: "device-manager/:engineId/assetsGroups/:_id", verb: "put" }, + ], + }, + delete: { + handler: this.delete.bind(this), + http: [ + { + path: "device-manager/:engineId/assetsGroups/:_id", + verb: "delete", + }, + ], + }, + search: { + handler: this.search.bind(this), + http: [ + { + path: "device-manager/:engineId/assetsGroups/_search", + verb: "get", + }, + { + path: "device-manager/:engineId/assetsGroups/_search", + verb: "post", + }, + ], + }, + addAsset: { + handler: this.addAsset.bind(this), + http: [ + { + path: "device-manager/:engineId/assetsGroups/:_id/addAsset", + verb: "post", + }, + ], + }, + removeAsset: { + handler: this.removeAsset.bind(this), + http: [ + { + path: "device-manager/:engineId/assetsGroups/:_id/removeAsset", + verb: "post", + }, + ], + }, + }, + }; + /* eslint-enable sort-keys */ + } + + private get sdk() { + return this.plugin.context.accessors.sdk; + } + + private get as() { + return (user: User | null): EmbeddedSDK => { + if (user?._id) { + return this.sdk.as(user, { checkRights: true }); + } + return this.sdk; + }; + } + + async checkParent( + engineId: string, + parent: AssetsGroupsBodyRequest["parent"] + ): Promise { + if (typeof parent !== "string") { + return; + } + + try { + const assetGroup = await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + parent + ); + + if (assetGroup._source.parent !== null) { + throw new BadRequestError( + `Can't create asset group with more than one nesting level` + ); + } + } catch (error) { + if (error.status === 404) { + throw new BadRequestError( + `The parent group "${parent}" does not exist` + ); + } + throw error; + } + } + + async checkChildren( + engineId: string, + children: AssetsGroupsBodyRequest["children"] + ): Promise { + if (!Array.isArray(children)) { + throw new BadRequestError("The Children property should be an array"); + } + + if (children.length === 0) { + return; + } + + const { result } = await this.sdk.query({ + action: "mExists", + body: { + ids: children, + }, + collection: InternalCollection.ASSETS_GROUPS, + controller: "document", + index: engineId, + }); + + if (Array.isArray(result.errors) && result.errors.length > 0) { + throw new BadRequestError( + `The children group "${result.errors.join(",")}" does not exist` + ); + } + } + + async checkGroupName( + engineId: string, + name: AssetsGroupsBodyRequest["name"], + assetId?: string + ) { + if (typeof name !== "string") { + return; + } + + const groupsCount = await this.sdk.document.count( + engineId, + InternalCollection.ASSETS_GROUPS, + { + query: { + bool: { + must: [ + { + regexp: { + name: { + case_insensitive: true, + value: name, + }, + }, + }, + ], + must_not: [ + { + terms: { + _id: typeof assetId === "string" ? [assetId] : [], + }, + }, + ], + }, + }, + } + ); + + if (groupsCount > 0) { + throw new BadRequestError(`A group with name "${name}" already exist`); + } + } + + async create(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId({ + generator: () => NameGenerator.generateRandomName({ prefix: "group" }), + ifMissing: "generate", + }); + const body = request.getBody() as AssetsGroupsBodyRequest; + + await this.checkParent(engineId, body.parent); + await this.checkGroupName(engineId, body.name); + + if (typeof body.name !== "string") { + throw new BadRequestError(`A group must have a name`); + } + + if (typeof body.parent === "string") { + const parentGroup = await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + body.parent + ); + + const children = parentGroup._source.children ?? []; + children.push(_id); + + this.sdk.document.update( + engineId, + InternalCollection.ASSETS_GROUPS, + body.parent, + { + children, + } + ); + } + + return this.as(request.getUser()).document.create( + engineId, + InternalCollection.ASSETS_GROUPS, + { + children: [], + name: body.name, + parent: body.parent ?? null, + }, + _id + ); + } + + async get(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId(); + + return this.as(request.getUser()).document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + _id + ); + } + + async update(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId(); + const body = request.getBody() as AssetsGroupsBodyRequest; + + await this.checkParent(engineId, body.parent); + await this.checkChildren(engineId, body.children); + await this.checkGroupName(engineId, body.name, _id); + + return this.as(request.getUser()).document.update( + engineId, + InternalCollection.ASSETS_GROUPS, + _id, + { parent: null, ...body }, + { source: true } + ); + } + + async delete(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId(); + + const { _source: assetGroup } = + await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + _id + ); + + if (assetGroup.parent !== null) { + const { _source: parentGroup } = + await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + assetGroup.parent + ); + await this.sdk.document.update( + engineId, + InternalCollection.ASSETS_GROUPS, + assetGroup.parent, + { + children: parentGroup.children.filter((children) => children !== _id), + } + ); + } + + await this.sdk.document.mUpdate( + engineId, + InternalCollection.ASSETS_GROUPS, + assetGroup.children.map((childrenId) => ({ + _id: childrenId, + body: { parent: null }, + })), + { strict: true } + ); + + const { hits: assets } = await this.sdk.document.search( + engineId, + InternalCollection.ASSETS, + { query: { equals: { groups: _id } } }, + { lang: "koncorde" } + ); + + await this.sdk.document.mUpdate( + engineId, + InternalCollection.ASSETS, + assets.map((asset) => ({ + _id: asset._id, + body: { + groups: asset._source.groups.filter((groupId) => groupId !== _id), + }, + })), + { strict: true } + ); + + await this.as(request.getUser()).document.delete( + engineId, + InternalCollection.ASSETS_GROUPS, + _id + ); + } + + async search(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const { + searchBody, + from, + size, + scrollTTL: scroll, + } = request.getSearchParams(); + const lang = request.getLangParam(); + + return this.as(request.getUser()).document.search( + engineId, + InternalCollection.ASSETS_GROUPS, + searchBody, + { from, lang, scroll, size } + ); + } + + async addAsset(request: KuzzleRequest): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId(); + const body = request.getBody() as ApiGroupAddAssetsRequest["body"]; + + // ? Get document to check if really exists, even if not indexed + const assetGroup = await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + _id + ); + + const assets = []; + for (const assetId of body.assetIds) { + const assetContent = ( + await this.sdk.document.get( + engineId, + InternalCollection.ASSETS, + assetId + ) + )._source; + + if (!Array.isArray(assetContent.groups)) { + assetContent.groups = []; + } + + if (assetGroup._source.parent !== null) { + assetContent.groups.push(assetGroup._source.parent); + } + + assetContent.groups.push(_id); + + assets.push({ + _id: assetId, + body: assetContent, + }); + } + + return this.sdk.document.mReplace( + engineId, + InternalCollection.ASSETS, + assets + ); + } + + async removeAsset( + request: KuzzleRequest + ): Promise { + const engineId = request.getString("engineId"); + const _id = request.getId(); + const body = request.getBody() as ApiGroupRemoveAssetsRequest["body"]; + + // ? Get document to check if really exists, even if not indexed + const { _source: AssetGroupContent } = + await this.sdk.document.get( + engineId, + InternalCollection.ASSETS_GROUPS, + _id + ); + + const removedGroups = AssetGroupContent.children; + removedGroups.push(_id); + + const assets = []; + for (const assetId of body.assetIds) { + const assetContent = ( + await this.sdk.document.get( + engineId, + InternalCollection.ASSETS, + assetId + ) + )._source; + + if (!Array.isArray(assetContent.groups)) { + continue; + } + + assetContent.groups = assetContent.groups.filter( + (group) => !removedGroups.includes(group) + ); + + assets.push({ + _id: assetId, + body: assetContent, + }); + } + + return this.sdk.document.mReplace( + engineId, + InternalCollection.ASSETS, + assets + ); + } +} diff --git a/lib/modules/asset/collections/assetsGroupsMapping.ts b/lib/modules/asset/collections/assetsGroupsMapping.ts new file mode 100644 index 00000000..16705d8d --- /dev/null +++ b/lib/modules/asset/collections/assetsGroupsMapping.ts @@ -0,0 +1,31 @@ +import { CollectionMappings } from "kuzzle"; + +export const assetGroupsMappings: CollectionMappings = { + dynamic: "strict", + properties: { + children: { + fields: { + text: { + type: "text", + }, + }, + type: "keyword", + }, + name: { + fields: { + text: { + type: "text", + }, + }, + type: "keyword", + }, + parent: { + fields: { + text: { + type: "text", + }, + }, + type: "keyword", + }, + }, +}; diff --git a/lib/modules/asset/collections/assetsMappings.ts b/lib/modules/asset/collections/assetsMappings.ts index d38908cd..06cd26fe 100644 --- a/lib/modules/asset/collections/assetsMappings.ts +++ b/lib/modules/asset/collections/assetsMappings.ts @@ -1,9 +1,11 @@ +import { CollectionMappings } from "kuzzle"; + /** * Base mappings for the "assets" collection. * * Those mappings does not contains the `measures` and `metadata` mappings. */ -export const assetsMappings = { +export const assetsMappings: CollectionMappings = { dynamic: "strict", properties: { model: { @@ -14,6 +16,10 @@ export const assetsMappings = { type: "keyword", fields: { text: { type: "text" } }, }, + groups: { + type: "keyword", + fields: { text: { type: "text" } }, + }, metadata: { properties: { diff --git a/lib/modules/asset/exports.ts b/lib/modules/asset/exports.ts index d60d103b..f294c0fe 100644 --- a/lib/modules/asset/exports.ts +++ b/lib/modules/asset/exports.ts @@ -2,6 +2,8 @@ export * from "./types/AssetContent"; export * from "./types/AssetHistoryContent"; export * from "./types/AssetEvents"; export * from "./types/AssetApi"; +export * from "./types/AssetGroupContent"; +export * from "./types/AssetGroupsApi"; export * from "./roles/RoleAssetsAdmin"; export * from "./roles/RoleAssetsReader"; export * from "./collections/assetsMappings"; diff --git a/lib/modules/asset/index.ts b/lib/modules/asset/index.ts index 2821b683..befe0f7b 100644 --- a/lib/modules/asset/index.ts +++ b/lib/modules/asset/index.ts @@ -1,5 +1,6 @@ export * from "./collections/assetsMappings"; export * from "./collections/assetsHistoryMappings"; +export * from "./collections/assetsGroupsMapping"; export * from "./types/AssetContent"; export * from "./types/AssetHistoryContent"; export * from "./types/AssetEvents"; diff --git a/lib/modules/asset/types/AssetContent.ts b/lib/modules/asset/types/AssetContent.ts index 0bb22648..6b543414 100644 --- a/lib/modules/asset/types/AssetContent.ts +++ b/lib/modules/asset/types/AssetContent.ts @@ -31,6 +31,10 @@ export interface AssetContent< */ measureNames: Array<{ asset: string; device: string; type: string }>; }>; + /** + * Id's of asset groups + */ + groups: string[]; } /** diff --git a/lib/modules/asset/types/AssetGroupContent.ts b/lib/modules/asset/types/AssetGroupContent.ts new file mode 100644 index 00000000..d714a97c --- /dev/null +++ b/lib/modules/asset/types/AssetGroupContent.ts @@ -0,0 +1,9 @@ +import { KDocumentContent } from "kuzzle-sdk"; + +export interface AssetsGroupsBody { + name: string; + children: string[]; + parent: string | null; +} + +export type AssetsGroupContent = AssetsGroupsBody & KDocumentContent; diff --git a/lib/modules/asset/types/AssetGroupsApi.ts b/lib/modules/asset/types/AssetGroupsApi.ts new file mode 100644 index 00000000..dcb7cf60 --- /dev/null +++ b/lib/modules/asset/types/AssetGroupsApi.ts @@ -0,0 +1,75 @@ +import { + JSONObject, + KDocument, + KHit, + SearchResult, + mUpdateResponse, +} from "kuzzle-sdk"; +import { AssetsGroupsBody, AssetsGroupContent } from "./AssetGroupContent"; + +// Make "parent" property to optional for request +export type AssetsGroupsBodyRequest = Partial & + Omit; + +interface GroupControllerRequest { + controller: "device-manager/assetsGroup"; + engineId: string; +} + +export interface ApiGroupCreateRequest extends GroupControllerRequest { + action: "create"; + _id?: string; + body: Omit; +} + +export type ApiGroupCreateResult = KDocument; + +export interface ApiGroupGetRequest extends GroupControllerRequest { + action: "get"; + _id: string; +} + +export type ApiGroupGetResult = KDocument; + +export interface ApiGroupUpdateRequest extends GroupControllerRequest { + action: "update"; + _id: string; + body: AssetsGroupsBodyRequest; +} + +export type ApiGroupUpdateResult = KDocument; + +export interface ApiGroupDeleteRequest extends GroupControllerRequest { + action: "delete"; + _id: string; +} + +export type ApiGroupDeleteResult = void; + +export interface ApiGroupSearchRequest extends GroupControllerRequest { + action: "search"; + from?: number; + size?: number; + scrollTTL?: string; + lang?: "koncorde" | "elasticsearch"; + body: JSONObject; +} +export type ApiGroupSearchResult = SearchResult>; + +export interface ApiGroupAddAssetsRequest extends GroupControllerRequest { + action: "addAsset"; + _id: string; + body: { + assetIds: string[]; + }; +} +export type ApiGroupAddAssetsResult = mUpdateResponse; + +export interface ApiGroupRemoveAssetsRequest extends GroupControllerRequest { + action: "removeAsset"; + _id: string; + body: { + assetIds: string[]; + }; +} +export type ApiGroupRemoveAssetsResult = mUpdateResponse; diff --git a/lib/modules/plugin/DeviceManagerEngine.ts b/lib/modules/plugin/DeviceManagerEngine.ts index 59591b51..847640bd 100644 --- a/lib/modules/plugin/DeviceManagerEngine.ts +++ b/lib/modules/plugin/DeviceManagerEngine.ts @@ -4,7 +4,11 @@ import { JSONObject } from "kuzzle-sdk"; import { AbstractEngine, ConfigManager } from "kuzzle-plugin-commons"; import { EngineContent } from "kuzzle-plugin-commons"; -import { assetsMappings, assetsHistoryMappings } from "../asset"; +import { + assetsMappings, + assetsHistoryMappings, + assetGroupsMappings, +} from "../asset"; import { AssetModelContent, DeviceModelContent, @@ -106,6 +110,8 @@ export class DeviceManagerEngine extends AbstractEngine { promises.push(this.createAssetsHistoryCollection(index, group)); + promises.push(this.createAssetsGroupsCollection(index)); + promises.push(this.createDevicesCollection(index)); promises.push(this.createMeasuresCollection(index, group)); @@ -124,6 +130,8 @@ export class DeviceManagerEngine extends AbstractEngine { promises.push(this.createAssetsHistoryCollection(index, group)); + promises.push(this.createAssetsGroupsCollection(index)); + promises.push(this.createDevicesCollection(index)); promises.push(this.createMeasuresCollection(index, group)); @@ -134,28 +142,24 @@ export class DeviceManagerEngine extends AbstractEngine { } async onDelete(index: string) { - const promises = []; + const collections = [ + InternalCollection.ASSETS, + InternalCollection.ASSETS_HISTORY, + InternalCollection.ASSETS_GROUPS, + InternalCollection.DEVICES, + InternalCollection.MEASURES, + ]; - promises.push(this.sdk.collection.delete(index, InternalCollection.ASSETS)); - promises.push( - this.sdk.collection.delete(index, InternalCollection.ASSETS_HISTORY) - ); - promises.push( - this.sdk.collection.delete(index, InternalCollection.DEVICES) - ); - promises.push( - this.sdk.collection.delete(index, InternalCollection.MEASURES) + await Promise.all( + collections.map(async (collection) => { + if (await this.sdk.collection.exists(index, collection)) { + await this.sdk.collection.delete(index, collection); + } + }) ); - await Promise.all(promises); - return { - collections: [ - InternalCollection.ASSETS, - InternalCollection.ASSETS_HISTORY, - InternalCollection.DEVICES, - InternalCollection.MEASURES, - ], + collections, }; } @@ -194,6 +198,16 @@ export class DeviceManagerEngine extends AbstractEngine { return InternalCollection.ASSETS_HISTORY; } + async createAssetsGroupsCollection(engineId: string) { + await this.sdk.collection.create( + engineId, + InternalCollection.ASSETS_GROUPS, + { mappings: assetGroupsMappings } + ); + + return InternalCollection.ASSETS_GROUPS; + } + private async getDigitalTwinMappings< TDigitalTwinModelContent extends AssetModelContent | DeviceModelContent >(digitalTwinType: "asset" | "device", engineGroup?: string) { diff --git a/lib/modules/plugin/types/InternalCollection.ts b/lib/modules/plugin/types/InternalCollection.ts index e185066c..e5c7122c 100644 --- a/lib/modules/plugin/types/InternalCollection.ts +++ b/lib/modules/plugin/types/InternalCollection.ts @@ -1,6 +1,7 @@ export enum InternalCollection { ASSETS = "assets", ASSETS_HISTORY = "assets-history", + ASSETS_GROUPS = "assets-groups", DEVICES = "devices", MEASURES = "measures", MODELS = "models", diff --git a/package-lock.json b/package-lock.json index 84000441..b8b0b629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/jest": "^29.5.1", + "@types/lodash": "^4.14.195", "@types/node": "^18.15.13", "axios": "^1.3.6", "ergol": "^1.0.2", @@ -1606,6 +1607,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "node_modules/@types/lodash": { + "version": "4.14.195", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==", + "dev": true + }, "node_modules/@types/node": { "version": "18.15.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", diff --git a/package.json b/package.json index 4722b069..17e40d2a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@types/jest": "^29.5.1", + "@types/lodash": "^4.14.195", "@types/node": "^18.15.13", "axios": "^1.3.6", "ergol": "^1.0.2", diff --git a/tests/fixtures/assetsGroups.ts b/tests/fixtures/assetsGroups.ts new file mode 100644 index 00000000..fdcd377d --- /dev/null +++ b/tests/fixtures/assetsGroups.ts @@ -0,0 +1,70 @@ +import { AssetsGroupsBody } from "../../lib/modules/asset/types/AssetGroupContent"; + +export const assetGroupTestId = "test-group"; +export const assetGroupTestParentId1 = "test-parent-1"; +export const assetGroupTestParentId2 = "test-parent-2"; +export const assetGroupTestChildrenId1 = "test-children-1"; +export const assetGroupTestChildrenId2 = "test-children-2"; +export const assetGroupParentWithAssetId = "test-parent-asset"; +export const assetGroupChildrenWithAssetId = "test-children-asset"; + +export const assetGroupTestBody: AssetsGroupsBody = { + name: "Test group", + children: [], + parent: null, +}; + +export const assetGroupTestParentBody1: AssetsGroupsBody = { + name: "Test parent 1", + children: [assetGroupTestChildrenId1], + parent: null, +}; + +export const assetGroupTestParentBody2: AssetsGroupsBody = { + name: "Test parent 2", + children: [assetGroupTestChildrenId2], + parent: null, +}; + +export const assetGroupTestChildrenBody1: AssetsGroupsBody = { + name: "Test children 1", + children: [], + parent: assetGroupTestParentId1, +}; + +export const assetGroupTestChildrenBody2: AssetsGroupsBody = { + name: "Test children 2", + children: [], + parent: assetGroupTestParentId2, +}; + +export const assetGroupParentWithAssetBody: AssetsGroupsBody = { + name: "Parent Group with asset", + children: [assetGroupChildrenWithAssetId], + parent: null, +}; + +export const assetGroupChildrenWithAssetBody: AssetsGroupsBody = { + name: "Children Group with asset", + children: [], + parent: assetGroupParentWithAssetId, +}; + +export const assetGroupFixtures = { + "assets-groups": [ + { index: { _id: assetGroupTestId } }, + assetGroupTestBody, + { index: { _id: assetGroupTestParentId1 } }, + assetGroupTestParentBody1, + { index: { _id: assetGroupTestParentId2 } }, + assetGroupTestParentBody2, + { index: { _id: assetGroupTestChildrenId1 } }, + assetGroupTestChildrenBody1, + { index: { _id: assetGroupTestChildrenId2 } }, + assetGroupTestChildrenBody2, + { index: { _id: assetGroupParentWithAssetId } }, + assetGroupParentWithAssetBody, + { index: { _id: assetGroupChildrenWithAssetId } }, + assetGroupChildrenWithAssetBody, + ], +}; diff --git a/tests/fixtures/fixtures.ts b/tests/fixtures/fixtures.ts index f928008d..ef7f2c35 100644 --- a/tests/fixtures/fixtures.ts +++ b/tests/fixtures/fixtures.ts @@ -1,3 +1,5 @@ +import { assetGroupFixtures } from "./assetsGroups"; + const deviceDetached1 = { model: "DummyTemp", reference: "detached1", @@ -106,6 +108,30 @@ const assetAyseUnlinked = { }; const assetAyseUnlinkedId = `${assetAyseUnlinked.model}-${assetAyseUnlinked.reference}`; +const assetAyseGrouped = { + model: "Container", + reference: "grouped", + metadata: { + weight: 20, + height: 22, + }, + linkedDevices: [], + groups: ["test-parent-asset", "test-children-asset"], +}; +const assetAyseGroupedId = `${assetAyseGrouped.model}-${assetAyseGrouped.reference}`; + +const assetAyseGrouped2 = { + model: "Container", + reference: "grouped2", + metadata: { + weight: 20, + height: 22, + }, + linkedDevices: [], + groups: ["test-parent-asset", "test-children-asset"], +}; +const assetAyseGroupedId2 = `${assetAyseGrouped2.model}-${assetAyseGrouped2.reference}`; + export default { "device-manager": { devices: [ @@ -156,6 +182,13 @@ export default { { index: { _id: assetAyseUnlinkedId } }, assetAyseUnlinked, + + { index: { _id: assetAyseGroupedId } }, + assetAyseGrouped, + + { index: { _id: assetAyseGroupedId2 } }, + assetAyseGrouped2, ], + ...assetGroupFixtures, }, }; diff --git a/tests/hooks/collections.ts b/tests/hooks/collections.ts index 020e8f2c..5a5f93b9 100644 --- a/tests/hooks/collections.ts +++ b/tests/hooks/collections.ts @@ -32,11 +32,13 @@ export async function beforeEachTruncateCollections(sdk: Kuzzle) { truncateCollection(sdk, "engine-kuzzle", "assets"), truncateCollection(sdk, "engine-kuzzle", "assets-history"), + truncateCollection(sdk, "engine-kuzzle", "assets-groups"), truncateCollection(sdk, "engine-kuzzle", "measures"), truncateCollection(sdk, "engine-kuzzle", "devices"), truncateCollection(sdk, "engine-ayse", "assets"), truncateCollection(sdk, "engine-ayse", "assets-history"), + truncateCollection(sdk, "engine-ayse", "assets-groups"), truncateCollection(sdk, "engine-ayse", "measures"), truncateCollection(sdk, "engine-ayse", "devices"), deleteModels(sdk), diff --git a/tests/hooks/fixtures.ts b/tests/hooks/fixtures.ts index c7809b52..e1aa7496 100644 --- a/tests/hooks/fixtures.ts +++ b/tests/hooks/fixtures.ts @@ -9,4 +9,11 @@ export async function beforeEachLoadFixtures(sdk: Kuzzle) { refresh: "false", body: fixtures, }); + + // Refresh all fixtures collections (faster than refresh of loadFixtures) + for (const index of Object.keys(fixtures)) { + for (const collection of Object.keys(fixtures[index])) { + await sdk.collection.refresh(index, collection); + } + } } diff --git a/tests/scenario/modules/assets/asset-group.test.ts b/tests/scenario/modules/assets/asset-group.test.ts new file mode 100644 index 00000000..d5b9de73 --- /dev/null +++ b/tests/scenario/modules/assets/asset-group.test.ts @@ -0,0 +1,594 @@ +import { + assetGroupTestId, + assetGroupTestBody, + assetGroupTestParentId1, + assetGroupTestParentBody1, + assetGroupTestChildrenId1, + assetGroupTestChildrenBody1, + assetGroupTestChildrenId2, + assetGroupTestParentId2, + assetGroupChildrenWithAssetId, + assetGroupParentWithAssetId, +} from "../../../fixtures/assetsGroups"; + +// Lib +import { + ApiGroupCreateRequest, + ApiGroupCreateResult, + ApiGroupDeleteRequest, + ApiGroupGetRequest, + ApiGroupSearchRequest, + ApiGroupSearchResult, + ApiGroupUpdateRequest, + ApiGroupAddAssetsRequest, + ApiGroupRemoveAssetsRequest, +} from "../../../../lib/modules/asset/types/AssetGroupsApi"; +import { AssetsGroupContent } from "../../../../lib/modules/asset/exports"; +import { InternalCollection } from "../../../../lib/modules/plugin"; +import { setupHooks } from "../../../helpers"; + +jest.setTimeout(10000); + +describe("AssetsGroupsController", () => { + const sdk = setupHooks(); + + it("can create a group", async () => { + const missingBodyQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + _id: "root-group", + }; + await expect(sdk.query(missingBodyQuery)).rejects.toThrow( + /^The request must specify a body.$/ + ); + + const missingNameQuery = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + body: {}, + }; + await expect(sdk.query(missingNameQuery)).rejects.toThrow( + /^A group must have a name$/ + ); + + const badParentIdQuery: ApiGroupCreateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + _id: "parent-not-exist", + body: { + name: "Parent not exist", + parent: "not-exist", + }, + }; + await expect(sdk.query(badParentIdQuery)).rejects.toThrow( + /^The parent group "not-exist" does not exist$/ + ); + + const duplicateGroupName: ApiGroupCreateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + body: { + name: "test group", + }, + }; + await expect(sdk.query(duplicateGroupName)).rejects.toThrow( + /^A group with name "test group" already exist$/ + ); + + const tooMuchNested: ApiGroupCreateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + body: { + name: "nested group", + parent: assetGroupTestChildrenId1, + }, + }; + await expect(sdk.query(tooMuchNested)).rejects.toThrow( + /^Can't create asset group with more than one nesting level$/ + ); + + const { result: assetGroupRoot } = await sdk.query< + ApiGroupCreateRequest, + ApiGroupCreateResult + >({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + _id: "root-group", + body: { + name: "root group", + }, + }); + + expect(assetGroupRoot._id).toBe("root-group"); + expect(assetGroupRoot._source).toMatchObject({ + name: "root group", + children: [], + parent: null, + }); + + const { result: assetGroupChildren } = await sdk.query< + ApiGroupCreateRequest, + ApiGroupCreateResult + >({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "create", + _id: "children-group", + body: { + name: "children group", + parent: "root-group", + }, + }); + + const { _source: rootGroup } = await sdk.document.get( + "engine-ayse", + InternalCollection.ASSETS_GROUPS, + "root-group" + ); + + expect(rootGroup).toMatchObject({ + children: ["children-group"], + }); + + expect(assetGroupChildren._id).toBe("children-group"); + expect(assetGroupChildren._source).toMatchObject({ + name: "children group", + children: [], + parent: "root-group", + }); + + const { result: assetGroupWithoutIdSpecified } = await sdk.query< + ApiGroupCreateRequest, + ApiGroupCreateResult + >({ + controller: "device-manager/assetsGroup", + action: "create", + engineId: "engine-ayse", + body: { + name: "group", + }, + }); + + expect(typeof assetGroupWithoutIdSpecified._id).toBe("string"); + }); + + it("can get a group", async () => { + const missingIdQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "get", + }; + await expect(sdk.query(missingIdQuery)).rejects.toThrow( + /^Missing argument "_id".$/ + ); + + const { result } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "get", + _id: assetGroupTestId, + }); + + expect(result._id).toEqual(assetGroupTestId); + expect(result._source).toMatchObject(assetGroupTestBody); + }); + + it("can update a group", async () => { + const missingIdQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + body: assetGroupTestBody, + }; + await expect(sdk.query(missingIdQuery)).rejects.toThrow( + /^Missing argument "_id".$/ + ); + + const missingBodyQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + }; + await expect(sdk.query(missingBodyQuery)).rejects.toThrow( + /^The request must specify a body.$/ + ); + + const badParentIdQuery: ApiGroupUpdateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + body: { + name: "root group", + children: ["children-group"], + parent: "not-exist", + }, + }; + await expect(sdk.query(badParentIdQuery)).rejects.toThrow( + /^The parent group "not-exist" does not exist$/ + ); + + const badChildrenIdQuery: ApiGroupUpdateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + body: { + name: "root group", + children: [assetGroupTestChildrenId1, "not-exist"], + }, + }; + await expect(sdk.query(badChildrenIdQuery)).rejects.toThrow( + /^The children group "not-exist" does not exist$/ + ); + + const duplicateGroupName: ApiGroupUpdateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestParentId1, + body: { + name: "test group", + children: [], + }, + }; + await expect(sdk.query(duplicateGroupName)).rejects.toThrow( + /^A group with name "test group" already exist$/ + ); + + const tooMuchNested: ApiGroupUpdateRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + body: { + name: "Test group", + parent: assetGroupTestChildrenId1, + children: [], + }, + }; + await expect(sdk.query(tooMuchNested)).rejects.toThrow( + /^Can't create asset group with more than one nesting level$/ + ); + + const { result } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + body: { + name: "root group", + children: [], + }, + }); + + expect(result._id).toEqual(assetGroupTestId); + expect(result._source).toMatchObject({ + name: "root group", + children: [], + parent: null, + }); + + const { result: resultChildren } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "update", + _id: assetGroupTestId, + body: { + name: "root group", + children: [assetGroupTestChildrenId1], + }, + }); + + expect(resultChildren._id).toEqual(assetGroupTestId); + expect(resultChildren._source).toMatchObject({ + name: "root group", + children: [assetGroupTestChildrenId1], + parent: null, + }); + }); + + it("can delete a group", async () => { + const missingIdQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "delete", + }; + await expect(sdk.query(missingIdQuery)).rejects.toThrow( + /^Missing argument "_id".$/ + ); + + const { error, status } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "delete", + _id: assetGroupTestId, + }); + + expect(error).toBeNull(); + expect(status).toBe(200); + + await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "delete", + _id: assetGroupTestParentId1, + }); + + const { _source: childrenGroup } = + await sdk.document.get( + "engine-ayse", + InternalCollection.ASSETS_GROUPS, + assetGroupTestChildrenId1 + ); + + expect(childrenGroup).toMatchObject({ + parent: null, + }); + + await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "delete", + _id: assetGroupTestChildrenId2, + }); + + const { _source: parentGroup } = await sdk.document.get( + "engine-ayse", + InternalCollection.ASSETS_GROUPS, + assetGroupTestParentId2 + ); + + expect(parentGroup).toMatchObject({ + children: [], + }); + + await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "delete", + _id: assetGroupParentWithAssetId, + }); + + const { _source: assetGrouped } = + await sdk.document.get( + "engine-ayse", + InternalCollection.ASSETS, + "Container-grouped" + ); + + expect(assetGrouped).toMatchObject({ + groups: [assetGroupChildrenWithAssetId], + }); + }); + + it("can search groups", async () => { + const { result } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "search", + body: { + query: { + ids: { + values: [ + assetGroupTestId, + assetGroupTestParentId1, + assetGroupTestChildrenId1, + ], + }, + }, + }, + lang: "koncorde", + }); + + const hits: ApiGroupSearchResult["hits"] = [ + { + _id: assetGroupTestId, + _score: 1, + _source: assetGroupTestBody, + }, + { + _id: assetGroupTestParentId1, + _score: 1, + _source: assetGroupTestParentBody1, + }, + { + _id: assetGroupTestChildrenId1, + _score: 1, + _source: assetGroupTestChildrenBody1, + }, + ]; + + expect(result).toMatchObject({ + fetched: hits.length, + hits, + total: hits.length, + }); + }); + + it("can add asset to a group", async () => { + const missingIdQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + body: { + assetIds: [], + }, + }; + await expect(sdk.query(missingIdQuery)).rejects.toThrow( + /^Missing argument "_id".$/ + ); + + const missingBodyQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + _id: assetGroupTestId, + }; + await expect(sdk.query(missingBodyQuery)).rejects.toThrow( + /^The request must specify a body.$/ + ); + + const badIdQuery: ApiGroupAddAssetsRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + _id: "bad-id", + body: { + assetIds: [], + }, + }; + await expect(sdk.query(badIdQuery)).rejects.toThrow( + /^Document "bad-id" not found in "engine-ayse":"assets-groups".$/ + ); + + const { result } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + _id: assetGroupTestId, + body: { + assetIds: ["Container-linked1", "Container-linked2"], + }, + }); + + expect(result.errors).toHaveLength(0); + + expect(result.successes).toMatchObject([ + { _id: "Container-linked1", _source: { groups: [assetGroupTestId] } }, + { _id: "Container-linked2", _source: { groups: [assetGroupTestId] } }, + ]); + + // Add assets in an second group + const { result: result2 } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + _id: assetGroupTestParentId1, + body: { + assetIds: ["Container-linked1", "Container-linked2"], + }, + }); + + expect(result2.errors).toHaveLength(0); + + expect(result2.successes).toMatchObject([ + { + _id: "Container-linked1", + _source: { + groups: [assetGroupTestId, assetGroupTestParentId1], + }, + }, + { + _id: "Container-linked2", + _source: { + groups: [assetGroupTestId, assetGroupTestParentId1], + }, + }, + ]); + + // Add an asset to a subgroup also add the reference of the parent group + const { result: result3 } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "addAsset", + _id: assetGroupTestChildrenId1, + body: { + assetIds: ["Container-unlinked1"], + }, + }); + + expect(result3.errors).toHaveLength(0); + + expect(result3.successes).toMatchObject([ + { + _id: "Container-unlinked1", + _source: { + groups: [assetGroupTestParentId1, assetGroupTestChildrenId1], + }, + }, + ]); + }); + + it("can remove asset to group", async () => { + const missingIdQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "removeAsset", + body: { + assetIds: [], + }, + }; + await expect(sdk.query(missingIdQuery)).rejects.toThrow( + /^Missing argument "_id".$/ + ); + + const missingBodyQuery: Omit = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "removeAsset", + _id: assetGroupTestId, + }; + await expect(sdk.query(missingBodyQuery)).rejects.toThrow( + /^The request must specify a body.$/ + ); + + const badIdQuery: ApiGroupRemoveAssetsRequest = { + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "removeAsset", + _id: "bad-id", + body: { + assetIds: [], + }, + }; + await expect(sdk.query(badIdQuery)).rejects.toThrow( + /^Document "bad-id" not found in "engine-ayse":"assets-groups".$/ + ); + + const { result: asset } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "removeAsset", + _id: assetGroupChildrenWithAssetId, + body: { + assetIds: ["Container-grouped"], + }, + }); + + expect(asset.errors).toHaveLength(0); + + expect(asset.successes[0]).toMatchObject({ + _id: "Container-grouped", + _source: { + groups: [assetGroupParentWithAssetId], + }, + }); + + const { result: asset2 } = await sdk.query({ + controller: "device-manager/assetsGroup", + engineId: "engine-ayse", + action: "removeAsset", + _id: assetGroupParentWithAssetId, + body: { + assetIds: ["Container-grouped2"], + }, + }); + + expect(asset2.errors).toHaveLength(0); + + expect(asset2.successes[0]).toMatchObject({ + _id: "Container-grouped2", + _source: { + groups: [], + }, + }); + }); +});