From 41380776aa0fa2ed86aaa2b8fd0b8411ce6f14a0 Mon Sep 17 00:00:00 2001 From: Alin Stefan Olaru Date: Wed, 17 Apr 2024 14:06:36 +0200 Subject: [PATCH] test: unit tests and misc (#4) * tests and misc * tests for service * wip: tests for provider * docs and fluff * strings * tests: provider unit tests for all cases * tests ci * url not ending with slash * comment * outputs directly * fancy caution message * mockimpl but shorter * removed unused getallgates and tests * more test cases for regex --- .github/workflows/ci.yml | 15 + CONTRIBUTING.md | 40 ++ README.md | 77 ++++ example/Pulumi.test.yaml | 1 + example/index.ts | 12 +- package.json | 11 +- src/index.ts | 3 +- src/link-mobility.provider.ts | 40 +- src/link-mobility.service.ts | 45 ++- src/link-mobiliy-gate-destination.resource.ts | 19 + ...biliy-partner-gate-destination.resource.ts | 20 - src/tests/link-mobility.provider.test.ts | 255 ++++++++++++ src/tests/link-mobility.service.test.ts | 382 ++++++++++++++++++ 13 files changed, 856 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 example/Pulumi.test.yaml create mode 100644 src/link-mobiliy-gate-destination.resource.ts delete mode 100644 src/link-mobiliy-partner-gate-destination.resource.ts create mode 100644 src/tests/link-mobility.provider.test.ts create mode 100644 src/tests/link-mobility.service.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39e1cda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +on: + push: + branches-ignore: [main] + +name: CI +jobs: + unit-tests: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: npm install + - name: Run unit tests + run: npm test \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2fd0253 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +## Contribution + +This is great that you'd like to contribute to this project. All change requests should go through the steps described below. + +## Pull Requests + +**Please, make sure you open an issue before starting with a Pull Request, unless it's a typo or an obvious error.** Pull requests are the best way to propose changes to the specification. + +## Conventional commits + +Our repository follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) specification. Releasing to GitHub and NPM is done with the support of [semantic-release](https://semantic-release.gitbook.io/semantic-release/). + +Pull requests should have a title that follows the specification, otherwise, merging is blocked. If you are not familiar with the specification, simply ask maintainers to modify. You can also use this cheat sheet if you want: + +- `fix: ` prefix in the title indicates that PR is a bug fix and a PATCH release must be triggered. +- `feat: ` prefix in the title indicates that PR is a feature and a MINOR release must be triggered. +- `docs: ` prefix in the title indicates that PR is only related to the documentation and there is no need to trigger a release. +- `chore: ` prefix in the title indicates that PR is only related to cleanup in the project and there is no need to trigger a release. +- `test: ` prefix in the title indicates that PR is only related to tests and there is no need to trigger a release. +- `refactor: ` prefix in the title indicates that PR is only related to refactoring and there is no need to trigger a release. + +What about a MAJOR release? Just add `!` to the prefix, like `fix!: ` or `refactor!: ` + +Prefix that follows specification is not enough though. Remember that the title must be clear and descriptive with the usage of [imperative mood](https://chris.beams.io/posts/git-commit/#imperative). + +## Resources + +- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) +- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) +- [GitHub Help](https://help.github.com) + +## Example project + +You can create a Symlink in the example project by running the following command: + +```bash +npm link .. +``` + +This will allow you to live-test your changes and it requires you have your own Pulumi backend. diff --git a/README.md b/README.md index 8f8696c..6a370a1 100644 --- a/README.md +++ b/README.md @@ -1 +1,78 @@ # pulumi-link-mobility-provider + +A pulumi custom provider that allows you to create, update and delete destinations in Link Mobility's partner gate. + +## Installation + +To use from JavaScript or TypeScript in Node.js, install using either `npm`: + +``` +npm install @lego/pulumi-link-mobility-provider +``` + +or `yarn`: + +``` +yarn add @lego/pulumi-link-mobility-provider +``` + +## Usage + +```typescript +const provider = new LinkMobilityPartnerGateDestinationProvider({ + username: 'myGateUsername', + password: 'myNotSoSecretPassword', + url: 'https://n-eu.linkmobility.io', + partner: 'myPartner', + platform: 'myPlatform', +}); + +new LinkMobilityPartnerGateDestination('link-mobility-foo-bar-destination', { + provider: provider, + partnerGateId: 'myPartnerGateId', + destination: { + url: 'https://foo.bar', + contentType: 'application/json', + // Username & password + username: 'fooBarUsername', + password: 'myEvenWorsePassword', + // Custom auth with API Key header + customParameters: { + 'http.header1': `x-my-secret-header:myApiKey`, + }, + }, +}); +``` + +> [!CAUTION] +> It is highly recommended you do _NOT_ leave your passwords/API-keys in clear text, but instead store them as secrets in your Pulumi project. For the sake of showing an example they have been left in clear text here. + +## Contribution + +This project welcomes contributions and suggestions. +Would you like to contribute to the project? Learn how to contribute [here](CONTRIBUTING.md). + +## License + +[Modified Apache 2.0 (Section 6)](LICENSE) + +## Open Source Attribution + +### Project Dependencies + +- [@pulumi/pulumi](https://www.npmjs.com/package/@pulumi/pulumi): [Apache 2.0](https://github.com/pulumi/pulumi/blob/master/LICENSE) + +### Dev Dependencies + +- [@semantic-release/changelog](https://www.npmjs.com/package/@semantic-release/changelog) - [MIT](https://github.com/semantic-release/changelog/blob/master/LICENSE) +- [@semantic-release/git](https://www.npmjs.com/package/@semantic-release/git) - [MIT](https://github.com/semantic-release/git/blob/master/LICENSE) +- [@types/jest](https://www.npmjs.com/package/@types/jest): [MIT](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/LICENSE) +- [@types/node](https://www.npmjs.com/package/@types/node): [MIT](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/LICENSE) +- [@typescript-eslint/eslint-plugin](https://www.npmjs.com/package/@typescript-eslint/eslint-plugin): [BSD-2-Clause](https://github.com/typescript-eslint/typescript-eslint/blob/main/LICENSE) +- [@typescript-eslint/parser](https://www.npmjs.com/package): [BSD-2-Clause](https://github.com/typescript-eslint/typescript-eslint/blob/main/LICENSE) +- [semantic-release](https://www.npmjs.com/package/semantic-release): [MIT](https://github.com/semantic-release/semantic-release/blob/master/LICENSE) +- [eslint](https://www.npmjs.com/package/eslint): [MIT](https://github.com/eslint/eslint/blob/main/LICENSE) +- [husky](https://github.com/typicode/husky): [MIT](https://github.com/typicode/husky?tab=MIT-1-ov-file#readme) +- [jest](https://www.npmjs.com/package/jest): [MIT](https://github.com/facebook/jest/blob/main/LICENSE) +- [ts-jest](https://www.npmjs.com/package/ts-jest): [MIT](https://github.com/kulshekhar/ts-jest/blob/main/LICENSE.md) +- [typescript](https://www.npmjs.com/package/typescript): [Apache 2.0](https://github.com/microsoft/TypeScript/blob/main/LICENSE.txt) diff --git a/example/Pulumi.test.yaml b/example/Pulumi.test.yaml new file mode 100644 index 0000000..f4b4bd0 --- /dev/null +++ b/example/Pulumi.test.yaml @@ -0,0 +1 @@ +encryptionsalt: v1:XbnIsPJjAKk=:v1:JP1+ntD/KahbnJtH:nqEMZk68CMXs21lq0nMDfil9dtcMUQ== diff --git a/example/index.ts b/example/index.ts index 1237594..8a9f5ec 100644 --- a/example/index.ts +++ b/example/index.ts @@ -5,16 +5,16 @@ import { import 'dotenv/config'; const provider = new LinkMobilityPartnerGateDestinationProvider({ - username: process.env.USERNAME!, - password: process.env.PASSWORD!, - url: 'https://XX.linkmobility.io', - partner: '0000', - platform: 'XXXX', + username: process.env.LINK_MOBILITY_USERNAME!, + password: process.env.LINK_MOBILITY_PASSWORD!, + url: process.env.LINK_MOBILITY_URL!, + partner: process.env.LINK_MOBILITY_PARTNER!, + platform: process.env.LINK_MOBILITY_PLATFORM!, }); new LinkMobilityPartnerGateDestination('link-mobility-foo-bar-destination', { provider: provider, - partnerGateId: 'xxxxx', + partnerGateId: process.env.FOOBAR_PARTNER_GATE_ID!, destination: { url: 'https://foo.bar', contentType: 'application/json', diff --git a/package.json b/package.json index e30c9e3..92e2c74 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pulumi-link-mobility-provider", + "name": "@lego/pulumi-link-mobility-provider", "version": "1.0.0", "description": "A custom provider used to create resources within the Link Mobility Platform", "main": "src/index.ts", @@ -7,6 +7,7 @@ "build": "tsc -p .", "lint": "eslint . --ext .ts", "test": "jest --coverage", + "test:watch": "yarn test --watch", "format": "prettier --write \"**/**/*.ts\"", "release": "semantic-release", "prepare": "husky" @@ -30,5 +31,13 @@ "semantic-release": "^20.1.1", "ts-jest": "^28.0.3", "typescript": "^4.7.2" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testMatch": [ + "**/*.test.ts" + ] } } diff --git a/src/index.ts b/src/index.ts index 67206b5..3130a64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export * from './link-mobility.provider'; export * from './link-mobility.service'; -export * from './link-mobiliy-partner-gate-destination.resource'; -export * from './models'; +export * from './link-mobiliy-gate-destination.resource'; diff --git a/src/link-mobility.provider.ts b/src/link-mobility.provider.ts index c3ede1c..22486de 100644 --- a/src/link-mobility.provider.ts +++ b/src/link-mobility.provider.ts @@ -1,8 +1,8 @@ import * as pulumi from '@pulumi/pulumi'; -import { LinkMobilityPartnerGateService } from './link-mobility.service'; +import { LinkMobilityGateService } from './link-mobility.service'; import { LinkMobilityGateDestination } from './models'; -export type LinkMobilityPartnerGateDestinationProviderInputs = { +export type LinkMobilityGateDestinationProviderInputs = { username: string; password: string; url: string; @@ -10,26 +10,27 @@ export type LinkMobilityPartnerGateDestinationProviderInputs = { platform: string; }; -export type LinkMobilityPartnerGateDestinationInputs = { +export type LinkMobilityGateDestinationInputs = { partnerGateId: string; destination: LinkMobilityGateDestination; }; -export class LinkMobilityPartnerGateDestinationProvider implements pulumi.dynamic.ResourceProvider { +export class LinkMobilityGateDestinationProvider implements pulumi.dynamic.ResourceProvider { private username: string; private password: string; private partner: string; private platform: string; private url: string; - private readonly linkMobilityService: LinkMobilityPartnerGateService; + private readonly linkMobilityService: LinkMobilityGateService; - constructor(inputs: LinkMobilityPartnerGateDestinationProviderInputs) { + constructor(inputs: LinkMobilityGateDestinationProviderInputs) { this.username = inputs.username; this.password = inputs.password; this.partner = inputs.partner; this.platform = inputs.platform; this.url = inputs.url; - this.linkMobilityService = new LinkMobilityPartnerGateService({ + + this.linkMobilityService = new LinkMobilityGateService({ username: this.username, password: this.password, url: this.url, @@ -39,11 +40,12 @@ export class LinkMobilityPartnerGateDestinationProvider implements pulumi.dynami } async create( - input: LinkMobilityPartnerGateDestinationInputs - ): Promise> { + input: LinkMobilityGateDestinationInputs + ): Promise> { await this.linkMobilityService.createOrUpdateDestination( input.partnerGateId, - input.destination + input.destination, + false ); return { @@ -57,10 +59,14 @@ export class LinkMobilityPartnerGateDestinationProvider implements pulumi.dynami async update( _id: string, - _olds: LinkMobilityPartnerGateDestinationInputs, - news: LinkMobilityPartnerGateDestinationInputs - ): Promise> { - await this.linkMobilityService.createOrUpdateDestination(news.partnerGateId, news.destination); + _olds: LinkMobilityGateDestinationInputs, + news: LinkMobilityGateDestinationInputs + ): Promise> { + await this.linkMobilityService.createOrUpdateDestination( + news.partnerGateId, + news.destination, + true + ); return { outs: { @@ -70,14 +76,14 @@ export class LinkMobilityPartnerGateDestinationProvider implements pulumi.dynami }; } - async delete(_id: string, props: LinkMobilityPartnerGateDestinationInputs) { + async delete(_id: string, props: LinkMobilityGateDestinationInputs) { await this.linkMobilityService.deleteDestination(props.partnerGateId, props.destination); } async diff( id: string, - old: LinkMobilityPartnerGateDestinationInputs, - news: LinkMobilityPartnerGateDestinationInputs + old: LinkMobilityGateDestinationInputs, + news: LinkMobilityGateDestinationInputs ): Promise { const shouldReplace = old.destination.url !== news.destination.url; const hasChanges = JSON.stringify(old.destination) !== JSON.stringify(news.destination); diff --git a/src/link-mobility.service.ts b/src/link-mobility.service.ts index 9d6bb7d..e7dca56 100644 --- a/src/link-mobility.service.ts +++ b/src/link-mobility.service.ts @@ -1,6 +1,6 @@ import { LinkMobilityGate, LinkMobilityGateDestination } from './models'; -export type LinkMobilityPartnerGateServiceInputs = { +export type LinkMobilityGateServiceInputs = { username: string; password: string; url: string; @@ -8,14 +8,22 @@ export type LinkMobilityPartnerGateServiceInputs = { partner: string; }; -export class LinkMobilityPartnerGateService { +export class LinkMobilityGateService { private username: string; private password: string; private url: string; - constructor(inputs: LinkMobilityPartnerGateServiceInputs) { + constructor(inputs: LinkMobilityGateServiceInputs) { this.username = inputs.username; this.password = inputs.password; + const regexString = + '^(https?|http)://(w{3}.)?[a-zA-Z0-9-]+.[a-zA-Z]{2,}(:[0-9]{2,4})?(/[a-zA-Z0-9-]+)*$'; + const urlRegex = new RegExp(regexString); + if (!urlRegex.test(inputs.url)) { + throw new Error( + `Invalid Link Mobility URL. URL must follow the following pattern: ${regexString}` + ); + } this.url = `${inputs.url}/gate/partnergate/platform/${inputs.platform}/partner/${inputs.partner}`; } @@ -23,18 +31,7 @@ export class LinkMobilityPartnerGateService { return `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`; } - public async getAll(): Promise { - const response = await fetch(this.url, { - method: 'GET', - headers: { - Authorization: this.getAuth(), - }, - }); - - return await response.json(); - } - - public async getById(id: string): Promise { + public async getGateById(id: string): Promise { const response = await fetch(`${this.url}/id/${id}`, { method: 'GET', headers: { @@ -45,14 +42,26 @@ export class LinkMobilityPartnerGateService { return await response.json(); } - public async createOrUpdateDestination(gateId: string, destination: LinkMobilityGateDestination) { - const gate: LinkMobilityGate = await this.getById(gateId); + public async createOrUpdateDestination( + gateId: string, + destination: LinkMobilityGateDestination, + overwrite: boolean + ) { + const gate: LinkMobilityGate = await this.getGateById(gateId); const destinationIndex = gate.destinations.findIndex((d) => d.url === destination.url); if (destinationIndex === -1) { gate.destinations.push(destination); } else { + /** + * It seems normal Pulumi behaviour to not create a resource if it already exists. + * Unsure if we should keep this or just allow for input from the user to over-write the resource. + * Will keep it like this for now. + */ + if (!overwrite) { + throw new Error('Can not create destination as it already exists.'); + } gate.destinations[destinationIndex] = destination; } @@ -67,7 +76,7 @@ export class LinkMobilityPartnerGateService { } public async deleteDestination(gateId: string, destination: LinkMobilityGateDestination) { - const gate: LinkMobilityGate = await this.getById(gateId); + const gate: LinkMobilityGate = await this.getGateById(gateId); const destinationIndex = gate.destinations.findIndex((d) => d.url === destination.url); if (destinationIndex === -1) { diff --git a/src/link-mobiliy-gate-destination.resource.ts b/src/link-mobiliy-gate-destination.resource.ts new file mode 100644 index 0000000..59cd38a --- /dev/null +++ b/src/link-mobiliy-gate-destination.resource.ts @@ -0,0 +1,19 @@ +import * as pulumi from '@pulumi/pulumi'; +import { + LinkMobilityGateDestinationInputs, + LinkMobilityGateDestinationProvider, +} from './link-mobility.provider'; + +export interface LinkMobilityGateResourceInputs extends LinkMobilityGateDestinationInputs { + provider: LinkMobilityGateDestinationProvider; +} + +export class LinkMobilityGateDestination extends pulumi.dynamic.Resource { + constructor( + name: string, + inputs: LinkMobilityGateResourceInputs, + opts?: pulumi.CustomResourceOptions + ) { + super(inputs.provider, `${name}-destination`, inputs, opts); + } +} diff --git a/src/link-mobiliy-partner-gate-destination.resource.ts b/src/link-mobiliy-partner-gate-destination.resource.ts deleted file mode 100644 index 6a8dbc4..0000000 --- a/src/link-mobiliy-partner-gate-destination.resource.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as pulumi from '@pulumi/pulumi'; -import { - LinkMobilityPartnerGateDestinationInputs, - LinkMobilityPartnerGateDestinationProvider, -} from './link-mobility.provider'; - -export interface LinkMobilityPartnerGateResourceInputs - extends LinkMobilityPartnerGateDestinationInputs { - provider: LinkMobilityPartnerGateDestinationProvider; -} - -export class LinkMobilityPartnerGateDestination extends pulumi.dynamic.Resource { - constructor( - name: string, - inputs: LinkMobilityPartnerGateResourceInputs, - opts?: pulumi.CustomResourceOptions - ) { - super(inputs.provider, `${name}-destination`, inputs, opts); - } -} diff --git a/src/tests/link-mobility.provider.test.ts b/src/tests/link-mobility.provider.test.ts new file mode 100644 index 0000000..dae7894 --- /dev/null +++ b/src/tests/link-mobility.provider.test.ts @@ -0,0 +1,255 @@ +import { + LinkMobilityGateDestinationInputs, + LinkMobilityGateDestinationProvider, + LinkMobilityGateDestinationProviderInputs, +} from '../link-mobility.provider'; +import { LinkMobilityGateService } from '../link-mobility.service'; + +describe('Test suite for Link Mobility Provider', () => { + let linkMobilityProvider: LinkMobilityGateDestinationProvider; + + beforeEach(async () => { + jest.resetModules(); + linkMobilityProvider = new LinkMobilityGateDestinationProvider({ + partner: 'partner', + username: 'username', + password: 'password', + platform: 'platform', + url: 'http://some-url.com', + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should throw an error if the URL is invalid', () => { + // Arrange + const ctorInput: LinkMobilityGateDestinationProviderInputs = { + partner: 'partner', + username: 'username', + password: 'password', + platform: 'platform', + url: 'http://some-url.com/ending-with/', + }; + + // Act & Assert + expect(() => { + new LinkMobilityGateDestinationProvider(ctorInput); + }).toThrowError( + 'Invalid Link Mobility URL. URL must follow the following pattern: ^(https?|http)://(w{3}.)?[a-zA-Z0-9-]+.[a-zA-Z]{2,}(:[0-9]{2,4})?(/[a-zA-Z0-9-]+)*$' + ); + }); + + it('should create a destination when providing one', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + + const spy = jest + .spyOn(LinkMobilityGateService.prototype, 'createOrUpdateDestination') + .mockImplementation(); + + // Act + const result = await linkMobilityProvider.create(input); + + // Assert + expect(result.id).toBe('http://some-url.com'); + expect(spy).toBeCalledWith( + 'partnerGateId', + { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + false + ); + expect(result.outs).toEqual({ + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }); + }); + + it('should update a destination when providing one', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username-new', + password: 'password-new', + }, + }; + + const oldInput: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + const spy = jest + .spyOn(LinkMobilityGateService.prototype, 'createOrUpdateDestination') + .mockImplementation(); + + // Act + const result = await linkMobilityProvider.update(input.destination.url, oldInput, input); + + // Assert + expect(result.outs?.destination).toEqual({ + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username-new', + password: 'password-new', + }); + expect(spy).toBeCalledWith( + 'partnerGateId', + { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username-new', + password: 'password-new', + }, + true + ); + expect(result.outs?.partnerGateId).toBe('partnerGateId'); + }); + + it('should delete destination', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + + const spy = jest + .spyOn(LinkMobilityGateService.prototype, 'deleteDestination') + .mockImplementation(); + + // Act + await linkMobilityProvider.delete(input.destination.url, input); + + // Assert + expect(spy).toBeCalledWith('partnerGateId', { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }); + }); + + it('should generate a diff when there are non-url changes to the destination', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + + const oldInput: LinkMobilityGateDestinationInputs = { + destination: { + contentType: 'application/json', + password: 'password-old', + username: 'username-old', + url: 'http://some-url.com', + }, + partnerGateId: 'partnerGateId', + }; + + // Act + const result = await linkMobilityProvider.diff(input.destination.url, oldInput, input); + + // Assert + expect(result).toEqual({ + changes: true, + stables: ['http://some-url.com'], + }); + }); + + it('should generate a diff to replace when the url changes', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-new-url.com', + contentType: 'application/json', + username: 'username-new', + password: 'password-new', + }, + }; + + const oldInput: LinkMobilityGateDestinationInputs = { + destination: { + contentType: 'application/json', + password: 'password-old', + username: 'username-old', + url: 'http://some-url.com', + }, + partnerGateId: 'partnerGateId', + }; + + // Act + const result = await linkMobilityProvider.diff('http://some-url.com', oldInput, input); + + // Assert + expect(result).toEqual({ + changes: true, + replaces: ['http://some-url.com'], + deleteBeforeReplace: true, + }); + }); + + it('should generate a diff no changes when inputs are the same', async () => { + // Arrange + const input: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + + const oldInput: LinkMobilityGateDestinationInputs = { + partnerGateId: 'partnerGateId', + destination: { + url: 'http://some-url.com', + contentType: 'application/json', + username: 'username', + password: 'password', + }, + }; + + // Act + const result = await linkMobilityProvider.diff(input.destination.url, oldInput, input); + + // Assert + expect(result.changes).toEqual(false); + }); +}); diff --git a/src/tests/link-mobility.service.test.ts b/src/tests/link-mobility.service.test.ts new file mode 100644 index 0000000..dd03137 --- /dev/null +++ b/src/tests/link-mobility.service.test.ts @@ -0,0 +1,382 @@ +import { LinkMobilityGateService, LinkMobilityGateServiceInputs } from '../link-mobility.service'; +import { LinkMobilityGate, LinkMobilityGateDestination } from '../models'; + +describe('Test suite for link mobility partner gate service', () => { + let linkMobilityService: LinkMobilityGateService; + const mockFetch = jest.fn(); + + beforeEach(async () => { + jest.resetModules(); + global.fetch = mockFetch; + linkMobilityService = new LinkMobilityGateService({ + username: 'username', + password: 'password', + url: 'http://some-url.com', + platform: 'platform', + partner: 'partner', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each(['https://some-url.com/ending-with/', 'not-a-real-url'])( + 'should fail to initialise if url is invalid', + async (url) => { + // Arrange + const ctorInput: LinkMobilityGateServiceInputs = { + username: 'username', + password: 'password', + url: url, + platform: 'platform', + partner: 'partner', + }; + + // Act & Assert + expect(() => { + new LinkMobilityGateService(ctorInput); + }).toThrowError(); + } + ); + + it.each([ + 'http://some-url.com', + 'http://www.some-other-url.org', + 'https://actual-url.dk/with-paths', + ])('should initialise if url is valid', async (url) => { + // Arrange + const ctorInput: LinkMobilityGateServiceInputs = { + username: 'username', + password: 'password', + url: url, + platform: 'platform', + partner: 'partner', + }; + + // Act + const service = new LinkMobilityGateService(ctorInput); + + // Assert + expect(service).toBeDefined(); + }); + + it('should get auth', async () => { + // Act + // @ts-expect-error Testing a private method here - TypeScript will cry about it but JS will allow it + const auth = linkMobilityService.getAuth(); + + // Assert + // Base64 encoding of 'username:password' is 'dXNlcm5hbWU6cGFzc3dvcmQ=' + expect(auth).toBe('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + }); + + it('should get by id', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + // Act + const result = await linkMobilityService.getGateById('foo'); + + // Assert + expect(result).toEqual({ + id: 'foo', + acknowledge: true, + destinations: [], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }); + }); + + it('should create destination if not exists', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://bar.foo', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + const destination: LinkMobilityGateDestination = { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + jest.spyOn(linkMobilityService, 'getGateById').mockResolvedValue(response); + + // Act + await linkMobilityService.createOrUpdateDestination('foo', destination, false); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'http://some-url.com/gate/partnergate/platform/platform/partner/partner/id/foo', + { + method: 'PUT', + headers: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://bar.foo', + username: 'username', + password: 'password', + }, + { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }), + } + ); + }); + + it('should throw error if destination exists and overwrite is false', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + const destination: LinkMobilityGateDestination = { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + jest.spyOn(linkMobilityService, 'getGateById').mockResolvedValue(response); + + // Act & Assert + await expect( + linkMobilityService.createOrUpdateDestination('foo', destination, false) + ).rejects.toThrowError('Can not create destination as it already exists.'); + }); + + it('should update the destination if it exists', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + const destination: LinkMobilityGateDestination = { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username-new', + password: 'password-new', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + jest.spyOn(linkMobilityService, 'getGateById').mockResolvedValue(response); + + // Act + await linkMobilityService.createOrUpdateDestination('foo', destination, true); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'http://some-url.com/gate/partnergate/platform/platform/partner/partner/id/foo', + { + method: 'PUT', + headers: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username-new', + password: 'password-new', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }), + } + ); + }); + + it('should delete destination', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }, + { + contentType: 'application/json', + url: 'https://bar.foo', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + const destination: LinkMobilityGateDestination = { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + jest.spyOn(linkMobilityService, 'getGateById').mockResolvedValue(response); + + // Act + await linkMobilityService.deleteDestination('foo', destination); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'http://some-url.com/gate/partnergate/platform/platform/partner/partner/id/foo', + { + method: 'PUT', + headers: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://bar.foo', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }), + } + ); + }); + + it('should do nothing if trying to delete destination that doesnt exist', async () => { + // Arrange + const response: LinkMobilityGate = { + id: 'foo', + acknowledge: true, + destinations: [ + { + contentType: 'application/json', + url: 'https://bar.foo', + username: 'username', + password: 'password', + }, + ], + gateType: 'bar', + platformId: 'platform', + platformPartnerId: 'platformPartner', + refId: 'ref', + ttl: 1000, + type: 'type', + }; + const destination: LinkMobilityGateDestination = { + contentType: 'application/json', + url: 'https://foo.bar', + username: 'username', + password: 'password', + }; + mockFetch.mockResolvedValue({ json: () => response }); + + jest.spyOn(linkMobilityService, 'getGateById').mockResolvedValue(response); + + // Act + await linkMobilityService.deleteDestination('foo', destination); + + // Assert + expect(mockFetch).not.toHaveBeenCalled(); + }); +});