Repository: Redocly/redocly-cli
Pull request #778 (2022-07-27 08:28:18)

fix: join paths properly
## What/Why/How?
Currently `summary`, `description`, `servers`, `parameters` fields are ignored/incorrectly handled by `join` command.

This PR is rework for `join` command based on [this one](https://github.com/Redocly/redocly-cli/pull/536). The reason is that changes in the [latest](https://github.com/Redocly/redocly-cli/pull/536) is based just on checking if the value of `PathItem` field is of `object` type. I suppose that current logic performed on `object` values is  making sense only for `Operation Object` type. `summary`, `description`, `servers` and `parameters` should be joined in a different way.

Things required to finish this PR:
- [x] Merge only operations (`post`, `get`, etc.) using existing implementation
- [ ]  ~~Implement join of `Operation Object`s which are $refs~~
- [x]  Implement join of `summary` and `description`
- [x]  Implement join of `servers`
- [x]  Implement join of `parameters` 
- [x]  Add meaningful tests for join command

## Reference

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [ ] All new/updated code is covered with tests

## Security

- [ ] Security impact of change has been considered
- [ ] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/2745248904) 
```
Error: The process '/usr/local/bin/npm' failed with exit code 1
```

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 67.86%     |    2842/4188    |
|                                                                           🟡                                                                           | Branches   | 60.98%     |    1563/2563    |
|                                                                           🔴                                                                           | Functions  | 59.08%     |     452/765     |
|                                                                           🟡                                                                           | Lines      | 67.85%     |    2638/3888    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                              | Statements | Branches | Functions | Lines |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------- | :--------- | :------- | :-------- | :---- |
|                                                                           🟢                                                                           | <div title="core/src/typings/openapi.ts">`...` / openapi.ts</div> | 100%       | 100%     | 100%      | 100%  |

</details>


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                        | Statements                                                    | Branches | Functions                                               | Lines                                                         |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------- | :------------------------------------------------------------ | :------- | :------------------------------------------------------ | :------------------------------------------------------------ |
|                                                                           🟢                                                                           | core/src/index.ts                                           | 100%                                                          | 100%     | 8.16%                                                   | 100%                                                          |
|                                                                           🟢                                                                           | cli/src/js-utils.ts                                         | <div title="Base coverage is: 100%">88.89% (-11.11% 🔻)</div> | 100%     | <div title="Base coverage is: 100%">75% (-25% 🔻)</div> | <div title="Base coverage is: 100%">88.89% (-11.11% 🔻)</div> |
|                                                                           🔴                                                                           | <div title="cli/src/commands/join.ts">`...` / join.ts</div> | 6.74%                                                         | 5.26%    | 2.17%                                                   | 7.12%                                                         |

</details>

## Test suite run failed
Failed tests: 0/426. Failed suites: 0/75.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 841848e314978013dc5f3477405083cea2973d28</p>

roman-sainchuk: For `description` and `summary` - take it from first document
For `servers` - create unique array by `url` from both documents

@adamaltman @RomanHotsiy please advice how to join `parameters` ? Should we create unique array by `name` and `in` fields ?
roman-sainchuk: > Can you exclude prettier changes from this PR? It's hard to review :(

removed
RomanHotsiy: > For description and summary - take it from first document

Yes, I would also show a warning if there are different values and they mismatch.

> For servers - create unique array by url from both documents

Yes. I would also show a warning or even crash if there are different values and they mismatch (e.g. variables)

> how to join parameters ? Should we create unique array by name and in fields ?

Yes. Unique by name/in seems valid. Make sure to crash if there are different schemas. For different descriptions we can show warnings only.
roman-sainchuk: LGTM 👍 

Patch:
diff --git a/packages/cli/src/__mocks__/@redocly/openapi-core.ts b/packages/cli/src/__mocks__/@redocly/openapi-core.ts
index e87c249c..83d80e83 100644
--- a/packages/cli/src/__mocks__/@redocly/openapi-core.ts
+++ b/packages/cli/src/__mocks__/@redocly/openapi-core.ts
@@ -1,4 +1,6 @@
 import { ConfigFixture } from './../../__tests__/fixtures/config';
+import { Document, ResolveError } from '@redocly/openapi-core';
+import { firstDocument, secondDocument } from '../documents';
 
 export const __redoclyClient = {
   isAuthorizedWithRedocly: jest.fn().mockResolvedValue(true),
@@ -25,3 +27,39 @@ export const formatProblems = jest.fn();
 export const slash = jest.fn();
 export const findConfig = jest.fn();
 export const doesYamlFileExist = jest.fn();
+export const bundleDocument = jest.fn(() => Promise.resolve({ problems: {} }));
+export const detectOpenAPI = jest.fn();
+
+export class BaseResolver {
+  cache = new Map<string, Promise<Document | ResolveError>>();
+
+  getFiles = jest.fn();
+  resolveExternalRef = jest.fn();
+  loadExternalRef = jest.fn;
+  parseDocument = jest.fn();
+  resolveDocument = jest
+    .fn()
+    .mockImplementationOnce(() =>
+      Promise.resolve({ source: { absoluteRef: 'ref' }, parsed: firstDocument })
+    )
+    .mockImplementationOnce(() =>
+      Promise.resolve({ source: { absoluteRef: 'ref' }, parsed: secondDocument })
+    );
+}
+
+export enum OasVersion {
+  Version2 = 'oas2',
+  Version3_0 = 'oas3_0',
+  Version3_1 = 'oas3_1',
+}
+
+export enum Oas3Operations {
+  get = 'get',
+  put = 'put',
+  post = 'post',
+  delete = 'delete',
+  options = 'options',
+  head = 'head',
+  patch = 'patch',
+  trace = 'trace',
+}
diff --git a/packages/cli/src/__mocks__/documents.ts b/packages/cli/src/__mocks__/documents.ts
new file mode 100644
index 00000000..95a55a9c
--- /dev/null
+++ b/packages/cli/src/__mocks__/documents.ts
@@ -0,0 +1,63 @@
+export const firstDocument = {
+  openapi: '3.0.0',
+  servers: [{ url: 'http://localhost:8080' }],
+  info: {
+    description: 'example test',
+    version: '1.0.0',
+    title: 'Swagger Petstore',
+    termsOfService: 'http://swagger.io/terms/',
+    license: {
+      name: 'Apache 2.0',
+      url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
+    },
+  },
+  paths: {
+    '/GETUser/{userId}': {
+      summary: 'get user by id',
+      description: 'user info',
+      servers: [{ url: '/user' }, { url: '/pet', description: 'pet server' }],
+
+      get: {
+        tags: ['pet'],
+        summary: 'Find pet by ID',
+        description: 'Returns a single pet',
+        operationId: 'getPetById',
+        servers: [{ url: '/pet' }],
+      },
+      parameters: [{ name: 'param1', in: 'header', schema: { description: 'string' } }],
+    },
+  },
+  components: {},
+};
+
+export const secondDocument = {
+  openapi: '3.0.0',
+  servers: [{ url: 'http://localhost:8080' }],
+  info: {
+    description: 'example test',
+    version: '1.0.0',
+    title: 'Swagger Petstore',
+    termsOfService: 'http://swagger.io/terms/',
+    license: {
+      name: 'Apache 2.0',
+      url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
+    },
+  },
+  post: {
+    '/GETUser/{userId}': {
+      summary: 'get user',
+      description: 'user information',
+      servers: [{ url: '/user' }, { url: '/pet', description: '' }],
+
+      get: {
+        tags: ['pet'],
+        summary: 'Find pet by ID',
+        description: 'Returns a single pet',
+        operationId: 'getPetById',
+        servers: [{ url: '/pet' }],
+      },
+      parameters: [{ name: 'param1', in: 'header', schema: { description: 'string' } }],
+    },
+  },
+  components: {},
+};
diff --git a/packages/cli/src/__mocks__/utils.ts b/packages/cli/src/__mocks__/utils.ts
index 78deab18..1c8a5e2b 100644
--- a/packages/cli/src/__mocks__/utils.ts
+++ b/packages/cli/src/__mocks__/utils.ts
@@ -11,3 +11,4 @@ export const printLintTotals = jest.fn();
 export const getOutputFileName = jest.fn(() => ({ outputFile: 'test.yaml', ext: 'yaml' }));
 export const handleError = jest.fn();
 export const exitWithError = jest.fn();
+export const writeYaml = jest.fn();
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index 645c567d..8abd80be 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -26,7 +26,9 @@ import {
   writeYaml,
   exitWithError,
 } from '../utils';
-import { isObject, isString } from '../js-utils';
+import { isObject, isString, keysOf } from '../js-utils';
+import { Oas3Parameter, Oas3PathItem, Oas3Server } from '@redocly/openapi-core/lib/typings/openapi';
+import { OPENAPI3_METHOD } from './split/types';
 
 const COMPONENTS = 'components';
 const Tags = 'tags';
@@ -332,73 +334,180 @@ export async function handleJoin(argv: JoinArgv, packageVersion: string) {
     { apiFilename, api, potentialConflicts, tagsPrefix, componentsPrefix }: JoinDocumentContext
   ) {
     const { paths } = openapi;
+    const operationsSet = new Set(keysOf<typeof OPENAPI3_METHOD>(OPENAPI3_METHOD));
     if (paths) {
       if (!joinedDef.hasOwnProperty('paths')) {
         joinedDef['paths'] = {};
       }
-      for (const path of Object.keys(paths)) {
+
+      for (const path of keysOf(paths)) {
         if (!joinedDef.paths.hasOwnProperty(path)) {
           joinedDef.paths[path] = {};
         }
         if (!potentialConflicts.paths.hasOwnProperty(path)) {
           potentialConflicts.paths[path] = {};
         }
-        for (const [operation, pathOperation] of Object.entries(paths[path])) {
-          joinedDef.paths[path][operation] = pathOperation;
-          potentialConflicts.paths[path][operation] = [
-            ...(potentialConflicts.paths[path][operation] || []),
-            api,
-          ];
-          const { operationId } = pathOperation;
-          if (operationId) {
-            if (!potentialConflicts.paths.hasOwnProperty('operationIds')) {
-              potentialConflicts.paths['operationIds'] = {};
-            }
-            potentialConflicts.paths.operationIds[operationId] = [
-              ...(potentialConflicts.paths.operationIds[operationId] || []),
-              api,
-            ];
+
+        const pathItem = paths[path] as Oas3PathItem;
+
+        for (const field of keysOf(pathItem)) {
+          if (operationsSet.has(field as OPENAPI3_METHOD)) {
+            collectPathOperation(pathItem, path, field as OPENAPI3_METHOD);
           }
-          const { tags, security } = joinedDef.paths[path][operation];
-          if (tags) {
-            joinedDef.paths[path][operation].tags = tags.map((tag: string) =>
-              addPrefix(tag, tagsPrefix)
-            );
-            populateTags({
-              api,
-              apiFilename,
-              tags: formatTags(tags),
-              potentialConflicts,
-              tagsPrefix,
-              componentsPrefix,
-            });
-          } else {
-            joinedDef.paths[path][operation]['tags'] = [
-              addPrefix('other', tagsPrefix || apiFilename),
-            ];
-            populateTags({
-              api,
-              apiFilename,
-              tags: formatTags(['other']),
-              potentialConflicts,
-              tagsPrefix: tagsPrefix || apiFilename,
-              componentsPrefix,
-            });
+          if (field === 'servers') {
+            collectPathServers(pathItem, path);
           }
-          if (!security && openapi.hasOwnProperty('security')) {
-            joinedDef.paths[path][operation]['security'] = addSecurityPrefix(
-              openapi.security,
-              componentsPrefix!
-            );
-          } else if (pathOperation.security) {
-            joinedDef.paths[path][operation].security = addSecurityPrefix(
-              pathOperation.security,
-              componentsPrefix!
-            );
+          if (field === 'parameters') {
+            collectPathParameters(pathItem, path);
+          }
+          if (typeof pathItem[field] === 'string') {
+            collectPathStringFields(pathItem, path, field);
           }
         }
       }
     }
+
+    function collectPathStringFields(
+      pathItem: Oas3PathItem,
+      path: string | number,
+      field: keyof Oas3PathItem
+    ) {
+      const fieldValue = pathItem[field];
+      if (
+        joinedDef.paths[path].hasOwnProperty(field) &&
+        joinedDef.paths[path][field] !== fieldValue
+      ) {
+        process.stderr.write(yellow(`warning: different ${field} values in ${path}\n`));
+        return;
+      }
+      joinedDef.paths[path][field] = fieldValue;
+    }
+
+    function collectPathServers(pathItem: Oas3PathItem, path: string | number) {
+      if (!pathItem.servers) {
+        return;
+      }
+
+      if (!joinedDef.paths[path].hasOwnProperty('servers')) {
+        joinedDef.paths[path].servers = [];
+      }
+
+      for (const server of pathItem.servers) {
+        let isFoundServer = false;
+        for (const pathServer of joinedDef.paths[path].servers) {
+          if (pathServer.url === server.url) {
+            if (!isServersEqual(pathServer, server)) {
+              exitWithError(`Different server values for (${server.url}) in ${path}`);
+            }
+            isFoundServer = true;
+          }
+        }
+
+        if (!isFoundServer) {
+          joinedDef.paths[path].servers.push(server);
+        }
+      }
+    }
+
+    function collectPathParameters(pathItem: Oas3PathItem, path: string | number) {
+      if (!pathItem.parameters) {
+        return;
+      }
+      if (!joinedDef.paths[path].hasOwnProperty('parameters')) {
+        joinedDef.paths[path].parameters = [];
+      }
+
+      for (const parameter of pathItem.parameters as Oas3Parameter[]) {
+        let isFoundParameter = false;
+        for (const pathParameter of joinedDef.paths[path].parameters) {
+          if (pathParameter.name === parameter.name && pathParameter.in === parameter.in) {
+            if (!isEqual(pathParameter.schema, parameter.schema)) {
+              exitWithError(`Different parameter schemas for (${parameter.name}) in ${path}`);
+            }
+            isFoundParameter = true;
+          }
+        }
+
+        if (!isFoundParameter) {
+          joinedDef.paths[path].parameters.push(parameter);
+        }
+      }
+    }
+
+    function collectPathOperation(
+      pathItem: Oas3PathItem,
+      path: string | number,
+      operation: OPENAPI3_METHOD
+    ) {
+      const pathOperation = pathItem[operation];
+
+      if (!pathOperation) {
+        return;
+      }
+
+      joinedDef.paths[path][operation] = pathOperation;
+      potentialConflicts.paths[path][operation] = [
+        ...(potentialConflicts.paths[path][operation] || []),
+        api,
+      ];
+
+      const { operationId } = pathOperation;
+
+      if (operationId) {
+        if (!potentialConflicts.paths.hasOwnProperty('operationIds')) {
+          potentialConflicts.paths['operationIds'] = {};
+        }
+        potentialConflicts.paths.operationIds[operationId] = [
+          ...(potentialConflicts.paths.operationIds[operationId] || []),
+          api,
+        ];
+      }
+
+      const { tags, security } = joinedDef.paths[path][operation];
+
+      if (tags) {
+        joinedDef.paths[path][operation].tags = tags.map((tag: string) =>
+          addPrefix(tag, tagsPrefix)
+        );
+        populateTags({
+          api,
+          apiFilename,
+          tags: formatTags(tags),
+          potentialConflicts,
+          tagsPrefix,
+          componentsPrefix,
+        });
+      } else {
+        joinedDef.paths[path][operation]['tags'] = [addPrefix('other', tagsPrefix || apiFilename)];
+        populateTags({
+          api,
+          apiFilename,
+          tags: formatTags(['other']),
+          potentialConflicts,
+          tagsPrefix: tagsPrefix || apiFilename,
+          componentsPrefix,
+        });
+      }
+      if (!security && openapi.hasOwnProperty('security')) {
+        joinedDef.paths[path][operation]['security'] = addSecurityPrefix(
+          openapi.security,
+          componentsPrefix!
+        );
+      } else if (pathOperation.security) {
+        joinedDef.paths[path][operation].security = addSecurityPrefix(
+          pathOperation.security,
+          componentsPrefix!
+        );
+      }
+    }
+  }
+
+  function isServersEqual(serverOne: Oas3Server, serverTwo: Oas3Server) {
+    if (serverOne.description === serverTwo.description) {
+      return isEqual(serverOne.variables, serverTwo.variables);
+    }
+
+    return false;
   }
 
   function collectComponents(
diff --git a/packages/cli/src/commands/split/types.ts b/packages/cli/src/commands/split/types.ts
index fce700b1..808da7fc 100644
--- a/packages/cli/src/commands/split/types.ts
+++ b/packages/cli/src/commands/split/types.ts
@@ -38,26 +38,26 @@ export const WEBHOOKS = 'webhooks';
 export const xWEBHOOKS = 'x-webhooks';
 export const componentsPath = `#/${COMPONENTS}/`;
 
-enum OPENAPI3_METHOD {
-  Get = 'get',
-  Put = 'put',
-  Post = 'post',
-  Delete = 'delete',
-  Options = 'options',
-  Head = 'head',
-  Patch = 'patch',
-  Trace = 'trace',
+export enum OPENAPI3_METHOD {
+  get = 'get',
+  put = 'put',
+  post = 'post',
+  delete = 'delete',
+  options = 'options',
+  head = 'head',
+  patch = 'patch',
+  trace = 'trace',
 }
 
 export const OPENAPI3_METHOD_NAMES: OPENAPI3_METHOD[] = [
-  OPENAPI3_METHOD.Get,
-  OPENAPI3_METHOD.Put,
-  OPENAPI3_METHOD.Post,
-  OPENAPI3_METHOD.Delete,
-  OPENAPI3_METHOD.Options,
-  OPENAPI3_METHOD.Head,
-  OPENAPI3_METHOD.Patch,
-  OPENAPI3_METHOD.Trace,
+  OPENAPI3_METHOD.get,
+  OPENAPI3_METHOD.put,
+  OPENAPI3_METHOD.post,
+  OPENAPI3_METHOD.delete,
+  OPENAPI3_METHOD.options,
+  OPENAPI3_METHOD.head,
+  OPENAPI3_METHOD.patch,
+  OPENAPI3_METHOD.trace,
 ];
 
 export enum OPENAPI3_COMPONENT {
diff --git a/packages/cli/src/js-utils.ts b/packages/cli/src/js-utils.ts
index c70a9c64..1f4f42cb 100644
--- a/packages/cli/src/js-utils.ts
+++ b/packages/cli/src/js-utils.ts
@@ -10,3 +10,8 @@ export function isEmptyObject(obj: any) {
 export function isString(str: string) {
   return Object.prototype.toString.call(str) === '[object String]';
 }
+
+export function keysOf<T>(obj: T) {
+  if (!obj) return [];
+  return Object.keys(obj) as (keyof T)[];
+}


Test patch:
diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts
index 68621e35..1bcc5515 100644
--- a/__tests__/commands.test.ts
+++ b/__tests__/commands.test.ts
@@ -143,6 +143,16 @@ describe('E2E', () => {
         (<any>expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
       });
     });
+
+    describe('with metadata', () => {
+      const testPath = join(__dirname, `join/with-metadata`);
+      const args = getParams('../../../packages/cli/src/index.ts', 'join', [
+        'test.yaml',
+        'pet.yaml',
+      ]);
+      const result = getCommandOutput(args, testPath);
+      (<any>expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
+    });
   });
 
   describe('bundle', () => {
diff --git a/__tests__/join/with-metadata/pet.yaml b/__tests__/join/with-metadata/pet.yaml
new file mode 100644
index 00000000..1e650ab9
--- /dev/null
+++ b/__tests__/join/with-metadata/pet.yaml
@@ -0,0 +1,48 @@
+openapi: 3.0.0
+servers:
+  - url: http://localhost:8080
+info:
+  description: example test
+  version: 1.0.0
+  title: Swagger Petstore
+  termsOfService: 'http://swagger.io/terms/'
+  license:
+    name: Apache 2.0
+    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
+paths:
+  '/GETUser/{userId}':
+    summary: getPet
+    parameters:
+      - name: param1
+        in: header
+        schema:
+          description: string
+    description: get Pet description
+    servers:
+      - url:
+          /pet
+        description: some description
+
+    get:
+      tags:
+        - pet
+      summary: Find pet by ID
+      description: Returns a single pet
+      operationId: getPetById
+      servers:
+        - url:
+            /pet
+      parameters:
+        - name: petId
+          in: path
+          description: ID of pet to return
+          required: true
+          deprecated: true
+          schema:
+            type: integer
+            format: int64
+      responses:
+        200:
+          description: ok
+        400:
+          description: bad request
diff --git a/__tests__/join/with-metadata/snapshot.js b/__tests__/join/with-metadata/snapshot.js
new file mode 100644
index 00000000..37f6086d
--- /dev/null
+++ b/__tests__/join/with-metadata/snapshot.js
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` 1`] = `
+
+warning: different summary values in /GETUser/{userId}
+warning: different description values in /GETUser/{userId}
+openapi: 3.0.0
+info:
+  description: test
+  version: 1.0.0
+  title: Swagger Petstore
+  termsOfService: http://swagger.io/terms/
+  license:
+    name: Apache 2.0
+    url: http://www.apache.org/licenses/LICENSE-2.0.html
+servers:
+  - url: http://localhost:8081
+  - url: http://localhost:8080
+paths:
+  /GETUser/{userId}:
+    summary: getUser
+    description: getUser
+    parameters:
+      - name: param2
+        in: header
+      - name: param1
+        in: header
+        schema:
+          description: string
+    servers:
+      - url: /test
+      - url: /pets
+        description: another description
+      - url: /pet
+        description: some description
+    post:
+      tags:
+        - user
+      description: Returns a single pet
+      operationId: getUserById
+      summary: get user info
+      parameters:
+        - name: userId
+          in: path
+          description: ID of pet to return
+          required: true
+          deprecated: true
+          schema:
+            type: integer
+            format: int64
+      responses:
+        '200':
+          description: oka
+    get:
+      tags:
+        - pet
+      summary: Find pet by ID
+      description: Returns a single pet
+      operationId: getPetById
+      servers:
+        - url: /pet
+      parameters:
+        - name: petId
+          in: path
+          description: ID of pet to return
+          required: true
+          deprecated: true
+          schema:
+            type: integer
+            format: int64
+      responses:
+        '200':
+          description: ok
+        '400':
+          description: bad request
+tags:
+  - name: user
+    x-displayName: user
+  - name: pet
+    x-displayName: pet
+x-tagGroups:
+  - name: test
+    tags:
+      - user
+  - name: pet
+    tags:
+      - pet
+components: {}
+
+openapi.yaml: join processed in <test>ms
+
+
+`;
diff --git a/__tests__/join/with-metadata/test.yaml b/__tests__/join/with-metadata/test.yaml
new file mode 100644
index 00000000..4e29d402
--- /dev/null
+++ b/__tests__/join/with-metadata/test.yaml
@@ -0,0 +1,43 @@
+openapi: 3.0.0
+servers:
+  - url: http://localhost:8081
+info:
+  description: test
+  version: 1.0.0
+  title: Swagger Petstore
+  termsOfService: 'http://swagger.io/terms/'
+  license:
+    name: Apache 2.0
+    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
+paths:
+  '/GETUser/{userId}':
+    summary: getUser
+    description: getUser
+    parameters:
+      - name: param2
+        in: header
+    servers:
+      - url:
+          /test
+      - url:
+          /pets
+        description: another description
+    post:
+      tags:
+        - user
+      description: Returns a single pet
+      operationId: getUserById
+      summary: get user info
+      parameters:
+        - name: userId
+          in: path
+          description: ID of pet to return
+          required: true
+          deprecated: true
+          schema:
+            type: integer
+            format: int64
+      responses:
+        '200':
+          description: oka
+
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index 6ab9f759..a5ebbe60 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -1,6 +1,7 @@
 import { handleJoin } from '../../commands/join';
-import { exitWithError } from '../../utils';
+import { exitWithError, writeYaml } from '../../utils';
 import { yellow } from 'colorette';
+import { detectOpenAPI } from '@redocly/openapi-core';
 
 jest.mock('../../utils');
 jest.mock('colorette');
@@ -44,4 +45,25 @@ describe('handleJoin fails', () => {
       `You use prefix-tags-with-filename, without-x-tag-groups together.\nPlease choose only one! \n\n`
     );
   });
+
+  it('should call exitWithError because Only OpenAPI 3 is supported', async () => {
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml'],
+      },
+      'cli-version'
+    );
+    expect(exitWithError).toHaveBeenCalledWith('Only OpenAPI 3 is supported: undefined \n\n');
+  });
+
+  it('should call writeYaml function', async () => {
+    (detectOpenAPI as jest.Mock).mockReturnValue('oas3_0');
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml'],
+      },
+      'cli-version'
+    );
+    expect(writeYaml).toHaveBeenCalled();
+  });
 });