Sfoglia il codice sorgente

test(server): Set up e2e testing infrastructure

Uses sql.js as a database when running e2e tests, using freshly-populated, deterministic test data. Relates to #11.
Michael Bromley 7 anni fa
parent
commit
f9e22d32dc
32 ha cambiato i file con 581 aggiunte e 156 eliminazioni
  1. 2 0
      .gitignore
  2. 35 3
      README.md
  3. 1 0
      server/e2e/__data__/.gitkeep
  4. 28 0
      server/e2e/__snapshots__/product.e2e-spec.ts.snap
  5. 0 25
      server/e2e/app.e2e-spec.ts
  6. 18 0
      server/e2e/config/jest-e2e.json
  7. 21 0
      server/e2e/config/test-config.ts
  8. 10 0
      server/e2e/config/tsconfig.e2e.json
  9. 0 8
      server/e2e/jest-e2e.json
  10. 197 0
      server/e2e/product.e2e-spec.ts
  11. 12 0
      server/e2e/test-client.ts
  12. 80 0
      server/e2e/test-server.ts
  13. 7 3
      server/mock-data/clear-all-tables.ts
  14. 0 12
      server/mock-data/gql-request.ts
  15. 47 50
      server/mock-data/mock-data.service.ts
  16. 24 0
      server/mock-data/populate-cli.ts
  17. 29 22
      server/mock-data/populate.ts
  18. 19 0
      server/mock-data/simple-graphql-client.ts
  19. 4 2
      server/package.json
  20. 19 8
      server/src/bootstrap.ts
  21. 3 2
      server/src/config/vendure-config.ts
  22. 12 6
      server/src/entity/address/address.entity.ts
  23. 1 1
      server/src/entity/facet-value/facet-value-translation.entity.ts
  24. 1 1
      server/src/entity/facet/facet-translation.entity.ts
  25. 1 1
      server/src/entity/product-option-group/product-option-group-translation.entity.ts
  26. 1 1
      server/src/entity/product-option/product-option-translation.entity.ts
  27. 1 1
      server/src/entity/product-variant/product-variant-translation.entity.ts
  28. 1 1
      server/src/entity/product/product-translation.entity.ts
  29. 2 1
      server/src/entity/user/user.entity.ts
  30. 1 0
      server/src/service/product-variant.service.ts
  31. 0 8
      server/tsconfig.spec.json
  32. 4 0
      server/yarn.lock

+ 2 - 0
.gitignore

@@ -387,3 +387,5 @@ Temporary Items
 docker-compose.yml
 .env
 dist
+server/e2e/__data__/*
+!server/e2e/__data__/.gitkeep

+ 35 - 3
README.md

@@ -27,8 +27,8 @@ Vendure uses [TypeORM](http://typeorm.io), so it compatible will any database wh
 
 * Configure the [dev config](./server/dev-config.ts)
 * `cd server && yarn`
-* `yarn start:dev`
 * Populate mock data with `yarn populate`
+* `yarn start:dev` to start the server
 
 ### Admin UI
 
@@ -36,8 +36,6 @@ Vendure uses [TypeORM](http://typeorm.io), so it compatible will any database wh
 * `yarn start`
 * Go to http://localhost:4200 and log in with "admin@test.com", "test"
 
-## User Guide
-
 ### Code Generation
 
 [apollo-cli](https://github.com/apollographql/apollo-cli) is used to automatically create TypeScript interfaces
@@ -46,6 +44,40 @@ for all GraphQL queries used in the admin ui. These generated interfaces are use
 Run `yarn generate-gql-types` to generate TypeScript interfaces based on these queries. The generated
 types are located at [`./shared/generated-types.ts`](./shared/generated-types.ts).
 
+### Testing
+
+#### Server Unit Tests
+
+The server has unit tests which are run with the following scripts in the `server` directory:
+
+* `yarn test` - Run unit tests once
+* `yarn test:watch` - Run unit tests in watch mode
+
+Unit tests are co-located with the files which they test, and have the suffix `.spec.ts`.
+
+#### Server e2e Tests
+
+The server has e2e tests which test the API with real http calls and against a real Sqlite database powered by [sql.js](https://github.com/kripken/sql.js/). 
+The tests are run with the following scripts in the `server` directory:
+
+* `yarn test:e2e` - Run e2e tests once
+* `yarn test:e2e:watch` - Run e2e tests in watch mode
+
+The e2e tests are located in [`/server/e2e`](./server/e2e/). Each test suite (file) has the suffix `.e2e-spec.ts`. The first time the e2e tests are run,
+sqlite files will be generated in the `__data__` directory. These files are used to speed up subsequent runs of the e2e tests. They can be freely deleted
+and will be re-created the next time the e2e tests are run.
+
+#### Admin UI Unit Tests
+
+The Admin UI has unit tests which are run with the following scripts in the `admin-ui` directory:
+
+* `yarn test --watch=false` - Run unit tests once.
+* `yarn test` - Run unit tests in watch mode.
+
+Unit tests are co-located with the files which they test, and have the suffix `.spec.ts`.
+
+## User Guide
+
 ### Localization
 
 Vendure server will detect the most suitable locale based on the `Accept-Language` header of the client.

+ 1 - 0
server/e2e/__data__/.gitkeep

@@ -0,0 +1 @@
+This folder contains the cached sqlite databases which represent the initial state of each e2e test suite.

+ 28 - 0
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Product resolver createProduct mutation creates a new Product 1`] = `
+Object {
+  "description": "A baked potato",
+  "id": "21",
+  "image": "baked-potato",
+  "languageCode": "en",
+  "name": "en Baked Potato",
+  "optionGroups": Array [],
+  "slug": "en-baked-potato",
+  "translations": Array [
+    Object {
+      "description": "A baked potato",
+      "languageCode": "en",
+      "name": "en Baked Potato",
+      "slug": "en-baked-potato",
+    },
+    Object {
+      "description": "Eine baked Erdapfel",
+      "languageCode": "de",
+      "name": "de Baked Potato",
+      "slug": "de-baked-potato",
+    },
+  ],
+  "variants": Array [],
+}
+`;

+ 0 - 25
server/e2e/app.e2e-spec.ts

@@ -1,25 +0,0 @@
-import { INestApplication } from '@nestjs/common';
-import { Test } from '@nestjs/testing';
-import request from 'supertest';
-
-import { AppModule } from '../src/app.module';
-
-describe('AppController (e2e)', () => {
-    let app: INestApplication;
-
-    beforeAll(async () => {
-        const moduleFixture = await Test.createTestingModule({
-            imports: [AppModule],
-        }).compile();
-
-        app = moduleFixture.createNestApplication();
-        await app.init();
-    });
-
-    it('/ (GET)', () => {
-        return request(app.getHttpServer())
-            .get('/')
-            .expect(200)
-            .expect('Hello World!');
-    });
-});

+ 18 - 0
server/e2e/config/jest-e2e.json

@@ -0,0 +1,18 @@
+{
+  "moduleFileExtensions": ["js", "json", "ts"],
+  "moduleNameMapper": {
+    "shared/(.*)": "<rootDir>/../../shared/$1.ts"
+  },
+  "rootDir": "../",
+  "testRegex": ".e2e-spec.ts$",
+  "transform": {
+    "^.+\\.(t|j)s$": "ts-jest"
+  },
+  "globals": {
+    "ts-jest": {
+      "skipBabel": true,
+      "tsConfigFile": "<rootDir>/config/tsconfig.e2e.json",
+      "enableTsDiagnostics": false
+    }
+  }
+}

+ 21 - 0
server/e2e/config/test-config.ts

@@ -0,0 +1,21 @@
+import { API_PATH } from 'shared/shared-constants';
+
+import { VendureConfig } from '../../src/config/vendure-config';
+
+/**
+ * Config settings used for e2e tests
+ */
+export const testConfig: VendureConfig = {
+    port: 3050,
+    apiPath: API_PATH,
+    cors: true,
+    jwtSecret: 'some-secret',
+    dbConnectionOptions: {
+        type: 'sqljs',
+        database: new Uint8Array([]),
+        location: '',
+        autoSave: false,
+        logging: false,
+    },
+    customFields: {},
+};

+ 10 - 0
server/e2e/config/tsconfig.e2e.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "types": ["jest", "node"],
+    "lib": ["es2015"],
+    "skipLibCheck": false,
+    "sourceMap": true
+  },
+  "include": ["../**/*.e2e-spec.ts", "../**/*.d.ts"]
+}

+ 0 - 8
server/e2e/jest-e2e.json

@@ -1,8 +0,0 @@
-{
-  "moduleFileExtensions": ["js", "json", "ts"],
-  "rootDir": ".",
-  "testRegex": ".e2e-spec.ts$",
-  "transform": {
-    "^.+\\.(t|j)s$": "ts-jest"
-  }
-}

+ 197 - 0
server/e2e/product.e2e-spec.ts

@@ -0,0 +1,197 @@
+import {
+    CreateProduct,
+    CreateProductVariables,
+    GetProductList,
+    GetProductListVariables,
+    GetProductWithVariants,
+    GetProductWithVariantsVariables,
+    LanguageCode,
+    SortOrder,
+} from 'shared/generated-types';
+
+import { CREATE_PRODUCT } from '../../admin-ui/src/app/data/mutations/product-mutations';
+import {
+    GET_PRODUCT_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+} from '../../admin-ui/src/app/data/queries/product-queries';
+
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Product resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        await server.init({
+            productCount: 20,
+            customerCount: 1,
+        });
+    }, 30000);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('products list query', () => {
+        it('returns all products when no options passed', async () => {
+            const result = await client.query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, {
+                languageCode: LanguageCode.en,
+            });
+
+            expect(result.products.items.length).toBe(20);
+            expect(result.products.totalItems).toBe(20);
+        });
+
+        it('limits result set with skip & take', async () => {
+            const result = await client.query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, {
+                languageCode: LanguageCode.en,
+                options: {
+                    skip: 0,
+                    take: 3,
+                },
+            });
+
+            expect(result.products.items.length).toBe(3);
+            expect(result.products.totalItems).toBe(20);
+        });
+
+        it('filters by name', async () => {
+            const result = await client.query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, {
+                languageCode: LanguageCode.en,
+                options: {
+                    filter: {
+                        name: {
+                            contains: 'fish',
+                        },
+                    },
+                },
+            });
+
+            expect(result.products.items.length).toBe(1);
+            expect(result.products.items[0].name).toBe('en Practical Frozen Fish');
+        });
+
+        it('sorts by name', async () => {
+            const result = await client.query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, {
+                languageCode: LanguageCode.en,
+                options: {
+                    sort: {
+                        name: SortOrder.ASC,
+                    },
+                },
+            });
+
+            expect(result.products.items.map(p => p.name)).toEqual([
+                'en Fantastic Granite Salad',
+                'en Fantastic Rubber Sausages',
+                'en Generic Metal Keyboard',
+                'en Generic Wooden Sausages',
+                'en Handcrafted Granite Shirt',
+                'en Handcrafted Plastic Gloves',
+                'en Handmade Cotton Salad',
+                'en Incredible Metal Shirt',
+                'en Incredible Steel Cheese',
+                'en Intelligent Frozen Ball',
+                'en Intelligent Wooden Car',
+                'en Licensed Cotton Shirt',
+                'en Licensed Frozen Chair',
+                'en Practical Frozen Fish',
+                'en Refined Fresh Bacon',
+                'en Rustic Steel Salad',
+                'en Rustic Wooden Hat',
+                'en Small Granite Chicken',
+                'en Small Steel Cheese',
+                'en Tasty Soft Gloves',
+            ]);
+        });
+    });
+
+    describe('product query', () => {
+        it('returns expected properties', async () => {
+            const result = await client.query<GetProductWithVariants, GetProductWithVariantsVariables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    languageCode: LanguageCode.en,
+                    id: '2',
+                },
+            );
+
+            if (!result.product) {
+                fail('Product not found');
+                return;
+            }
+            expect(result.product).toEqual(
+                expect.objectContaining({
+                    description: 'en Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
+                    id: '2',
+                    image: 'http://lorempixel.com/640/480',
+                    languageCode: 'en',
+                    name: 'en Incredible Metal Shirt',
+                    optionGroups: [
+                        {
+                            code: 'size',
+                            id: '1',
+                            languageCode: 'en',
+                            name: 'Size',
+                        },
+                    ],
+                    slug: 'en incredible-metal-shirt',
+                    translations: [
+                        {
+                            description:
+                                'en Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
+                            languageCode: 'en',
+                            name: 'en Incredible Metal Shirt',
+                            slug: 'en incredible-metal-shirt',
+                        },
+                        {
+                            description:
+                                'de Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
+                            languageCode: 'de',
+                            name: 'de Incredible Metal Shirt',
+                            slug: 'de incredible-metal-shirt',
+                        },
+                    ],
+                }),
+            );
+        });
+
+        it('returns null when id not found', async () => {
+            const result = await client.query<GetProductWithVariants, GetProductWithVariantsVariables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    languageCode: LanguageCode.en,
+                    id: 'bad_id',
+                },
+            );
+
+            expect(result.product).toBeNull();
+        });
+    });
+
+    describe('createProduct mutation', () => {
+        it('creates a new Product', async () => {
+            const result = await client.query<CreateProduct, CreateProductVariables>(CREATE_PRODUCT, {
+                input: {
+                    image: 'baked-potato',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'en Baked Potato',
+                            slug: 'en-baked-potato',
+                            description: 'A baked potato',
+                        },
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'de Baked Potato',
+                            slug: 'de-baked-potato',
+                            description: 'Eine baked Erdapfel',
+                        },
+                    ],
+                },
+            });
+            expect(result.createProduct).toMatchSnapshot();
+        });
+    });
+});

+ 12 - 0
server/e2e/test-client.ts

@@ -0,0 +1,12 @@
+import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
+
+import { testConfig } from './config/test-config';
+
+/**
+ * A GraphQL client for use in e2e tests configured to use the test server endpoint.
+ */
+export class TestClient extends SimpleGraphQLClient {
+    constructor() {
+        super(`http://localhost:${testConfig.port}/${testConfig.apiPath}`);
+    }
+}

+ 80 - 0
server/e2e/test-server.ts

@@ -0,0 +1,80 @@
+import { INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import * as fs from 'fs';
+import * as path from 'path';
+import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
+
+import { populate, PopulateOptions } from '../mock-data/populate';
+import { preBootstrapConfig } from '../src/bootstrap';
+import { Mutable } from '../src/common/common-types';
+import { VendureConfig } from '../src/config/vendure-config';
+
+import { testConfig } from './config/test-config';
+
+// tslint:disable:no-console
+/**
+ * A server against which the e2e tests should be run.
+ */
+export class TestServer {
+    app: INestApplication;
+
+    /**
+     * Bootstraps an instance of Vendure server and populates the database according to the options
+     * passed in. Should be called immediately after creating the client in the `beforeAll` function.
+     *
+     * The populated data is saved into an .sqlite file for each test file. On subsequent runs, this file
+     * is loaded so that the populate step can be skipped, which speeds up the tests significantly.
+     */
+    async init(options: PopulateOptions) {
+        const testingConfig = testConfig;
+        const dbDataDir = '__data__';
+        // tslint:disable-next-line:no-non-null-assertion
+        const testFilePath = module!.parent!.filename;
+        const dbFileName = path.basename(testFilePath) + '.sqlite';
+        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
+
+        (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
+        if (!fs.existsSync(dbFilePath)) {
+            console.log(`Test data not found. Populating database and saving to "${dbFilePath}"`);
+            await this.populateInitialData(testingConfig, options);
+        }
+        console.log(`Loading test from "${dbFilePath}"`);
+        this.app = await this.bootstrapForTesting(testingConfig);
+    }
+
+    /**
+     * Destroy the Vendure instance. Should be called in the `afterAll` function.
+     */
+    async destroy() {
+        await this.app.close();
+    }
+
+    /**
+     * Populates an .sqlite database file based on the PopulateOptions.
+     */
+    private async populateInitialData(testingConfig: VendureConfig, options: PopulateOptions) {
+        (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
+        const populateApp = await populate(testingConfig, this.bootstrapForTesting, {
+            ...options,
+            logging: false,
+        });
+        await populateApp.close();
+        (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
+    }
+
+    /**
+     * Bootstraps an instance of the Vendure server for testing against.
+     */
+    private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
+        const config = await preBootstrapConfig(userConfig);
+
+        const appModule = await import('../src/app.module');
+        const moduleFixture = await Test.createTestingModule({
+            imports: [appModule.AppModule],
+        }).compile();
+
+        const app = moduleFixture.createNestApplication();
+        await app.listen(config.port);
+        return app;
+    }
+}

+ 7 - 3
server/mock-data/clear-all-tables.ts

@@ -5,11 +5,15 @@ import { ConnectionOptions, createConnection } from 'typeorm';
 /**
  * A Class used for generating mock data directly into the database via TypeORM.
  */
-export async function clearAllTables(connectionOptions: ConnectionOptions) {
+export async function clearAllTables(connectionOptions: ConnectionOptions, logging = true) {
     (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
     const connection = await createConnection({ ...connectionOptions, name: 'clearAllTables' });
-    console.log('Clearing all tables...');
+    if (logging) {
+        console.log('Clearing all tables...');
+    }
     await connection.synchronize(true);
     await connection.close();
-    console.log('Done!');
+    if (logging) {
+        console.log('Done!');
+    }
 }

+ 0 - 12
server/mock-data/gql-request.ts

@@ -1,12 +0,0 @@
-import { DocumentNode } from 'graphql';
-import { request } from 'graphql-request';
-import { print } from 'graphql/language/printer';
-
-export class SimpleGraphQLClient {
-    constructor(private apiUrl: string) {}
-
-    request<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T> {
-        const queryString = print(query);
-        return request(this.apiUrl, queryString, variables);
-    }
-}

+ 47 - 50
server/mock-data/mock-data-client.service.ts → server/mock-data/mock-data.service.ts

@@ -19,62 +19,55 @@ import {
     GENERATE_PRODUCT_VARIANTS,
     UPDATE_PRODUCT_VARIANTS,
 } from '../../admin-ui/src/app/data/mutations/product-mutations';
-import { PasswordService } from '../src/auth/password.service';
-import { VendureConfig } from '../src/config/vendure-config';
 import { CreateAddressDto } from '../src/entity/address/address.dto';
 import { CreateAdministratorDto } from '../src/entity/administrator/administrator.dto';
 import { CreateCustomerDto } from '../src/entity/customer/customer.dto';
 import { Customer } from '../src/entity/customer/customer.entity';
 
-import { SimpleGraphQLClient } from './gql-request';
+import { GraphQlClient } from './simple-graphql-client';
 
 // tslint:disable:no-console
 /**
  * A service for creating mock data via the GraphQL API.
  */
-export class MockDataClientService {
+export class MockDataService {
     apiUrl: string;
-    client: SimpleGraphQLClient;
 
-    constructor(config: VendureConfig) {
-        this.client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
+    constructor(private client: GraphQlClient, private logging = true) {
         // make the generated results deterministic
         faker.seed(1);
     }
 
     async populateOptions(): Promise<any> {
         await this.client
-            .request<CreateProductOptionGroup, CreateProductOptionGroupVariables>(
-                CREATE_PRODUCT_OPTION_GROUP,
-                {
-                    input: {
-                        code: 'size',
-                        translations: [
-                            { languageCode: LanguageCode.en, name: 'Size' },
-                            { languageCode: LanguageCode.de, name: 'Größe' },
-                        ],
-                        options: [
-                            {
-                                code: 'small',
-                                translations: [
-                                    { languageCode: LanguageCode.en, name: 'Small' },
-                                    { languageCode: LanguageCode.de, name: 'Klein' },
-                                ],
-                            },
-                            {
-                                code: 'large',
-                                translations: [
-                                    { languageCode: LanguageCode.en, name: 'Large' },
-                                    { languageCode: LanguageCode.de, name: 'Groß' },
-                                ],
-                            },
-                        ],
-                    },
+            .query<CreateProductOptionGroup, CreateProductOptionGroupVariables>(CREATE_PRODUCT_OPTION_GROUP, {
+                input: {
+                    code: 'size',
+                    translations: [
+                        { languageCode: LanguageCode.en, name: 'Size' },
+                        { languageCode: LanguageCode.de, name: 'Größe' },
+                    ],
+                    options: [
+                        {
+                            code: 'small',
+                            translations: [
+                                { languageCode: LanguageCode.en, name: 'Small' },
+                                { languageCode: LanguageCode.de, name: 'Klein' },
+                            ],
+                        },
+                        {
+                            code: 'large',
+                            translations: [
+                                { languageCode: LanguageCode.en, name: 'Large' },
+                                { languageCode: LanguageCode.de, name: 'Groß' },
+                            ],
+                        },
+                    ],
                 },
-            )
+            })
             .then(
-                data => console.log('Created option group:', data.createProductOptionGroup.name),
-                err => console.log(err),
+                data => this.log('Created option group:', data.createProductOptionGroup.name),
+                err => this.log(err),
             );
     }
 
@@ -98,13 +91,11 @@ export class MockDataClientService {
         };
 
         await this.client
-            .request(query, variables)
-            .then(data => console.log('Created Administrator:', data), err => console.log(err));
+            .query(query, variables)
+            .then(data => this.log('Created Administrator:', data), err => this.log(err));
     }
 
     async populateCustomers(count: number = 5): Promise<any> {
-        const passwordService = new PasswordService();
-
         for (let i = 0; i < count; i++) {
             const firstName = faker.name.firstName();
             const lastName = faker.name.lastName();
@@ -129,8 +120,8 @@ export class MockDataClientService {
             };
 
             const customer: Customer | void = await this.client
-                .request(query1, variables1)
-                .then((data: any) => data.createCustomer as Customer, err => console.log(err));
+                .query(query1, variables1)
+                .then((data: any) => data.createCustomer as Customer, err => this.log(err));
 
             if (customer) {
                 const query2 = gql`
@@ -154,12 +145,12 @@ export class MockDataClientService {
                     customerId: customer.id,
                 };
 
-                await this.client.request(query2, variables2).then(
+                await this.client.query(query2, variables2).then(
                     data => {
-                        console.log(`Created Customer ${i + 1}:`, data);
+                        this.log(`Created Customer ${i + 1}:`, data);
                         return data as Customer;
                     },
-                    err => console.log(err),
+                    err => this.log(err),
                 );
             }
         }
@@ -185,13 +176,13 @@ export class MockDataClientService {
             };
 
             const product = await this.client
-                .request<CreateProduct, CreateProductVariables>(query, variables)
+                .query<CreateProduct, CreateProductVariables>(query, variables)
                 .then(
                     data => {
-                        console.log(`Created Product ${i + 1}:`, data.createProduct.name);
+                        this.log(`Created Product ${i + 1}:`, data.createProduct.name);
                         return data;
                     },
-                    err => console.log(err),
+                    err => this.log(err),
                 );
             if (product) {
                 const prodWithVariants = await this.makeProductVariant(product.createProduct.id);
@@ -204,7 +195,7 @@ export class MockDataClientService {
                     delete variantDE.id;
                     variant.translations.push(variantDE);
                 }
-                await this.client.request<UpdateProductVariants, UpdateProductVariantsVariables>(
+                await this.client.query<UpdateProductVariants, UpdateProductVariantsVariables>(
                     UPDATE_PRODUCT_VARIANTS,
                     {
                         input: variants.map(({ id, translations, sku, price }) => ({
@@ -235,8 +226,14 @@ export class MockDataClientService {
 
     private async makeProductVariant(productId: string): Promise<GenerateProductVariants> {
         const query = GENERATE_PRODUCT_VARIANTS;
-        return this.client.request<GenerateProductVariants, GenerateProductVariantsVariables>(query, {
+        return this.client.query<GenerateProductVariants, GenerateProductVariantsVariables>(query, {
             productId,
         });
     }
+
+    private log(...args: any[]) {
+        if (this.logging) {
+            console.log(...args);
+        }
+    }
 }

+ 24 - 0
server/mock-data/populate-cli.ts

@@ -0,0 +1,24 @@
+import { devConfig } from '../dev-config';
+import { bootstrap } from '../src';
+import { VendureConfig } from '../src/config/vendure-config';
+
+import { populate } from './populate';
+
+/**
+ * A CLI script which populates the dev database with deterministic random data.
+ */
+if (require.main === module) {
+    // Running from command line
+    const populateConfig: VendureConfig = {
+        ...devConfig,
+        customFields: {},
+    };
+    // tslint:disable
+    populate(populateConfig, bootstrap, {
+        logging: true,
+        customerCount: 100,
+        productCount: 200,
+    })
+        .then(app => app.close())
+        .then(() => process.exit(0));
+}

+ 29 - 22
server/mock-data/populate.ts

@@ -1,30 +1,37 @@
-import { devConfig } from '../dev-config';
-import { bootstrap } from '../src';
+import { INestApplication } from '@nestjs/common';
+
+import { VendureBootstrapFunction } from '../src/bootstrap';
 import { setConfig, VendureConfig } from '../src/config/vendure-config';
 
 import { clearAllTables } from './clear-all-tables';
-import { MockDataClientService } from './mock-data-client.service';
-
-// tslint:disable:no-floating-promises
-async function populate() {
-    const populateConfig: VendureConfig = {
-        ...devConfig,
-        customFields: {},
-    };
-    (populateConfig.dbConnectionOptions as any).logging = false;
-    setConfig(populateConfig);
-    await clearAllTables(populateConfig.dbConnectionOptions);
+import { MockDataService } from './mock-data.service';
+import { SimpleGraphQLClient } from './simple-graphql-client';
 
-    await bootstrap(populateConfig).catch(err => {
-        // tslint:disable-next-line
-        console.log(err);
-    });
+export interface PopulateOptions {
+    logging?: boolean;
+    productCount: number;
+    customerCount: number;
+}
 
-    const mockDataClientService = new MockDataClientService(devConfig);
+// tslint:disable:no-floating-promises
+/**
+ * Clears all tables from the database and populates with (deterministic) random data.
+ */
+export async function populate(
+    config: VendureConfig,
+    bootstrapFn: VendureBootstrapFunction,
+    options: PopulateOptions,
+): Promise<INestApplication> {
+    (config.dbConnectionOptions as any).logging = false;
+    const logging = options.logging === undefined ? true : options.logging;
+    setConfig(config);
+    await clearAllTables(config.dbConnectionOptions, logging);
+    const app = await bootstrapFn(config);
+    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
+    const mockDataClientService = new MockDataService(client, logging);
     await mockDataClientService.populateOptions();
-    await mockDataClientService.populateProducts(5);
-    await mockDataClientService.populateCustomers(5);
+    await mockDataClientService.populateProducts(options.productCount);
+    await mockDataClientService.populateCustomers(options.customerCount);
     await mockDataClientService.populateAdmins();
+    return app;
 }
-
-populate().then(() => process.exit(0));

+ 19 - 0
server/mock-data/simple-graphql-client.ts

@@ -0,0 +1,19 @@
+import { DocumentNode } from 'graphql';
+import { request } from 'graphql-request';
+import { print } from 'graphql/language/printer';
+
+export interface GraphQlClient {
+    query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T>;
+}
+
+/**
+ * A minimalistic GraphQL client for populating test data.
+ */
+export class SimpleGraphQLClient implements GraphQlClient {
+    constructor(private apiUrl: string) {}
+
+    query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T> {
+        const queryString = print(query);
+        return request(this.apiUrl, queryString, variables);
+    }
+}

+ 4 - 2
server/package.json

@@ -5,13 +5,14 @@
   "private": true,
   "license": "MIT",
   "scripts": {
-    "populate": "node -r ts-node/register -r tsconfig-paths/register mock-data/populate.ts",
+    "populate": "node -r ts-node/register -r tsconfig-paths/register mock-data/populate-cli.ts",
     "start:dev": "nodemon --config nodemon-debug.json",
     "lint": "tslint --project tsconfig.json -c tslint.json",
     "test": "jest",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
-    "test:e2e": "jest --config ./test/jest-e2e.json",
+    "test:e2e": "jest --config ./e2e/config/jest-e2e.json",
+    "test:e2e:watch": "jest --config ./e2e/config/jest-e2e.json --watch",
     "build": "rimraf dist && tsc -p tsconfig.build.json && gulp"
   },
   "main": "dist/index.js",
@@ -63,6 +64,7 @@
     "jest": "^23.5.0",
     "nodemon": "^1.14.1",
     "rimraf": "^2.6.2",
+    "sql.js": "^0.5.0",
     "supertest": "^3.0.0",
     "ts-jest": "^23.1.4",
     "tsconfig-paths": "^3.5.0"

+ 19 - 8
server/src/bootstrap.ts

@@ -1,3 +1,4 @@
+import { INestApplication } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { Type } from 'shared/shared-types';
 
@@ -5,10 +6,26 @@ import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 import { VendureEntity } from './entity/base/base.entity';
 import { registerCustomEntityFields } from './entity/custom-entity-fields';
 
+export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
+
 /**
  * Bootstrap the Vendure server.
  */
-export async function bootstrap(userConfig: Partial<VendureConfig>) {
+export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
+    const config = await preBootstrapConfig(userConfig);
+
+    // The AppModule *must* be loaded only after the entities have been set in the
+    // config, so that they are available when the AppModule decorator is evaluated.
+    // tslint:disable-next-line:whitespace
+    const appModule = await import('./app.module');
+    const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
+    return app.listen(config.port);
+}
+
+/**
+ * Setting the global config must be done prior to loading the AppModule.
+ */
+export async function preBootstrapConfig(userConfig: Partial<VendureConfig>): Promise<VendureConfig> {
     if (userConfig) {
         setConfig(userConfig);
     }
@@ -27,11 +44,5 @@ export async function bootstrap(userConfig: Partial<VendureConfig>) {
     const config = getConfig();
 
     registerCustomEntityFields(config);
-
-    // The AppModule *must* be loaded only after the entities have been set in the
-    // config, so that they are available when the AppModule decorator is evaluated.
-    // tslint:disable-next-line:whitespace
-    const appModule = await import('./app.module');
-    const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
-    await app.listen(config.port);
+    return config;
 }

+ 3 - 2
server/src/config/vendure-config.ts

@@ -1,5 +1,6 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { LanguageCode } from 'shared/generated-types';
+import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { CustomFields, DeepPartial } from 'shared/shared-types';
 import { ConnectionOptions } from 'typeorm';
 
@@ -53,10 +54,10 @@ export interface VendureConfig {
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     defaultLanguageCode: LanguageCode.en,
-    port: 3000,
+    port: API_PORT,
     cors: false,
     jwtSecret: 'secret',
-    apiPath: '/api',
+    apiPath: API_PATH,
     entityIdStrategy: new AutoIncrementIdStrategy(),
     dbConnectionOptions: {
         type: 'mysql',

+ 12 - 6
server/src/entity/address/address.entity.ts

@@ -16,25 +16,31 @@ export class Address extends VendureEntity implements HasCustomFields {
 
     @Column() fullName: string;
 
-    @Column() company: string;
+    @Column({ default: '' })
+    company: string;
 
     @Column() streetLine1: string;
 
-    @Column() streetLine2: string;
+    @Column({ default: '' })
+    streetLine2: string;
 
     @Column() city: string;
 
-    @Column() province: string;
+    @Column({ default: '' })
+    province: string;
 
     @Column() postalCode: string;
 
     @Column() country: string;
 
-    @Column() phoneNumber: string;
+    @Column({ default: '' })
+    phoneNumber: string;
 
-    @Column() defaultShippingAddress: boolean;
+    @Column({ default: false })
+    defaultShippingAddress: boolean;
 
-    @Column() defaultBillingAddress: boolean;
+    @Column({ default: false })
+    defaultBillingAddress: boolean;
 
     @Column(type => CustomAddressFields)
     customFields: CustomAddressFields;

+ 1 - 1
server/src/entity/facet-value/facet-value-translation.entity.ts

@@ -14,7 +14,7 @@ export class FacetValueTranslation extends VendureEntity implements Translation<
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 1 - 1
server/src/entity/facet/facet-translation.entity.ts

@@ -14,7 +14,7 @@ export class FacetTranslation extends VendureEntity implements Translation<Facet
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 1 - 1
server/src/entity/product-option-group/product-option-group-translation.entity.ts

@@ -16,7 +16,7 @@ export class ProductOptionGroupTranslation extends VendureEntity
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 1 - 1
server/src/entity/product-option/product-option-translation.entity.ts

@@ -16,7 +16,7 @@ export class ProductOptionTranslation extends VendureEntity
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 1 - 1
server/src/entity/product-variant/product-variant-translation.entity.ts

@@ -16,7 +16,7 @@ export class ProductVariantTranslation extends VendureEntity
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 1 - 1
server/src/entity/product/product-translation.entity.ts

@@ -14,7 +14,7 @@ export class ProductTranslation extends VendureEntity implements Translation<Pro
         super(input);
     }
 
-    @Column() languageCode: LanguageCode;
+    @Column('varchar') languageCode: LanguageCode;
 
     @Column() name: string;
 

+ 2 - 1
server/src/entity/user/user.entity.ts

@@ -19,7 +19,8 @@ export class User extends VendureEntity implements HasCustomFields {
 
     @Column('simple-array') roles: Role[];
 
-    @Column() lastLogin: string;
+    @Column({ nullable: true })
+    lastLogin: string;
 
     @Column(type => CustomUserFields)
     customFields: CustomUserFields;

+ 1 - 0
server/src/service/product-variant.service.ts

@@ -81,6 +81,7 @@ export class ProductVariantService {
             return this.create(product, {
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
+                image: '',
                 optionCodes: options.map(o => o.code),
                 translations: [
                     {

+ 0 - 8
server/tsconfig.spec.json

@@ -1,8 +0,0 @@
-{
-  "extends": "./tsconfig.json",
-  "compilerOptions": {
-    "types": ["jest", "node"],
-    "lib": ["es2015"]
-  },
-  "include": ["**/*.spec.ts", "**/*.d.ts"]
-}

+ 4 - 0
server/yarn.lock

@@ -4691,6 +4691,10 @@ sprintf-js@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
 
+sql.js@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.5.0.tgz#f880dea18280a840e41df2209dc967422dfa8d81"
+
 sqlstring@2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40"