Skip to content

Commit

Permalink
Add Injectable IConfigurationService (#41)
Browse files Browse the repository at this point in the history
- IConfigurationService is an injectable class which provides an interface which can be used to read from the loaded configuration
- The default implementation provided uses ConfigService from Nest which reads values from configuration.ts
- Adds a TestConfigurationModule which includes a FakeConfigurationService – this FakeConfigurationService can be used to override environment variables in a test setup.
  • Loading branch information
fmrsabino authored Aug 19, 2022
1 parent d5daa1b commit f1d434a
Show file tree
Hide file tree
Showing 15 changed files with 229 additions and 41 deletions.
10 changes: 9 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ import { ChainsModule } from './chains/chains.module';
import { BalancesModule } from './balances/balances.module';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { NetworkModule } from './common/network/network.module';
import { ConfigurationModule } from './common/config/configuration.module';

@Module({
imports: [BalancesModule, ChainsModule, NetworkModule],
imports: [
// features
BalancesModule,
ChainsModule,
// common
NetworkModule,
ConfigurationModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
Expand Down
54 changes: 36 additions & 18 deletions src/balances/balances.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,38 @@ import * as request from 'supertest';
import safeBalanceFactory from '../services/transaction-service/entities/__tests__/balance.factory';
import exchangeResultFactory from '../services/exchange/entities/__tests__/exchange.factory';
import chainFactory from '../services/config-service/entities/__tests__/chain.factory';
import configuration from '../config/configuration';
import {
mockNetworkService,
TestNetworkModule,
} from '../common/network/__tests__/test.network.module';
import { BalancesModule } from './balances.module';
import {
fakeConfigurationService,
TestConfigurationModule,
} from '../common/config/__tests__/test.configuration.module';

describe('Balances Controller (Unit)', () => {
let app: INestApplication;

beforeAll(async () => {
fakeConfigurationService.set('exchange.baseUri', 'https://test.exchange');
fakeConfigurationService.set(
'safeConfig.baseUri',
'https://test.safe.config',
);
});

beforeEach(async () => {
jest.clearAllMocks();

const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [BalancesModule, TestNetworkModule],
imports: [
// feature
BalancesModule,
// common
TestConfigurationModule,
TestNetworkModule,
],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
Expand All @@ -35,7 +53,7 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = exchangeResultFactory({ USD: 2.0 });
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -44,7 +62,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand Down Expand Up @@ -78,7 +96,7 @@ describe('Balances Controller (Unit)', () => {
// Once caching is in place we don't need to retrieve the Chain Data again
expect(mockNetworkService.get.mock.calls.length).toBe(4);
expect(mockNetworkService.get.mock.calls[0][0]).toBe(
'https://safe-config.gnosis.io/api/v1/chains/1',
'https://test.safe.config/api/v1/chains/1',
);
expect(mockNetworkService.get.mock.calls[1][0]).toBe(
`${chainResponse.transactionService}/api/v1/safes/0x0000000000000000000000000000000000000001/balances/usd/`,
Expand All @@ -87,7 +105,7 @@ describe('Balances Controller (Unit)', () => {
params: { trusted: undefined, excludeSpam: undefined },
});
expect(mockNetworkService.get.mock.calls[2][0]).toBe(
configuration().exchange.baseUri,
'https://test.exchange',
);
});

Expand All @@ -98,7 +116,7 @@ describe('Balances Controller (Unit)', () => {
const transactionServiceBalancesResponse = safeBalanceFactory(1);
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -107,7 +125,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.reject({ status: 500 });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand All @@ -132,7 +150,7 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = {}; // no rates
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -141,7 +159,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand All @@ -167,7 +185,7 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = exchangeResultFactory({ XYZ: 2 }); // Returns different rate than USD
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -176,7 +194,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand All @@ -202,7 +220,7 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = exchangeResultFactory({ USD: 0 }); // rate is zero
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -211,7 +229,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand All @@ -238,7 +256,7 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = exchangeResultFactory({ USD: 2 }); // Returns different rate than XYZ
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
Expand All @@ -247,7 +265,7 @@ describe('Balances Controller (Unit)', () => {
return Promise.resolve({
data: transactionServiceBalancesResponse,
});
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand All @@ -274,14 +292,14 @@ describe('Balances Controller (Unit)', () => {
const exchangeResponse = exchangeResultFactory({ USD: 2.0 });
const chainResponse = chainFactory(chainId);
mockNetworkService.get.mockImplementation((url) => {
if (url == `https://safe-config.gnosis.io/api/v1/chains/${chainId}`) {
if (url == `https://test.safe.config/api/v1/chains/${chainId}`) {
return Promise.resolve({ data: chainResponse });
} else if (
url ==
`${chainResponse.transactionService}/api/v1/safes/${safeAddress}/balances/usd/`
) {
return Promise.reject({ status: 500 });
} else if (url == configuration().exchange.baseUri) {
} else if (url == 'https://test.exchange') {
return Promise.resolve({ data: exchangeResponse });
} else {
return Promise.reject(new Error(`Could not match ${url}`));
Expand Down
19 changes: 18 additions & 1 deletion src/chains/chains.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
mockNetworkService,
TestNetworkModule,
} from '../common/network/__tests__/test.network.module';
import {
fakeConfigurationService,
TestConfigurationModule,
} from '../common/config/__tests__/test.configuration.module';

describe('Chains Controller (Unit)', () => {
let app: INestApplication;
Expand All @@ -23,10 +27,23 @@ describe('Chains Controller (Unit)', () => {
const chainResponse: Chain = chainFactory();
const backboneResponse: Backbone = backboneFactory();

beforeAll(async () => {
fakeConfigurationService.set(
'safeConfig.baseUri',
'https://test.safe.config',
);
});

beforeEach(async () => {
jest.clearAllMocks();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ChainsModule, TestNetworkModule],
imports: [
// feature
ChainsModule,
// common
TestConfigurationModule,
TestNetworkModule,
],
}).compile();

app = moduleFixture.createNestApplication();
Expand Down
36 changes: 36 additions & 0 deletions src/common/config/__tests__/fake.configuration.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FakeConfigurationService } from './fake.configuration.service';

describe('FakeConfigurationService', () => {
let configurationService: FakeConfigurationService;

beforeEach(async () => {
configurationService = new FakeConfigurationService();
});

it(`Setting key should store its value`, async () => {
configurationService.set('aaa', 'bbb');

const result = configurationService.get('aaa');

expect(configurationService.keyCount()).toBe(1);
expect(result).toBe('bbb');
});

it(`Retrieving unknown key should return undefined`, async () => {
configurationService.set('aaa', 'bbb');

const result = configurationService.get('unknown_key');

expect(result).toBe(undefined);
});

it(`Retrieving unknown key should throw`, async () => {
configurationService.set('aaa', 'bbb');

const result = () => {
configurationService.getOrThrow('unknown_key');
};

expect(result).toThrow(Error('No value set for key unknown_key'));
});
});
26 changes: 26 additions & 0 deletions src/common/config/__tests__/fake.configuration.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IConfigurationService } from '../configuration.service.interface';

export class FakeConfigurationService implements IConfigurationService {
private configuration: Record<string, any> = {};

set(key: string, value: any) {
this.configuration[key] = value;
}

keyCount(): number {
return Object.keys(this.configuration).length;
}

get<T>(key: string): T | undefined {
return this.configuration[key] as T;
}

getOrThrow<T>(key: string): T {
const value = this.configuration[key];
if (value === undefined) {
throw Error(`No value set for key ${key}`);
}

return value as T;
}
}
32 changes: 32 additions & 0 deletions src/common/config/__tests__/test.configuration.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Global, Module } from '@nestjs/common';
import { IConfigurationService } from '../configuration.service.interface';
import { FakeConfigurationService } from './fake.configuration.service';

/**
* {@link fakeConfigurationService} should be used in a test setup.
*
* It provides the ability to set a specific configuration key to any value.
*
* {@link fakeConfigurationService} is available only when a module imports
* {@link TestConfigurationModule}
*/
export const fakeConfigurationService = new FakeConfigurationService();

/**
* The {@link TestConfigurationModule} should be used whenever you want to
* override the values provided by {@link NestConfigurationService}
*
* Example:
* Test.createTestingModule({ imports: [ModuleA, TestConfigurationModule]}).compile();
*
* This will create a TestModule which uses the implementation of ModuleA but
* overrides the real Configuration Module with a fake one – {@link fakeConfigurationService}
*/
@Global()
@Module({
providers: [
{ provide: IConfigurationService, useValue: fakeConfigurationService },
],
exports: [IConfigurationService],
})
export class TestConfigurationModule {}
14 changes: 14 additions & 0 deletions src/common/config/configuration.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigurationModule } from './configuration.module';

describe('ConfigurationModule', () => {
it(`ConfigurationModule is successfully created`, async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ConfigurationModule],
}).compile();

const app = moduleFixture.createNestApplication();
await app.init();
await app.close();
});
});
26 changes: 26 additions & 0 deletions src/common/config/configuration.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { IConfigurationService } from './configuration.service.interface';
import { NestConfigurationService } from './nest.configuration.service';
import configuration from './entities/configuration';

/**
* A {@link Global} Module which provides local configuration support via {@link IConfigurationService}
* Feature Modules don't need to import this module directly in order to inject
* the {@link IConfigurationService}.
*
* This module should be included in the "root" application module
*/
@Global()
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
}),
],
providers: [
{ provide: IConfigurationService, useClass: NestConfigurationService },
],
exports: [IConfigurationService],
})
export class ConfigurationModule {}
6 changes: 6 additions & 0 deletions src/common/config/configuration.service.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const IConfigurationService = Symbol('IConfigurationService');

export interface IConfigurationService {
get<T>(key: string): T | undefined;
getOrThrow<T>(key: string): T;
}
File renamed without changes.
Loading

0 comments on commit f1d434a

Please sign in to comment.