From 4f40d190547bb3423091fde6190e6a87f0b65fe3 Mon Sep 17 00:00:00 2001 From: CptSchnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:15:39 +0300 Subject: [PATCH] test: all except config --- config/default.json | 4 -- package.json | 2 +- src/config.ts | 2 + src/env.ts | 6 +- src/httpClient.ts | 2 +- src/options.ts | 31 ++++---- tests/config.spec.ts | 54 ++++++++++++++ tests/config/default.json | 5 ++ tests/config/test.json | 1 + tests/configurations/jest.config.js | 2 +- tests/env.spec.ts | 107 ++++++++++++++++++++++++++++ tests/httpClient.spec.ts | 91 +++++++++++++++++++++++ tests/options.spec.ts | 95 ++++++++++++++++++++++++ tests/schemas.spec.ts | 15 +++- tests/validator.spec.ts | 9 +++ 15 files changed, 396 insertions(+), 30 deletions(-) delete mode 100644 config/default.json create mode 100644 tests/config.spec.ts create mode 100644 tests/config/default.json create mode 100644 tests/config/test.json create mode 100644 tests/env.spec.ts create mode 100644 tests/httpClient.spec.ts create mode 100644 tests/options.spec.ts diff --git a/config/default.json b/config/default.json deleted file mode 100644 index ae121db..0000000 --- a/config/default.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "avi": "test", - "host": "avi" -} diff --git a/package.json b/package.json index b4a8b53..0083d95 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "prelint": "npm run format", "lint": "eslint .", "lint:fix": "eslint --fix .", - "test": "jest --config=./tests/configurations/jest.config.js", + "test": "SUPPRESS_NO_CONFIG_WARNING=true jest --config=./tests/configurations/jest.config.js", "prebuild": "npm run clean", "build": "tsc --project tsconfig.build.json", "start": "npm run build && cd dist && node ./index.js", diff --git a/src/config.ts b/src/config.ts index bd6ccd3..3a697a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,8 @@ export async function config( const dereferencedSchema = await loadSchema(baseSchema); + console.log(options.localConfigPath); + const localConfig = configPkg.util.loadFileConfigs(options.localConfigPath) as { [key: string]: unknown }; debug('local config: %j', localConfig); diff --git a/src/env.ts b/src/env.ts index 5173f57..1d8441a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,7 +7,7 @@ const debug = createDebug('env'); const schemaCompositionKeys = ['oneOf', 'anyOf', 'allOf'] as const; -export function parseSchemaEnv(schema: JSONSchema): EnvMap { +function parseSchemaEnv(schema: JSONSchema): EnvMap { debug('parsing schema for env values'); const fromEnv: EnvMap = {}; @@ -27,7 +27,7 @@ export function parseSchemaEnv(schema: JSONSchema): EnvMap { function iterateOverSchemaObject(schema: JSONSchema, path: string): void { debug('iterating over schema object at path %s', path); const type = schema.type; - if (type === 'number' || type === 'string' || type === 'boolean' || type === 'integer' || type === 'null') { + if (type === 'number' || type === 'string' || type === 'boolean' || type === 'integer' || type === 'null') { return handlePrimitive(schema, type, path); } @@ -76,7 +76,7 @@ export function getEnvValues(schema: JSONSchema): object { switch (details.type) { case 'boolean': - value = value === 'true'; + value = unparsedValue.toLowerCase() === 'true'; break; case 'integer': value = parseInt(unparsedValue); diff --git a/src/httpClient.ts b/src/httpClient.ts index f16007c..845cc91 100644 --- a/src/httpClient.ts +++ b/src/httpClient.ts @@ -21,7 +21,7 @@ async function requestWrapper(url: string, query?: Record): Pro const res = await request(url, { query }); if (res.statusCode > StatusCodes.NOT_FOUND) { debug('Failed to fetch config. Status code: %d', res.statusCode); - throw createConfigError('httpResponseError', 'Failed to fetch config', await createHttpErrorPayload(res)); + throw createConfigError('httpResponseError', 'Failed to fetch', await createHttpErrorPayload(res)); } return res; } catch (error) { diff --git a/src/options.ts b/src/options.ts index 8e7bda2..42c8303 100644 --- a/src/options.ts +++ b/src/options.ts @@ -6,27 +6,24 @@ import { createConfigError } from './errors'; const debug = createDebug('options'); -function getEnvOptions(): Partial> { - const envOptions: Partial> = { - configName: process.env.CONFIG_NAME, - configServerUrl: process.env.CONFIG_SERVER_URL, - version: process.env.CONFIG_VERSION, - offlineMode: process.env.CONFIG_OFFLINE_MODE, - ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, - }; - - // in order to merge correctly the keys should not exist, undefined is not enough - for (const key in envOptions) { - if (envOptions[key as keyof BaseOptions] === undefined) { - delete envOptions[key as keyof BaseOptions]; - } +const envOptions: Partial> = { + configName: process.env.CONFIG_NAME, + configServerUrl: process.env.CONFIG_SERVER_URL, + version: process.env.CONFIG_VERSION, + offlineMode: process.env.CONFIG_OFFLINE_MODE, + ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, +}; + +// in order to merge correctly the keys should not exist, undefined is not enough +for (const key in envOptions) { + if (envOptions[key as keyof BaseOptions] === undefined) { + delete envOptions[key as keyof BaseOptions]; } - return envOptions; } + let baseOptions: BaseOptions | undefined = undefined; export function initializeOptions(options: Partial): BaseOptions { - const envOptions = getEnvOptions(); debug('initializing options with %j and env %j', options, envOptions); const mergedOptions = deepmerge(options, envOptions); @@ -56,4 +53,4 @@ export function getOptions(): BaseOptions { debug('returning options'); return baseOptions; -} +} \ No newline at end of file diff --git a/tests/config.spec.ts b/tests/config.spec.ts new file mode 100644 index 0000000..784d124 --- /dev/null +++ b/tests/config.spec.ts @@ -0,0 +1,54 @@ +import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; +import { commonDbPartialV1 } from '@map-colonies/schemas'; +import { StatusCodes } from 'http-status-codes'; +import { config } from '../src/config'; + +const URL = 'http://localhost:8080'; +describe('config', () => { + describe('#config', () => { + describe('httpClient', () => { + let client: Interceptable; + beforeEach(() => { + const agent = new MockAgent(); + agent.disableNetConnect(); + + setGlobalDispatcher(agent); + client = agent.get(URL); + }); + + it('should return the config with all the default values', async () => { + const configData = { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { + host: 'avi', + }, + createdAt: 0, + }; + + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).reply(StatusCodes.OK, configData); + + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + configServerUrl: URL, + localConfigPath: '../tests/config', + }); + + const conf = configInstance.getAll(); + + expect(conf).toEqual({ + host: 'avi', + port: 5432, + username: 'postgres', + password: 'postgres', + ssl: { + enabled: false, + } + }); + }); + }); + }); +}); diff --git a/tests/config/default.json b/tests/config/default.json new file mode 100644 index 0000000..83945a3 --- /dev/null +++ b/tests/config/default.json @@ -0,0 +1,5 @@ +{ + "ssl": { + "enabled": false + } +} \ No newline at end of file diff --git a/tests/config/test.json b/tests/config/test.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/config/test.json @@ -0,0 +1 @@ +{} diff --git a/tests/configurations/jest.config.js b/tests/configurations/jest.config.js index 23f8a74..36ad78f 100644 --- a/tests/configurations/jest.config.js +++ b/tests/configurations/jest.config.js @@ -4,7 +4,7 @@ module.exports = { }, coverageReporters: ['text', 'html'], collectCoverage: true, - collectCoverageFrom: ['/src/**/*.ts', '!*/node_modules/', '!/vendor/**', '!*/common/**', '!**/models/**', '!/src/*'], + collectCoverageFrom: ['/src/**/*.ts', '!*/node_modules/', '!/vendor/**', '!/src/index.ts'], coverageDirectory: '/coverage', rootDir: '../../.', testMatch: ['/tests/**/*.spec.ts'], diff --git a/tests/env.spec.ts b/tests/env.spec.ts new file mode 100644 index 0000000..8442b79 --- /dev/null +++ b/tests/env.spec.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { satisfies } from 'semver'; +import { JSONSchema } from '@apidevtools/json-schema-ref-parser'; +import { getEnvValues } from '../src/env'; +import { EnvType } from '../src/types'; + +describe('env', () => { + describe('#getEnvValues', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + // jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV }; // Make a copy + }); + + afterAll(() => { + process.env = OLD_ENV; // Restore old environment + }); + + it('should return the env values', () => { + process.env.FOO = 'bar'; + const schema = { + type: 'object', + properties: { + foo: { type: 'string', 'x-env-value': 'FOO' }, + }, + required: ['foo'], + } satisfies JSONSchema; + + const envValues = getEnvValues(schema); + + expect(envValues).toEqual({ foo: 'bar' }); + }); + + it.each([ + ['string', 'bar', 'bar'], + ['boolean', 'true', true], + ['integer', '1', 1], + ['number', '1.5', 1.5], + ['null', 'null', null], + ])(`should return the env values for %s`, (type, value, expected) => { + process.env.FOO = value; + const schema = { + type: 'object', + properties: { + foo: { type: type as EnvType, 'x-env-value': 'FOO' }, + }, + required: ['foo'], + } satisfies JSONSchema; + + const envValues = getEnvValues(schema); + + expect(envValues).toEqual({ foo: expected }); + }); + + it('should handle nested objects', () => { + process.env.FOO = 'bar'; + const schema = { + type: 'object', + properties: { + foo: { type: 'object', properties: { baz: { type: 'string', 'x-env-value': 'FOO' } } }, + }, + required: ['foo'], + } satisfies JSONSchema; + + const envValues = getEnvValues(schema); + + expect(envValues).toEqual({ foo: { baz: 'bar' } }); + }); + + it('should not handle array or any type, or any object inside of an array', () => { + process.env.FOO = 'bar'; + process.env.BAZ = 'baz'; + const schema = { + type: 'object', + properties: { + foo: { type: 'array', 'x-env-value': 'FOO', items: { type: 'string', 'x-env-value': "BAZ" } }, + }, + required: ['foo'], + } satisfies JSONSchema; + + const envValues = getEnvValues(schema); + + expect(envValues).toEqual({}); + }); + + it('should handle values in composed objects', () => { + process.env.FOO = 'bar'; + const schema = { + type: 'object', + allOf: [ + { + type: 'object', + properties: { + foo: { type: 'string', 'x-env-value': 'FOO' }, + }, + required: ['foo'], + }, + ], + } satisfies JSONSchema; + + const envValues = getEnvValues(schema); + + expect(envValues).toEqual({ foo: 'bar' }); + }); + }); +}); diff --git a/tests/httpClient.spec.ts b/tests/httpClient.spec.ts new file mode 100644 index 0000000..435056c --- /dev/null +++ b/tests/httpClient.spec.ts @@ -0,0 +1,91 @@ +import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; +import { StatusCodes } from 'http-status-codes'; +import { getRemoteConfig, getServerCapabilities } from '../src/httpClient'; +import { getOptions } from '../src/options'; +import { BaseOptions } from '../src/types'; + +jest.mock('../src/options'); + +const URL = 'http://localhost:8080'; + +const mockedGetOptions = getOptions as jest.MockedFunction; + +mockedGetOptions.mockReturnValue({ + configServerUrl: URL, +} as BaseOptions); + +describe('httpClient', () => { + let client: Interceptable; + beforeEach(() => { + const agent = new MockAgent(); + agent.disableNetConnect(); + + setGlobalDispatcher(agent); + client = agent.get(URL); + }); + + describe('#getServerCapabilities', () => { + it('should return the server capabilities', async () => { + const capabilities = { serverVersion: '1.0.0', schemasPackageVersion: '1.0.0', pubSubEnabled: true }; + + client.intercept({ path: '/capabilities', method: 'GET' }).reply(StatusCodes.OK, capabilities); + + const result = await getServerCapabilities(); + + expect(result).toEqual(capabilities); + }); + + it('should throw an error if the request fails', async () => { + client.intercept({ path: '/capabilities', method: 'GET' }).reply(StatusCodes.INTERNAL_SERVER_ERROR, 'Internal Server Error'); + + await expect(getServerCapabilities()).rejects.toThrow('Failed to fetch'); + }); + + it('should throw an error if the request fails to be sent', async () => { + client.intercept({ path: '/capabilities', method: 'GET' }).replyWithError(new Error('oh noes')); + + await expect(getServerCapabilities()).rejects.toThrow('An error occurred while making the request'); + }); + }); + + describe('#getRemoteConfig', () => { + it('should return the remote config', async () => { + const config = { + configName: 'name', + schemaId: 'schema', + version: 1, + config: {}, + createdAt: 0, + }; + + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).reply(StatusCodes.OK, config); + + const result = await getRemoteConfig('name', 1); + expect(result).toEqual(config); + }); + + it('should throw an error if the response is bad request', async () => { + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).reply(StatusCodes.BAD_REQUEST, 'Bad request'); + + await expect(getRemoteConfig('name', 1)).rejects.toThrow('Invalid request to getConfig'); + }); + + it('should throw an error if the response is not found', async () => { + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).reply(StatusCodes.NOT_FOUND, 'Not found'); + + await expect(getRemoteConfig('name', 1)).rejects.toThrow('Config with given name and version was not found'); + }); + + it('should throw an error if the request fails', async () => { + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).reply(StatusCodes.INTERNAL_SERVER_ERROR, 'Internal Server Error'); + + await expect(getRemoteConfig('name', 1)).rejects.toThrow('Failed to fetch'); + }); + + it('should throw an error if the request fails to be sent', async () => { + client.intercept({ path: '/config/name/1?shouldDereference=true', method: 'GET' }).replyWithError(new Error('oh noes')); + + await expect(getRemoteConfig('name', 1)).rejects.toThrow('An error occurred while making the request'); + }); + }); +}); diff --git a/tests/options.spec.ts b/tests/options.spec.ts new file mode 100644 index 0000000..fd797f9 --- /dev/null +++ b/tests/options.spec.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +describe('options', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + describe('#getOptions', () => { + it('should return the options if initialized', () => { + const { getOptions, initializeOptions } = require('../src/options'); + + initializeOptions({ configName: 'name', version: 'latest', configServerUrl: 'http://localhost:8080' }); + + const options = getOptions(); + + expect(options).toBeDefined(); + }); + + it('should throw an error if options are not initialized', () => { + const { getOptions } = require('../src/options'); + + expect(() => { + getOptions(); + }).toThrow(); + }); + }); + + describe('#initializeOptions', () => { + it('should initialize options with env variables', () => { + process.env.CONFIG_NAME = 'name'; + process.env.CONFIG_VERSION = 'latest'; + process.env.CONFIG_SERVER_URL = 'http://localhost:8080'; + + const { initializeOptions } = require('../src/options'); + + const options = initializeOptions({}); + + expect(options).toBeDefined(); + }); + + it('should initialize options with env variables and override with provided options', () => { + process.env.CONFIG_NAME = 'name'; + process.env.CONFIG_VERSION = 'latest'; + process.env.CONFIG_SERVER_URL = 'http://localhost:8080'; + + const { initializeOptions } = require('../src/options'); + + const options = initializeOptions({ configName: 'newName' }); + + expect(options).toHaveProperty('configName', 'name'); + }); + + it('should throw an error if options are invalid', () => { + process.env.CONFIG_VERSION = 'latest'; + process.env.CONFIG_SERVER_URL = 'http://localhost:8080'; + + const { initializeOptions } = require('../src/options'); + + expect(() => { + initializeOptions({}); + }).toThrow(); + }); + + it.each([ + ['configName', 'CONFIG_NAME', 'avi', 'avi'], + ['configServerUrl', 'CONFIG_SERVER_URL', 'http://localhost:8080', 'http://localhost:8080'], + ['version', 'CONFIG_VERSION', 'latest', 'latest'], + ['offlineMode', 'CONFIG_OFFLINE_MODE', 'true', true], + ['ignoreServerIsOlderVersionError', 'CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR', 'true', true], + ])('should initialize options and override with provided environment variable %s', (key, envKey, envValue, expected) => { + process.env[envKey] = envValue; + + const { initializeOptions } = require('../src/options'); + + const options = initializeOptions({ + configName: 'xv', + version: 1, + configServerUrl: 'http://localhost', + offlineMode: false, + ignoreServerIsOlderVersionError: false, + }); + + expect(options).toHaveProperty(key, expected); + }); + }); +}); diff --git a/tests/schemas.spec.ts b/tests/schemas.spec.ts index 1456272..3320515 100644 --- a/tests/schemas.spec.ts +++ b/tests/schemas.spec.ts @@ -51,14 +51,23 @@ describe('schemas', () => { const dereferencedSchema = await loadSchema(schema); - expect(dereferencedSchema).toEqual({ - $id: 'https://mapcolonies.com/test', + expect(dereferencedSchema).toHaveProperty('allOf[0].$id', 'https://mapcolonies.com/common/db/partial/v1'); + }); + + it('should throw an error if the schema is not found', async () => { + const schema = { + $id: 'https://mapcolonies.com/base', type: 'object', properties: { foo: { type: 'string' }, + bar: { $ref: 'https://mapcolonies.com/not-found' }, }, required: ['foo'], - }); + } satisfies JSONSchema; + + const promise = loadSchema(schema); + + await expect(promise).rejects.toThrow(); }); }); }); diff --git a/tests/validator.spec.ts b/tests/validator.spec.ts index dbdd511..06b0dfa 100644 --- a/tests/validator.spec.ts +++ b/tests/validator.spec.ts @@ -60,5 +60,14 @@ describe('validator', () => { expect(errors).toBeUndefined(); expect(validatedData).toEqual({ foo: 1 }); }); + + it('should throw an error if the schema is an boolean', () => { + const schema = true; + const data = {}; + + const action = () => validate(ajvOptionsValidator, schema, data); + + expect(action).toThrow() + }); }); });