diff --git a/SPIKE.md b/SPIKE.md new file mode 100644 index 000000000000..4e442e25db75 --- /dev/null +++ b/SPIKE.md @@ -0,0 +1,141 @@ +# Run LB3 tests from LB4 when LB3 is mounted on the LB4 app + +Since LoopBack 4 offers a way to mount LoopBack 3 applications on a LoopBack 4 +project with the use of +[`@loopback/booter-lb3app`](https://github.com/strongloop/loopback-next/tree/master/packages/booter-lb3app), +there should also be a way for users to run their LoopBack 3 tests as part of +LoopBack 4's `npm test` command. + +We want the LoopBack 3 tests to use the LoopBack 4 server rather than the +LoopBack 3 application. This spike aims to test running both acceptance and +integration LoopBack 3 tests. + +## All Tests + +In order to run LoopBack 3's tests from their current folder, add LB3 tests' +path to `test` entry in package.json: + +- `"test": "lb-mocha \"dist/**tests**/\*_/_.js\" \"lb3app/test/\*.js\""` + +In this case, the test folder is +[`/lb3app/test`](https://github.com/strongloop/loopback-next/tree/spike/lb3test/examples/lb3-application/lb3app/test) +from the root of the LoopBack 4 project. + +This will run LoopBack 4 tests first then LoopBack 3 tests. + +## Acceptance Tests + +First, move any LoopBack 3 test dependencies to package.json's devDependencies +and run: + +```sh +npm install +``` + +In your test file: + +- Update to use the LB4 Express server when doing requests: + + ```ts + // can use lb4's testlab's supertest as the dependency is already installed + const {supertest} = require('@loopback/testlab'); + const assert = require('assert'); + const should = require('should'); + const {ExpressServer} = require('../../dist/server'); + + let app; + + function request(verb, url) { + // use the LB4 express server + return supertest(app.server) + [verb](url) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .expect('Content-Type', /json/); + } + ``` + +- Boot and start the LB4 app in your before hook, and stop the app in the after + hook: + + ```ts + describe('LoopBack 3 style tests', function () { + before(async function () { + app = new ExpressServer(); + await app.boot(); + await app.start(); + }); + + after(async () => { + await app.stop(); + }); + + // your tests here + }); + ``` + +- Example of this use can be seen in + [`examples/lb3-application/lb3app/test/acceptance.js`](https://github.com/strongloop/loopback-next/blob/spike/lb3test/examples/lb3-application/lb3app/test/acceptance.js) + which has the same tests as + [`src/__tests__/acceptance/lb3app.acceptance.ts`](https://github.com/strongloop/loopback-next/blob/spike/lb3test/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts), + but in LB3 style. + +Now when you run `npm test` your LoopBack 3 tests should be run along with any +LoopBack 4 tests you have. + +Optional: Another option is to migrate your tests to use LoopBack 4 style of +testing, similar to `src/__tests__/acceptance/lb3app.acceptance.ts`. +Documentation for LoopBack testing can be found in +https://loopback.io/doc/en/lb4/Testing-your-application.html. + +## Integration Tests + +For the integration tests, LoopBack 3 models were bound to the LoopBack 4 +application in order to allow JavaScript API to call application logic such as +`Model.create()`. This can be seen in +[`packages/booter-lb3app/src/lb3app.booter.ts`](https://github.com/strongloop/loopback-next/blob/spike/lb3test/packages/booter-lb3app/src/lb3app.booter.ts#L76-L85). + +In order to retrieve the model from the application's context, `getSync()` can +be used as follows: + +```ts +describe('LoopBack 3 style integration tests', function () { + let app; + let CoffeeShop; + + before(async function () { + app = new ExpressServer(); + await app.boot(); + await app.start(); + }); + + before(() => { + // follow the syntax: lb3-models.{ModelName} + CoffeeShop = app.lbApp.getSync('lb3-models.CoffeeShop'); + }); + + after(async () => { + await app.stop(); + }); + + // your tests here +}); +``` + +Alternatively, `get()` can also be used in to retrieve the model asynchronously: + +```ts +before(async () => { + // follow the syntax: lb3-models.{ModelName} + CoffeeShop = await app.lbApp.get('lb3-models.CoffeeShop'); +}); +``` + +Additionally, LB3 datasources are also bound to the LB4 application's context +and can be retrieved with a key in the syntax `lb3-datasources.{ds name}`. + +Example integration tests can be found in +[`examples/lb3-application/lb3app/test/integration.js`](https://github.com/strongloop/loopback-next/blob/spike/lb3test/examples/lb3-application/lb3app/test/integration.js). + +Example authentication tests can be found in +[`examples/lb3-application/lb3app/test/authentication.js`](https://github.com/strongloop/loopback-next/blob/spike/lb3test/examples/lb3-application/lb3app/test/authentication.js). diff --git a/examples/lb3-application/lb3app/common/models/coffee-shop.js b/examples/lb3-application/lb3app/common/models/coffee-shop.js index 4917a2f582ef..c88f44497b2c 100755 --- a/examples/lb3-application/lb3app/common/models/coffee-shop.js +++ b/examples/lb3-application/lb3app/common/models/coffee-shop.js @@ -38,6 +38,6 @@ module.exports = function (CoffeeShop) { }; CoffeeShop.remoteMethod('greet', { http: {path: '/greet', verb: 'get'}, - returns: {type: 'string'}, + returns: {arg: 'greeting', type: 'string'}, }); }; diff --git a/examples/lb3-application/lb3app/test/acceptance.js b/examples/lb3-application/lb3app/test/acceptance.js new file mode 100644 index 000000000000..e9ad4e5ac874 --- /dev/null +++ b/examples/lb3-application/lb3app/test/acceptance.js @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-lb3-application +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; +const {supertest} = require('@loopback/testlab'); +const assert = require('assert'); +const {ExpressServer} = require('../../dist/server'); +require('should'); + +let app; + +function request(verb, url) { + // use the original app's server + return supertest(app.server) + [verb](url) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .expect('Content-Type', /json/); +} + +describe('LoopBack 3 style acceptance tests', function () { + before(async function () { + app = new ExpressServer(); + await app.boot(); + await app.start(); + }); + + after(async () => { + await app.stop(); + }); + + context('basic REST calls for LoopBack 3 application', () => { + it('creates and finds a CoffeeShop', function (done) { + request('post', '/api/CoffeeShops') + .send({ + name: 'Coffee Shop', + city: 'Toronto', + }) + .expect(200) + .end(function (err, res) { + assert(typeof res.body === 'object'); + assert(res.body.name); + assert(res.body.city); + assert.equal(res.body.name, 'Coffee Shop'); + assert.equal(res.body.city, 'Toronto'); + done(); + }); + }); + + it("gets the CoffeeShop's status", function (done) { + request('get', '/api/CoffeeShops/status').expect(200, function ( + err, + res, + ) { + res.body.status.should.be.equalOneOf( + 'We are open for business.', + 'Sorry, we are closed. Open daily from 6am to 8pm.', + ); + done(); + }); + }); + + it('gets external route in application', function (done) { + request('get', '/ping').expect(200, function (err, res) { + assert.equal(res.text, 'pong'); + done(); + }); + }); + }); +}); diff --git a/examples/lb3-application/lb3app/test/authentication.js b/examples/lb3-application/lb3app/test/authentication.js new file mode 100644 index 000000000000..71f8423d8d22 --- /dev/null +++ b/examples/lb3-application/lb3app/test/authentication.js @@ -0,0 +1,99 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-lb3-application +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; +const lb3App = require('../server/server'); +const {supertest} = require('@loopback/testlab'); +const assert = require('assert'); +const {ExpressServer} = require('../../dist/server'); +require('should'); + +let app; + +function request(verb, url) { + // use the original app's server + return supertest(app.server) + [verb](url) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .expect('Content-Type', /json/); +} + +describe('LoopBack 3 authentication', function () { + before(async function () { + app = new ExpressServer(); + await app.boot(); + await app.start(); + }); + + after(async () => { + await app.stop(); + }); + + context('authentication', () => { + let User; + + before(() => { + User = lb3App.models.User; + }); + + it('creates a User and logs them in and out', function (done) { + request('post', '/api/users') + .send({email: 'new@email.com', password: 'L00pBack!'}) + .expect(200, function (err, user) { + assert.equal(user.body.email, 'new@email.com'); + request('post', '/api/users/login') + .send({ + email: 'new@email.com', + password: 'L00pBack!', + }) + .expect(200, function (err2, token) { + token.body.should.have.properties( + 'ttl', + 'userId', + 'created', + 'id', + ); + assert.equal(token.body.userId, user.body.id); + request( + 'post', + `/api/users/logout?access_token=${token.body.id}`, + ).expect(204); + done(); + }); + }); + }); + + it('rejects anonymous requests to protected endpoints', function (done) { + request('get', '/api/CoffeeShops/greet').expect(401, function (err, res) { + assert.equal(res.body.error.code, 'AUTHORIZATION_REQUIRED'); + }); + done(); + }); + + it('makes an authenticated request', function (done) { + User.create({email: 'new@gmail.com', password: 'L00pBack!'}, function ( + err, + user, + ) { + user.email.should.be.equal('new@gmail.com'); + User.login({email: 'new@gmail.com', password: 'L00pBack!'}, function ( + err2, + token, + ) { + assert.equal(typeof token, 'object'); + assert.equal(token.userId, user.id); + request( + 'get', + `/api/CoffeeShops/greet?access_token=${token.id}`, + ).expect(200, function (err3, res) { + res.body.greeting.should.be.equal('Hello from this Coffee Shop'); + done(); + }); + }); + }); + }); + }); +}); diff --git a/examples/lb3-application/lb3app/test/integration.js b/examples/lb3-application/lb3app/test/integration.js new file mode 100644 index 000000000000..4160eeca799e --- /dev/null +++ b/examples/lb3-application/lb3app/test/integration.js @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-lb3-application +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const assert = require('assert'); +const ExpressServer = require('../../dist/server').ExpressServer; +require('should'); + +describe('LoopBack 3 style integration tests', function () { + let app; + let CoffeeShop; + + before(async function () { + app = new ExpressServer(); + await app.boot(); + await app.start(); + }); + + before(() => { + CoffeeShop = app.lbApp.getSync('lb3-models.CoffeeShop'); + }); + + after(async () => { + await app.stop(); + }); + + it('CoffeeShop.find', function (done) { + CoffeeShop.find({where: {name: 'Bel Cafe'}}, function (err, shop) { + shop[0].__data.name.should.be.equal('Bel Cafe'); + shop[0].__data.city.should.be.equal('Vancouver'); + }); + done(); + }); + + it('CoffeeShop.count', function (done) { + CoffeeShop.count({}, function (err, count) { + assert.equal(count, 5); + }); + done(); + }); + + it('CoffeeShop.create', function (done) { + CoffeeShop.create( + { + name: 'Nook Shop', + city: 'Toronto', + }, + function (err, shop) { + shop.__data.name.should.be.equal('Nook Shop'); + shop.__data.city.should.be.equal('Toronto'); + }, + ); + done(); + }); +}); diff --git a/examples/lb3-application/package-lock.json b/examples/lb3-application/package-lock.json index 265f7c003427..0dd8cad4f73b 100644 --- a/examples/lb3-application/package-lock.json +++ b/examples/lb3-application/package-lock.json @@ -2640,6 +2640,60 @@ "nanoid": "^2.1.0" } }, + "should": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", + "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", + "dev": true, + "requires": { + "should-equal": "^2.0.0", + "should-format": "^3.0.3", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "should-equal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", + "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", + "dev": true, + "requires": { + "should-type": "^1.4.0" + } + }, + "should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", + "dev": true, + "requires": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=", + "dev": true + }, + "should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "requires": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true + }, "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", diff --git a/examples/lb3-application/package.json b/examples/lb3-application/package.json index 5fa158025b99..9441b9286113 100644 --- a/examples/lb3-application/package.json +++ b/examples/lb3-application/package.json @@ -19,7 +19,7 @@ "eslint": "lb-eslint --report-unused-disable-directives .", "eslint:fix": "npm run eslint -- --fix", "pretest": "npm run build", - "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "test": "lb-mocha \"dist/__tests__/**/*.js\" \"lb3app/test/*.js\"", "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", "verify": "npm pack && tar xf loopback-lb3-application*.tgz && tree package && npm run clean", "migrate": "node ./dist/migrate", @@ -67,6 +67,7 @@ "eslint-plugin-eslint-plugin": "^2.2.1", "eslint-plugin-mocha": "^6.3.0", "lodash": "^4.17.15", + "should": "^13.2.3", "typescript": "~3.8.3" }, "keywords": [ diff --git a/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts b/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts index 2c1db8b8e1de..aa2313d1ecc2 100644 --- a/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts +++ b/examples/lb3-application/src/__tests__/acceptance/lb3app.acceptance.ts @@ -90,7 +90,7 @@ describe('CoffeeShopApplication', () => { .get(`/api/CoffeeShops/greet?access_token=${token.id}`) .expect(200); - expect(response.body.undefined).to.eql('Hello from this Coffee Shop'); + expect(response.body.greeting).to.eql('Hello from this Coffee Shop'); }); it('rejects anonymous requests to protected endpoints', async () => { diff --git a/examples/lb3-application/src/server.ts b/examples/lb3-application/src/server.ts index 5f9886cb4266..8f4b0f1f7f8a 100644 --- a/examples/lb3-application/src/server.ts +++ b/examples/lb3-application/src/server.ts @@ -19,7 +19,7 @@ const helmet = require('helmet'); export class ExpressServer { private app: express.Application; public readonly lbApp: CoffeeShopApplication; - private server?: http.Server; + public server?: http.Server; public url: String; constructor(options: ApplicationConfig = {}) { diff --git a/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts b/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts index 557b42d43af4..402418af143d 100644 --- a/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts +++ b/packages/booter-lb3app/src/__tests__/acceptance/booter-lb3app.acceptance.ts @@ -242,4 +242,20 @@ describe('booter-lb3app', () => { expect(ds).to.eql(expected); }); }); + + context('binding LoopBack 3 models', () => { + before(async () => { + ({app, client} = await setupApplication({ + lb3app: {path: '../fixtures/app-with-model'}, + })); + }); + + it('binds model to the context', async () => { + const expected = require('../../../fixtures/app-with-model').models.Color; + const modelBindings = app.findByTag('lb3-model'); + const key = modelBindings[0].key; + const model = await app.get(key); + expect(model).to.eql(expected); + }); + }); }); diff --git a/packages/booter-lb3app/src/lb3app.booter.ts b/packages/booter-lb3app/src/lb3app.booter.ts index 88050609e5dc..60b5ea5d8bf1 100644 --- a/packages/booter-lb3app/src/lb3app.booter.ts +++ b/packages/booter-lb3app/src/lb3app.booter.ts @@ -73,6 +73,17 @@ export class Lb3AppBooter implements Booter { }); } + const models = lb3App.models; + if (models) { + const visited: unknown[] = []; + Object.keys(models).forEach(key => { + const model = models[key]; + if (visited.includes(model)) return; + visited.push(model); + this.app.bind(`lb3-models.${key}`).to(model).tag('lb3-model'); + }); + } + // TODO(bajtos) Listen for the following events to update the OpenAPI spec: // - modelRemoted // - modelDeleted @@ -149,4 +160,6 @@ export interface Lb3AppBooterOptions { interface Lb3Application extends ExpressApplication { handler(name: 'rest'): ExpressRequestHandler; dataSources?: {[name: string]: unknown}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + models?: {[name: string]: any}; }