Browse Source

Merge branch 'deleting'

Michael Bromley 7 years ago
parent
commit
ba78aa4043
57 changed files with 1951 additions and 562 deletions
  1. 2 2
      admin-ui/src/app/customer/components/address-card/address-card.component.html
  2. 3 1
      admin-ui/src/app/customer/components/customer-detail/customer-detail.component.ts
  3. 5 2
      admin-ui/src/app/data/definitions/customer-definitions.ts
  4. 0 0
      schema.json
  5. 75 0
      server/e2e/__snapshots__/facet.e2e-spec.ts.snap
  6. 2 2
      server/e2e/__snapshots__/product-category.e2e-spec.ts.snap
  7. 1 1
      server/e2e/__snapshots__/promotion.e2e-spec.ts.snap
  8. 20 19
      server/e2e/administrator.e2e-spec.ts
  9. 41 46
      server/e2e/auth.e2e-spec.ts
  10. 126 0
      server/e2e/country.e2e-spec.ts
  11. 108 27
      server/e2e/customer.e2e-spec.ts
  12. 339 0
      server/e2e/facet.e2e-spec.ts
  13. 140 191
      server/e2e/order.e2e-spec.ts
  14. 33 36
      server/e2e/product-category.e2e-spec.ts
  15. 231 141
      server/e2e/product.e2e-spec.ts
  16. 67 10
      server/e2e/promotion.e2e-spec.ts
  17. 24 31
      server/e2e/role.e2e-spec.ts
  18. 14 0
      server/e2e/test-utils.ts
  19. 162 0
      server/e2e/zone.e2e-spec.ts
  20. 1 1
      server/mock-data/mock-data.service.ts
  21. 2 0
      server/src/api/common/request-context.service.ts
  22. 17 3
      server/src/api/common/request-context.ts
  23. 11 0
      server/src/api/resolvers/country.resolver.ts
  24. 14 3
      server/src/api/resolvers/customer.resolver.ts
  25. 24 0
      server/src/api/resolvers/facet.resolver.ts
  26. 12 3
      server/src/api/resolvers/product.resolver.ts
  27. 8 0
      server/src/api/resolvers/promotion.resolver.ts
  28. 11 0
      server/src/api/resolvers/zone.resolver.ts
  29. 4 0
      server/src/api/types/country.api.graphql
  30. 6 0
      server/src/api/types/customer.api.graphql
  31. 9 0
      server/src/api/types/facet.api.graphql
  32. 3 0
      server/src/api/types/product.api.graphql
  33. 1 0
      server/src/api/types/promotion.api.graphql
  34. 6 0
      server/src/api/types/zone.api.graphql
  35. 12 0
      server/src/common/types/common-types.graphql
  36. 7 0
      server/src/common/types/common-types.ts
  37. 3 3
      server/src/entity/address/address.entity.ts
  38. 1 2
      server/src/entity/address/address.graphql
  39. 1 1
      server/src/entity/country/country-translation.entity.ts
  40. 5 1
      server/src/entity/customer/customer.entity.ts
  41. 1 1
      server/src/entity/facet-value/facet-value-translation.entity.ts
  42. 1 1
      server/src/entity/facet-value/facet-value.entity.ts
  43. 1 1
      server/src/entity/facet/facet-translation.entity.ts
  44. 7 2
      server/src/entity/product/product.entity.ts
  45. 5 2
      server/src/entity/promotion/promotion.entity.ts
  46. 8 0
      server/src/i18n/messages/en.json
  47. 2 1
      server/src/service/helpers/utils/get-entity-or-throw.ts
  48. 30 1
      server/src/service/services/country.service.ts
  49. 1 1
      server/src/service/services/customer-group.service.ts
  50. 31 7
      server/src/service/services/customer.service.ts
  51. 57 0
      server/src/service/services/facet-value.service.ts
  52. 42 1
      server/src/service/services/facet.service.ts
  53. 1 0
      server/src/service/services/product-variant.service.ts
  54. 21 3
      server/src/service/services/product.service.ts
  55. 15 7
      server/src/service/services/promotion.service.ts
  56. 29 0
      server/src/service/services/zone.service.ts
  57. 148 8
      shared/generated-types.ts

+ 2 - 2
admin-ui/src/app/customer/components/address-card/address-card.component.html

@@ -24,9 +24,9 @@
                 <li *ngIf="address.city">{{ address.city }}</li>
                 <li *ngIf="address.province">{{ address.province }}</li>
                 <li *ngIf="address.postalCode">{{ address.postalCode }}</li>
-                <li *ngIf="address.countryCode">
+                <li *ngIf="address.country">
                     <clr-icon shape="world" size="12"></clr-icon>
-                    {{ getCountryName(address.countryCode) }}
+                    {{ address.country.name }}
                 </li>
                 <li *ngIf="address.phoneNumber">
                     <clr-icon shape="phone-handset" size="12"></clr-icon>

+ 3 - 1
admin-ui/src/app/customer/components/customer-detail/customer-detail.component.ts

@@ -248,7 +248,9 @@ export class CustomerDetailComponent extends BaseDetailComponent<GetCustomer.Cus
         if (entity.addresses) {
             const addressesArray = new FormArray([]);
             for (const address of entity.addresses) {
-                addressesArray.push(this.formBuilder.group(address));
+                addressesArray.push(
+                    this.formBuilder.group({ ...address, countryCode: address.country.code }),
+                );
                 if (address.defaultShippingAddress) {
                     this.defaultShippingAddressId = address.id;
                 }

+ 5 - 2
admin-ui/src/app/data/definitions/customer-definitions.ts

@@ -10,8 +10,11 @@ export const ADDRESS_FRAGMENT = gql`
         city
         province
         postalCode
-        country
-        countryCode
+        country {
+            id
+            code
+            name
+        }
         phoneNumber
         defaultShippingAddress
         defaultBillingAddress

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 75 - 0
server/e2e/__snapshots__/facet.e2e-spec.ts.snap

@@ -0,0 +1,75 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Facet resolver createFacet 1`] = `
+Object {
+  "code": "speaker-type",
+  "id": "T_2",
+  "languageCode": "en",
+  "name": "Speaker Type",
+  "translations": Array [
+    Object {
+      "id": "T_3",
+      "languageCode": "en",
+      "name": "Speaker Type",
+    },
+  ],
+  "values": Array [
+    Object {
+      "code": "portable",
+      "facet": Object {
+        "id": "T_2",
+        "name": "Speaker Type",
+      },
+      "id": "T_11",
+      "languageCode": "en",
+      "name": "Portable",
+      "translations": Array [
+        Object {
+          "id": "T_21",
+          "languageCode": "en",
+          "name": "Portable",
+        },
+      ],
+    },
+  ],
+}
+`;
+
+exports[`Facet resolver createFacetValues 1`] = `
+Array [
+  Object {
+    "code": "pc",
+    "facet": Object {
+      "id": "T_2",
+      "name": "Speaker Category",
+    },
+    "id": "T_13",
+    "languageCode": "en",
+    "name": "PC Speakers",
+    "translations": Array [
+      Object {
+        "id": "T_23",
+        "languageCode": "en",
+        "name": "PC Speakers",
+      },
+    ],
+  },
+  Object {
+    "code": "hi-fi",
+    "facet": Object {
+      "id": "T_2",
+      "name": "Speaker Category",
+    },
+    "id": "T_12",
+    "languageCode": "en",
+    "name": "Hi Fi Speakers",
+    "translations": Array [
+      Object {
+        "id": "T_22",
+        "languageCode": "en",
+        "name": "Hi Fi Speakers",
+      },
+    ],
+  },
+]
+`;

+ 2 - 2
server/e2e/__snapshots__/product-category.e2e-spec.ts.snap

@@ -26,7 +26,7 @@ Object {
   "description": "",
   "facetValues": Array [
     Object {
-      "code": "Larkin_Group",
+      "code": "larkin-group",
       "id": "T_1",
       "name": "Larkin Group",
     },
@@ -75,7 +75,7 @@ Object {
   "description": "Apple stuff ",
   "facetValues": Array [
     Object {
-      "code": "Lindgren,_Conn_and_King",
+      "code": "lindgren-conn-and-king",
       "id": "T_3",
       "name": "Lindgren, Conn and King",
     },

+ 1 - 1
server/e2e/__snapshots__/promotion.e2e-spec.ts.snap

@@ -42,7 +42,7 @@ Object {
 }
 `;
 
-exports[`Promotion resolver createPromotion promotion 1`] = `
+exports[`Promotion resolver createPromotion 1`] = `
 Object {
   "actions": Array [
     Object {

+ 20 - 19
server/e2e/administrator.e2e-spec.ts

@@ -16,6 +16,7 @@ import {
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 describe('Administrator resolver', () => {
     const client = new TestClient();
@@ -102,24 +103,24 @@ describe('Administrator resolver', () => {
         expect(result.updateAdministrator.lastName).toBe('new last');
     });
 
-    it('updateAdministrator throws with invalid roleId', async () => {
-        try {
-            const result = await client.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
-                UPDATE_ADMINISTRATOR,
-                {
-                    input: {
-                        id: createdAdmin.id,
-                        emailAddress: 'new-email',
-                        firstName: 'new first',
-                        lastName: 'new last',
-                        password: 'new password',
-                        roleIds: ['999'],
+    it(
+        'updateAdministrator throws with invalid roleId',
+        assertThrowsWithMessage(
+            () =>
+                client.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
+                    UPDATE_ADMINISTRATOR,
+                    {
+                        input: {
+                            id: createdAdmin.id,
+                            emailAddress: 'new-email',
+                            firstName: 'new first',
+                            lastName: 'new last',
+                            password: 'new password',
+                            roleIds: ['999'],
+                        },
                     },
-                },
-            );
-            fail(`Should throw`);
-        } catch (err) {
-            expect(err.message).toEqual(expect.stringContaining(`No Role with the id '999' could be found`));
-        }
-    });
+                ),
+            `No Role with the id '999' could be found`,
+        ),
+    );
 });

+ 41 - 46
server/e2e/auth.e2e-spec.ts

@@ -28,6 +28,7 @@ import { defaultEmailTypes } from '../src/email/default-email-types';
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 let sendEmailFn: jest.Mock;
 const emailOptions = {
@@ -226,17 +227,17 @@ describe('Authorization & permissions', () => {
             }
         });
 
-        it('verification fails with wrong token', async () => {
-            try {
-                await client.query(VERIFY_EMAIL, {
-                    password,
-                    token: 'bad-token',
-                });
-                fail('should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(expect.stringContaining(`Verification token not recognized`));
-            }
-        });
+        it(
+            'verification fails with wrong token',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(VERIFY_EMAIL, {
+                        password,
+                        token: 'bad-token',
+                    }),
+                `Verification token not recognized`,
+            ),
+        );
 
         it('verification succeeds with correct token', async () => {
             const result = await client.query(VERIFY_EMAIL, {
@@ -259,17 +260,17 @@ describe('Authorization & permissions', () => {
             expect(sendEmailFn).not.toHaveBeenCalled();
         });
 
-        it('verification fails if attempted a second time', async () => {
-            try {
-                await client.query(VERIFY_EMAIL, {
-                    password,
-                    token: verificationToken,
-                });
-                fail('should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(expect.stringContaining(`Verification token not recognized`));
-            }
-        });
+        it(
+            'verification fails if attempted a second time',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(VERIFY_EMAIL, {
+                        password,
+                        token: verificationToken,
+                    }),
+                `Verification token not recognized`,
+            ),
+        );
     });
 
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
@@ -374,37 +375,31 @@ describe('Expiring registration token', () => {
         await server.destroy();
     });
 
-    it('attempting to verify after token has expired throws', async () => {
-        const verificationTokenPromise = getVerificationTokenPromise();
-        const input: RegisterCustomerInput = {
-            firstName: 'Barry',
-            lastName: 'Wallace',
-            emailAddress: 'barry.wallace@test.com',
-        };
-        const result1 = await client.query(REGISTER_ACCOUNT, { input });
+    it(
+        'attempting to verify after token has expired throws',
+        assertThrowsWithMessage(async () => {
+            const verificationTokenPromise = getVerificationTokenPromise();
+            const input: RegisterCustomerInput = {
+                firstName: 'Barry',
+                lastName: 'Wallace',
+                emailAddress: 'barry.wallace@test.com',
+            };
+            const result1 = await client.query(REGISTER_ACCOUNT, { input });
 
-        const verificationToken = await verificationTokenPromise;
+            const verificationToken = await verificationTokenPromise;
 
-        expect(result1.registerCustomerAccount).toBe(true);
-        expect(sendEmailFn).toHaveBeenCalledTimes(1);
-        expect(verificationToken).toBeDefined();
+            expect(result1.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalledTimes(1);
+            expect(verificationToken).toBeDefined();
 
-        await new Promise(resolve => setTimeout(resolve, 3));
+            await new Promise(resolve => setTimeout(resolve, 3));
 
-        try {
-            await client.query(VERIFY_EMAIL, {
+            return client.query(VERIFY_EMAIL, {
                 password: 'test',
                 token: verificationToken,
             });
-            fail('should have thrown');
-        } catch (err) {
-            expect(err.message).toEqual(
-                expect.stringContaining(
-                    `Verification token has expired. Use refreshCustomerVerification to send a new token.`,
-                ),
-            );
-        }
-    });
+        }, `Verification token has expired. Use refreshCustomerVerification to send a new token.`),
+    );
 });
 
 function getVerificationTokenPromise(): Promise<string> {

+ 126 - 0
server/e2e/country.e2e-spec.ts

@@ -0,0 +1,126 @@
+import gql from 'graphql-tag';
+
+import {
+    CREATE_COUNTRY,
+    GET_AVAILABLE_COUNTRIES,
+    GET_COUNTRY,
+    GET_COUNTRY_LIST,
+    UPDATE_COUNTRY,
+} from '../../admin-ui/src/app/data/definitions/settings-definitions';
+import {
+    CreateCountry,
+    DeletionResult,
+    GetAvailableCountries,
+    GetCountry,
+    GetCountryList,
+    LanguageCode,
+    UpdateCountry,
+} from '../../shared/generated-types';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+// tslint:disable:no-non-null-assertion
+
+describe('Facet resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+    let countries: GetCountryList.Items[];
+    let GB: GetCountryList.Items;
+    let AT: GetCountryList.Items;
+
+    beforeAll(async () => {
+        await server.init({
+            productCount: 2,
+            customerCount: 1,
+        });
+        await client.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('countries', async () => {
+        const result = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+
+        expect(result.countries.totalItems).toBe(9);
+        countries = result.countries.items;
+        GB = countries.find(c => c.code === 'GB')!;
+        AT = countries.find(c => c.code === 'AT')!;
+    });
+
+    it('country', async () => {
+        const result = await client.query<GetCountry.Query, GetCountry.Variables>(GET_COUNTRY, {
+            id: GB.id,
+        });
+
+        expect(result.country!.name).toBe(GB.name);
+    });
+
+    it('updateCountry', async () => {
+        const result = await client.query<UpdateCountry.Mutation, UpdateCountry.Variables>(UPDATE_COUNTRY, {
+            input: {
+                id: AT.id,
+                enabled: false,
+            },
+        });
+
+        expect(result.updateCountry.enabled).toBe(false);
+    });
+
+    it('availableCountries returns enabled countries', async () => {
+        const result = await client.query<GetAvailableCountries.Query>(GET_AVAILABLE_COUNTRIES);
+
+        expect(result.availableCountries.length).toBe(countries.length - 1);
+        expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined();
+    });
+
+    it('createCountry', async () => {
+        const result = await client.query<CreateCountry.Mutation, CreateCountry.Variables>(CREATE_COUNTRY, {
+            input: {
+                code: 'GL',
+                enabled: true,
+                translations: [{ languageCode: LanguageCode.en, name: 'Gondwanaland' }],
+            },
+        });
+
+        expect(result.createCountry.name).toBe('Gondwanaland');
+    });
+
+    describe('deletion', () => {
+        it('deletes Country not used in any address', async () => {
+            const result1 = await client.query(DELETE_COUNTRY, { id: AT.id });
+
+            expect(result1.deleteCountry).toEqual({
+                result: DeletionResult.DELETED,
+                message: '',
+            });
+
+            const result2 = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+            expect(result2.countries.items.find(c => c.id === AT.id)).toBeUndefined();
+        });
+
+        it('does not delete Country that is used in one or more addresses', async () => {
+            const result1 = await client.query(DELETE_COUNTRY, { id: GB.id });
+
+            expect(result1.deleteCountry).toEqual({
+                result: DeletionResult.NOT_DELETED,
+                message: 'The selected Country cannot be deleted as it is used in 1 Address',
+            });
+
+            const result2 = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+            expect(result2.countries.items.find(c => c.id === GB.id)).not.toBeUndefined();
+        });
+    });
+});
+
+const DELETE_COUNTRY = gql`
+    mutation DeleteCountry($id: ID!) {
+        deleteCountry(id: $id) {
+            result
+            message
+        }
+    }
+`;

+ 108 - 27
server/e2e/customer.e2e-spec.ts

@@ -1,15 +1,26 @@
 import gql from 'graphql-tag';
 
 import {
+    CREATE_CUSTOMER_ADDRESS,
     GET_CUSTOMER,
     GET_CUSTOMER_LIST,
+    UPDATE_CUSTOMER,
+    UPDATE_CUSTOMER_ADDRESS,
 } from '../../admin-ui/src/app/data/definitions/customer-definitions';
-import { GetCustomer, GetCustomerList } from '../../shared/generated-types';
+import {
+    CreateCustomerAddress,
+    DeletionResult,
+    GetCustomer,
+    GetCustomerList,
+    UpdateCustomer,
+    UpdateCustomerAddress,
+} from '../../shared/generated-types';
 import { omit } from '../../shared/omit';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
@@ -17,7 +28,8 @@ describe('Customer resolver', () => {
     const client = new TestClient();
     const server = new TestServer();
     let firstCustomer: GetCustomerList.Items;
-    let secondsCustomer: GetCustomerList.Items;
+    let secondCustomer: GetCustomerList.Items;
+    let thirdCustomer: GetCustomerList.Items;
 
     beforeAll(async () => {
         const token = await server.init({
@@ -39,28 +51,27 @@ describe('Customer resolver', () => {
         expect(result.customers.items.length).toBe(5);
         expect(result.customers.totalItems).toBe(5);
         firstCustomer = result.customers.items[0];
-        secondsCustomer = result.customers.items[1];
+        secondCustomer = result.customers.items[1];
+        thirdCustomer = result.customers.items[2];
     });
 
     describe('addresses', () => {
         let firstCustomerAddressIds: string[] = [];
 
-        it('createCustomerAddress throws on invalid countryCode', async () => {
-            try {
-                await client.query(CREATE_ADDRESS, {
-                    id: firstCustomer.id,
-                    input: {
-                        streetLine1: 'streetLine1',
-                        countryCode: 'INVALID',
-                    },
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`The countryCode "INVALID" was not recognized`),
-                );
-            }
-        });
+        it(
+            'createCustomerAddress throws on invalid countryCode',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(CREATE_ADDRESS, {
+                        id: firstCustomer.id,
+                        input: {
+                            streetLine1: 'streetLine1',
+                            countryCode: 'INVALID',
+                        },
+                    }),
+                `The countryCode "INVALID" was not recognized`,
+            ),
+        );
 
         it('createCustomerAddress creates a new address', async () => {
             const result = await client.query(CREATE_ADDRESS, {
@@ -87,8 +98,10 @@ describe('Customer resolver', () => {
                 city: 'city',
                 province: 'province',
                 postalCode: 'postalCode',
-                countryCode: 'GB',
-                country: 'United Kingdom of Great Britain and Northern Ireland',
+                country: {
+                    code: 'GB',
+                    name: 'United Kingdom of Great Britain and Northern Ireland',
+                },
                 phoneNumber: 'phoneNumber',
                 defaultShippingAddress: false,
                 defaultBillingAddress: false,
@@ -143,7 +156,7 @@ describe('Customer resolver', () => {
 
             // get the second customer's address id
             const result5 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
-                id: secondsCustomer.id,
+                id: secondCustomer.id,
             });
             const secondCustomerAddressId = result5.customer!.addresses![0].id;
 
@@ -186,8 +199,10 @@ describe('Customer resolver', () => {
                 city: '',
                 province: '',
                 postalCode: '',
-                countryCode: 'GB',
-                country: 'United Kingdom of Great Britain and Northern Ireland',
+                country: {
+                    code: 'GB',
+                    name: 'United Kingdom of Great Britain and Northern Ireland',
+                },
                 phoneNumber: '',
                 defaultShippingAddress: true,
                 defaultBillingAddress: true,
@@ -206,7 +221,7 @@ describe('Customer resolver', () => {
     });
 
     describe('orders', () => {
-        it("lists that user's orders", async () => {
+        it(`lists that user\'s orders`, async () => {
             // log in as first customer
             await client.asUserWithCredentials(firstCustomer.emailAddress, 'test');
             // add an item to the order to create an order
@@ -223,6 +238,62 @@ describe('Customer resolver', () => {
             expect(result2.customer.orders.items[0].id).toBe(result1.addItemToOrder.id);
         });
     });
+
+    describe('deletion', () => {
+        it('deletes a customer', async () => {
+            const result = await client.query(DELETE_CUSTOMER, { id: thirdCustomer.id });
+
+            expect(result.deleteCustomer).toEqual({ result: DeletionResult.DELETED });
+        });
+
+        it('cannot get a deleted customer', async () => {
+            const result = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+                id: thirdCustomer.id,
+            });
+
+            expect(result.customer).toBe(null);
+        });
+
+        it('deleted customer omitted from list', async () => {
+            const result = await client.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+
+            expect(result.customers.items.map(c => c.id).includes(thirdCustomer.id)).toBe(false);
+        });
+
+        it(
+            'updateCustomer throws for deleted customer',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<UpdateCustomer.Mutation, UpdateCustomer.Variables>(UPDATE_CUSTOMER, {
+                        input: {
+                            id: thirdCustomer.id,
+                            firstName: 'updated',
+                        },
+                    }),
+                `No Customer with the id '3' could be found`,
+            ),
+        );
+
+        it(
+            'createCustomerAddress throws for deleted customer',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<CreateCustomerAddress.Mutation, CreateCustomerAddress.Variables>(
+                        CREATE_CUSTOMER_ADDRESS,
+                        {
+                            customerId: thirdCustomer.id,
+                            input: {
+                                streetLine1: 'test',
+                                countryCode: 'GB',
+                            },
+                        },
+                    ),
+                `No Customer with the id '3' could be found`,
+            ),
+        );
+    });
 });
 
 const CREATE_ADDRESS = gql`
@@ -236,8 +307,10 @@ const CREATE_ADDRESS = gql`
             city
             province
             postalCode
-            country
-            countryCode
+            country {
+                code
+                name
+            }
             phoneNumber
             defaultShippingAddress
             defaultBillingAddress
@@ -275,3 +348,11 @@ const GET_CUSTOMER_ORDERS = gql`
         }
     }
 `;
+
+const DELETE_CUSTOMER = gql`
+    mutation DeleteCustomer($id: ID!) {
+        deleteCustomer(id: $id) {
+            result
+        }
+    }
+`;

+ 339 - 0
server/e2e/facet.e2e-spec.ts

@@ -0,0 +1,339 @@
+import gql from 'graphql-tag';
+
+import {
+    CREATE_FACET,
+    CREATE_FACET_VALUES,
+    GET_FACET_LIST,
+    GET_FACET_WITH_VALUES,
+    UPDATE_FACET,
+    UPDATE_FACET_VALUES,
+} from '../../admin-ui/src/app/data/definitions/facet-definitions';
+import {
+    GET_PRODUCT_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    UPDATE_PRODUCT,
+    UPDATE_PRODUCT_VARIANTS,
+} from '../../admin-ui/src/app/data/definitions/product-definitions';
+import {
+    CreateFacet,
+    CreateFacetValues,
+    DeletionResult,
+    FacetWithValues,
+    GetFacetList,
+    GetFacetWithValues,
+    GetProductList,
+    GetProductWithVariants,
+    LanguageCode,
+    UpdateFacet,
+    UpdateFacetValues,
+    UpdateProduct,
+    UpdateProductVariants,
+} from '../../shared/generated-types';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+// tslint:disable:no-non-null-assertion
+
+describe('Facet resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+    let brandFacet: FacetWithValues.Fragment;
+    let speakerTypeFacet: FacetWithValues.Fragment;
+
+    beforeAll(async () => {
+        await server.init({
+            productCount: 2,
+            customerCount: 1,
+        });
+        await client.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('createFacet', async () => {
+        const result = await client.query<CreateFacet.Mutation, CreateFacet.Variables>(CREATE_FACET, {
+            input: {
+                code: 'speaker-type',
+                translations: [{ languageCode: LanguageCode.en, name: 'Speaker Type' }],
+                values: [
+                    {
+                        code: 'portable',
+                        translations: [{ languageCode: LanguageCode.en, name: 'Portable' }],
+                    },
+                ],
+            },
+        });
+
+        speakerTypeFacet = result.createFacet;
+        expect(speakerTypeFacet).toMatchSnapshot();
+    });
+
+    it('updateFacet', async () => {
+        const result = await client.query<UpdateFacet.Mutation, UpdateFacet.Variables>(UPDATE_FACET, {
+            input: {
+                id: speakerTypeFacet.id,
+                translations: [{ languageCode: LanguageCode.en, name: 'Speaker Category' }],
+            },
+        });
+
+        expect(result.updateFacet.name).toBe('Speaker Category');
+    });
+
+    it('createFacetValues', async () => {
+        const result = await client.query<CreateFacetValues.Mutation, CreateFacetValues.Variables>(
+            CREATE_FACET_VALUES,
+            {
+                input: [
+                    {
+                        facetId: speakerTypeFacet.id,
+                        code: 'pc',
+                        translations: [{ languageCode: LanguageCode.en, name: 'PC Speakers' }],
+                    },
+                    {
+                        facetId: speakerTypeFacet.id,
+                        code: 'hi-fi',
+                        translations: [{ languageCode: LanguageCode.en, name: 'Hi Fi Speakers' }],
+                    },
+                ],
+            },
+        );
+
+        expect(result.createFacetValues).toMatchSnapshot();
+    });
+
+    it('updateFacetValues', async () => {
+        const result = await client.query<UpdateFacetValues.Mutation, UpdateFacetValues.Variables>(
+            UPDATE_FACET_VALUES,
+            {
+                input: [
+                    {
+                        id: speakerTypeFacet.values[0].id,
+                        code: 'compact',
+                    },
+                ],
+            },
+        );
+
+        expect(result.updateFacetValues[0].code).toBe('compact');
+    });
+
+    it('facets', async () => {
+        const result = await client.query<GetFacetList.Query>(GET_FACET_LIST);
+
+        const { items } = result.facets;
+        expect(items.length).toBe(2);
+        expect(items[0].name).toBe('Brand');
+        expect(items[1].name).toBe('Speaker Category');
+
+        brandFacet = items[0];
+        speakerTypeFacet = items[1];
+    });
+
+    it('facet', async () => {
+        const result = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            GET_FACET_WITH_VALUES,
+            {
+                id: speakerTypeFacet.id,
+            },
+        );
+
+        expect(result.facet!.name).toBe('Speaker Category');
+    });
+
+    describe('deletion', () => {
+        let products: Array<GetProductList.Items & { variants: Array<{ id: string; name: string }> }>;
+
+        beforeAll(async () => {
+            // add the FacetValues to products and variants
+            const result1 = await client.query(GET_PRODUCTS_LIST_WITH_VARIANTS);
+            products = result1.products.items;
+
+            await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                input: {
+                    id: products[0].id,
+                    facetValueIds: [speakerTypeFacet.values[0].id],
+                },
+            });
+
+            await client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [
+                        {
+                            id: products[0].variants[0].id,
+                            facetValueIds: [speakerTypeFacet.values[0].id],
+                        },
+                    ],
+                },
+            );
+
+            await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                input: {
+                    id: products[1].id,
+                    facetValueIds: [speakerTypeFacet.values[1].id],
+                },
+            });
+        });
+
+        it('deleteFacetValues deletes unused facetValue', async () => {
+            const facetValueToDelete = speakerTypeFacet.values[2];
+            const result1 = await client.query(DELETE_FACET_VALUES, {
+                ids: [facetValueToDelete.id],
+                force: false,
+            });
+            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+                GET_FACET_WITH_VALUES,
+                {
+                    id: speakerTypeFacet.id,
+                },
+            );
+
+            expect(result1.deleteFacetValues).toEqual([
+                {
+                    result: DeletionResult.DELETED,
+                    message: ``,
+                },
+            ]);
+
+            expect(result2.facet!.values[0]).not.toEqual(facetValueToDelete);
+        });
+
+        it('deleteFacetValues for FacetValue in use returns NOT_DELETED', async () => {
+            const facetValueToDelete = speakerTypeFacet.values[0];
+            const result1 = await client.query(DELETE_FACET_VALUES, {
+                ids: [facetValueToDelete.id],
+                force: false,
+            });
+            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+                GET_FACET_WITH_VALUES,
+                {
+                    id: speakerTypeFacet.id,
+                },
+            );
+
+            expect(result1.deleteFacetValues).toEqual([
+                {
+                    result: DeletionResult.NOT_DELETED,
+                    message: `The selected FacetValue is assigned to 1 Product, 1 ProductVariant. To delete anyway, set "force: true"`,
+                },
+            ]);
+
+            expect(result2.facet!.values[0]).toEqual(facetValueToDelete);
+        });
+
+        it('deleteFacetValues for FacetValue in use can be force deleted', async () => {
+            const facetValueToDelete = speakerTypeFacet.values[0];
+            const result1 = await client.query(DELETE_FACET_VALUES, {
+                ids: [facetValueToDelete.id],
+                force: true,
+            });
+
+            expect(result1.deleteFacetValues).toEqual([
+                {
+                    result: DeletionResult.DELETED,
+                    message: `The selected FacetValue was removed from 1 Product, 1 ProductVariant and deleted`,
+                },
+            ]);
+
+            // FacetValue no longer in the Facet.values array
+            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+                GET_FACET_WITH_VALUES,
+                {
+                    id: speakerTypeFacet.id,
+                },
+            );
+            expect(result2.facet!.values[0]).not.toEqual(facetValueToDelete);
+
+            // FacetValue no longer in the Product.facetValues array
+            const result3 = await client.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: products[0].id,
+            });
+            expect(result3.product!.facetValues).toEqual([]);
+        });
+
+        it('deleteFacet that is in use returns NOT_DELETED', async () => {
+            const result1 = await client.query(DELETE_FACET, { id: speakerTypeFacet.id, force: false });
+            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+                GET_FACET_WITH_VALUES,
+                {
+                    id: speakerTypeFacet.id,
+                },
+            );
+
+            expect(result1.deleteFacet).toEqual({
+                result: DeletionResult.NOT_DELETED,
+                message: `The selected Facet includes FacetValues which are assigned to 1 Product. To delete anyway, set "force: true"`,
+            });
+
+            expect(result2.facet).not.toBe(null);
+        });
+
+        it('deleteFacet that is in use can be force deleted', async () => {
+            const result1 = await client.query(DELETE_FACET, { id: speakerTypeFacet.id, force: true });
+
+            expect(result1.deleteFacet).toEqual({
+                result: DeletionResult.DELETED,
+                message: `The Facet was deleted and its FacetValues were removed from 1 Product`,
+            });
+
+            // FacetValue no longer in the Facet.values array
+            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+                GET_FACET_WITH_VALUES,
+                {
+                    id: speakerTypeFacet.id,
+                },
+            );
+            expect(result2.facet).toBe(null);
+
+            // FacetValue no longer in the Product.facetValues array
+            const result3 = await client.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: products[1].id,
+            });
+            expect(result3.product!.facetValues).toEqual([]);
+        });
+    });
+});
+
+const DELETE_FACET_VALUES = gql`
+    mutation DeleteFacetValue($ids: [ID!]!, $force: Boolean) {
+        deleteFacetValues(ids: $ids, force: $force) {
+            result
+            message
+        }
+    }
+`;
+
+const DELETE_FACET = gql`
+    mutation DeleteFacet($id: ID!, $force: Boolean) {
+        deleteFacet(id: $id, force: $force) {
+            result
+            message
+        }
+    }
+`;
+
+const GET_PRODUCTS_LIST_WITH_VARIANTS = gql`
+    query GetProductListWithVariants {
+        products {
+            items {
+                id
+                name
+                variants {
+                    id
+                    name
+                }
+            }
+            totalItems
+        }
+    }
+`;

+ 140 - 191
server/e2e/order.e2e-spec.ts

@@ -7,6 +7,7 @@ import { PaymentMethodHandler } from '../src/config/payment-method/payment-metho
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 describe('Orders', () => {
     const client = new TestClient();
@@ -65,33 +66,29 @@ describe('Orders', () => {
             firstOrderItemId = result.addItemToOrder.lines[0].id;
         });
 
-        it('addItemToOrder errors with an invalid productVariantId', async () => {
-            try {
-                await client.query(ADD_ITEM_TO_ORDER, {
-                    productVariantId: 'T_999',
-                    quantity: 1,
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`No ProductVariant with the id '999' could be found`),
-                );
-            }
-        });
+        it(
+            'addItemToOrder errors with an invalid productVariantId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(ADD_ITEM_TO_ORDER, {
+                        productVariantId: 'T_999',
+                        quantity: 1,
+                    }),
+                `No ProductVariant with the id '999' could be found`,
+            ),
+        );
 
-        it('addItemToOrder errors with a negative quantity', async () => {
-            try {
-                await client.query(ADD_ITEM_TO_ORDER, {
-                    productVariantId: 'T_999',
-                    quantity: -3,
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`-3 is not a valid quantity for an OrderItem`),
-                );
-            }
-        });
+        it(
+            'addItemToOrder errors with a negative quantity',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(ADD_ITEM_TO_ORDER, {
+                        productVariantId: 'T_999',
+                        quantity: -3,
+                    }),
+                `-3 is not a valid quantity for an OrderItem`,
+            ),
+        );
 
         it('addItemToOrder with an existing productVariantId adds quantity to the existing OrderLine', async () => {
             const result = await client.query(ADD_ITEM_TO_ORDER, {
@@ -113,33 +110,29 @@ describe('Orders', () => {
             expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
         });
 
-        it('adjustItemQuantity errors with a negative quantity', async () => {
-            try {
-                await client.query(ADJUST_ITEM_QUENTITY, {
-                    orderItemId: firstOrderItemId,
-                    quantity: -3,
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`-3 is not a valid quantity for an OrderItem`),
-                );
-            }
-        });
+        it(
+            'adjustItemQuantity errors with a negative quantity',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(ADJUST_ITEM_QUENTITY, {
+                        orderItemId: firstOrderItemId,
+                        quantity: -3,
+                    }),
+                `-3 is not a valid quantity for an OrderItem`,
+            ),
+        );
 
-        it('adjustItemQuantity errors with an invalid orderItemId', async () => {
-            try {
-                await client.query(ADJUST_ITEM_QUENTITY, {
-                    orderItemId: 'T_999',
-                    quantity: 5,
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`This order does not contain an OrderLine with the id 999`),
-                );
-            }
-        });
+        it(
+            'adjustItemQuantity errors with an invalid orderItemId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(ADJUST_ITEM_QUENTITY, {
+                        orderItemId: 'T_999',
+                        quantity: 5,
+                    }),
+                `This order does not contain an OrderLine with the id 999`,
+            ),
+        );
 
         it('removeItemFromOrder removes the correct item', async () => {
             const result1 = await client.query(ADD_ITEM_TO_ORDER, {
@@ -156,18 +149,16 @@ describe('Orders', () => {
             expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
         });
 
-        it('removeItemFromOrder errors with an invalid orderItemId', async () => {
-            try {
-                await client.query(REMOVE_ITEM_FROM_ORDER, {
-                    orderItemId: 'T_999',
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`This order does not contain an OrderLine with the id 999`),
-                );
-            }
-        });
+        it(
+            'removeItemFromOrder errors with an invalid orderItemId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query(REMOVE_ITEM_FROM_ORDER, {
+                        orderItemId: 'T_999',
+                    }),
+                `This order does not contain an OrderLine with the id 999`,
+            ),
+        );
 
         it('nextOrderStates returns next valid states', async () => {
             const result = await client.query(gql`
@@ -179,29 +170,21 @@ describe('Orders', () => {
             expect(result.nextOrderStates).toEqual(['ArrangingPayment']);
         });
 
-        it('transitionOrderToState throws for an invalid state', async () => {
-            try {
-                await client.query(TRANSITION_TO_STATE, { state: 'Completed' });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`Cannot transition Order from "AddingItems" to "Completed"`),
-                );
-            }
-        });
+        it(
+            'transitionOrderToState throws for an invalid state',
+            assertThrowsWithMessage(
+                () => client.query(TRANSITION_TO_STATE, { state: 'Completed' }),
+                `Cannot transition Order from "AddingItems" to "Completed"`,
+            ),
+        );
 
-        it('attempting to transition to ArrangingPayment throws when Order has no Customer', async () => {
-            try {
-                await client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(
-                        `Cannot transition Order to the "ArrangingShipping" state without Customer details`,
-                    ),
-                );
-            }
-        });
+        it(
+            'attempting to transition to ArrangingPayment throws when Order has no Customer',
+            assertThrowsWithMessage(
+                () => client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }),
+                `Cannot transition Order to the "ArrangingShipping" state without Customer details`,
+            ),
+        );
 
         it('setCustomerForOrder creates a new Customer and associates it with the Order', async () => {
             const result = await client.query(SET_CUSTOMER, {
@@ -342,23 +325,19 @@ describe('Orders', () => {
         describe('shipping', () => {
             let shippingMethods: any;
 
-            it('setOrderShippingAddress throws with invalid countryCode', async () => {
-                const address: CreateAddressInput = {
-                    streetLine1: '12 the street',
-                    countryCode: 'INVALID',
-                };
+            it(
+                'setOrderShippingAddress throws with invalid countryCode',
+                assertThrowsWithMessage(() => {
+                    const address: CreateAddressInput = {
+                        streetLine1: '12 the street',
+                        countryCode: 'INVALID',
+                    };
 
-                try {
-                    await client.query(SET_SHIPPING_ADDRESS, {
+                    return client.query(SET_SHIPPING_ADDRESS, {
                         input: address,
                     });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(`The countryCode "INVALID" was not recognized`),
-                    );
-                }
-            });
+                }, `The countryCode "INVALID" was not recognized`),
+            );
 
             it('setOrderShippingAddress sets shipping address', async () => {
                 const address: CreateAddressInput = {
@@ -438,23 +417,19 @@ describe('Orders', () => {
         });
 
         describe('payment', () => {
-            it('attempting add a Payment throws error when in AddingItems state', async () => {
-                try {
-                    await client.query(ADD_PAYMENT, {
-                        input: {
-                            method: testPaymentMethod.code,
-                            metadata: {},
-                        },
-                    });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(
-                            `A Payment may only be added when Order is in "ArrangingPayment" state`,
-                        ),
-                    );
-                }
-            });
+            it(
+                'attempting add a Payment throws error when in AddingItems state',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query(ADD_PAYMENT, {
+                            input: {
+                                method: testPaymentMethod.code,
+                                metadata: {},
+                            },
+                        }),
+                    `A Payment may only be added when Order is in "ArrangingPayment" state`,
+                ),
+            );
 
             it('transitions to the ArrangingPayment state', async () => {
                 const result = await client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
@@ -464,70 +439,51 @@ describe('Orders', () => {
                 });
             });
 
-            it('attempting to add an item throws error when in ArrangingPayment state', async () => {
-                try {
-                    const result = await client.query(ADD_ITEM_TO_ORDER, {
-                        productVariantId: 'T_4',
-                        quantity: 1,
-                    });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(
-                            `Order contents may only be modified when in the "AddingItems" state`,
-                        ),
-                    );
-                }
-            });
-
-            it('attempting to modify item quantity throws error when in ArrangingPayment state', async () => {
-                try {
-                    const result = await client.query(ADJUST_ITEM_QUENTITY, {
-                        orderItemId: activeOrder.lines[0].id,
-                        quantity: 12,
-                    });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(
-                            `Order contents may only be modified when in the "AddingItems" state`,
-                        ),
-                    );
-                }
-            });
+            it(
+                'attempting to add an item throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query(ADD_ITEM_TO_ORDER, {
+                            productVariantId: 'T_4',
+                            quantity: 1,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
 
-            it('attempting to remove an item throws error when in ArrangingPayment state', async () => {
-                try {
-                    const result = await client.query(REMOVE_ITEM_FROM_ORDER, {
-                        orderItemId: activeOrder.lines[0].id,
-                    });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(
-                            `Order contents may only be modified when in the "AddingItems" state`,
-                        ),
-                    );
-                }
-            });
+            it(
+                'attempting to modify item quantity throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query(ADJUST_ITEM_QUENTITY, {
+                            orderItemId: activeOrder.lines[0].id,
+                            quantity: 12,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
 
-            it('attempting to setOrderShippingMethod throws error when in ArrangingPayment state', async () => {
-                const shippingMethodsResult = await client.query(GET_ELIGIBLE_SHIPPING_METHODS);
-                const shippingMethods = shippingMethodsResult.eligibleShippingMethods;
+            it(
+                'attempting to remove an item throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query(REMOVE_ITEM_FROM_ORDER, {
+                            orderItemId: activeOrder.lines[0].id,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
 
-                try {
-                    await client.query(SET_SHIPPING_METHOD, {
+            it(
+                'attempting to setOrderShippingMethod throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(async () => {
+                    const shippingMethodsResult = await client.query(GET_ELIGIBLE_SHIPPING_METHODS);
+                    const shippingMethods = shippingMethodsResult.eligibleShippingMethods;
+                    return client.query(SET_SHIPPING_METHOD, {
                         id: shippingMethods[0].id,
                     });
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(
-                            `Order contents may only be modified when in the "AddingItems" state`,
-                        ),
-                    );
-                }
-            });
+                }, `Order contents may only be modified when in the "AddingItems" state`),
+            );
 
             it('adds a declined payment', async () => {
                 const result = await client.query(ADD_PAYMENT, {
@@ -591,23 +547,16 @@ describe('Orders', () => {
                     expect(result.orderByCode.id).toBe(activeOrder.id);
                 });
 
-                it(`throws error for another user's Order`, async () => {
-                    authenticatedUserEmailAddress = customers[1].emailAddress;
-                    await client.asUserWithCredentials(authenticatedUserEmailAddress, password);
-
-                    try {
-                        await client.query(GET_ORDER_BY_CODE, {
+                it(
+                    `throws error for another user's Order`,
+                    assertThrowsWithMessage(async () => {
+                        authenticatedUserEmailAddress = customers[1].emailAddress;
+                        await client.asUserWithCredentials(authenticatedUserEmailAddress, password);
+                        return client.query(GET_ORDER_BY_CODE, {
                             code: activeOrder.code,
                         });
-                        fail('Should have thrown');
-                    } catch (err) {
-                        expect(err.message).toEqual(
-                            expect.stringContaining(
-                                `You are not currently authorized to perform this action`,
-                            ),
-                        );
-                    }
-                });
+                    }, `You are not currently authorized to perform this action`),
+                );
             });
         });
     });

+ 33 - 36
server/e2e/product-category.e2e-spec.ts

@@ -22,6 +22,7 @@ import { ROOT_CATEGORY_NAME } from '../../shared/shared-constants';
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 describe('ProductCategory resolver', () => {
     const client = new TestClient();
@@ -223,45 +224,41 @@ describe('ProductCategory resolver', () => {
             expect(afterResult.map(i => i.id)).toEqual([laptopsCategory.id, appleCategory.id]);
         });
 
-        it('throws if attempting to move into self', async () => {
-            try {
-                await client.query<MoveProductCategory.Mutation, MoveProductCategory.Variables>(
-                    MOVE_PRODUCT_CATEGORY,
-                    {
-                        input: {
-                            categoryId: appleCategory.id,
-                            parentId: appleCategory.id,
-                            index: 0,
+        it(
+            'throws if attempting to move into self',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<MoveProductCategory.Mutation, MoveProductCategory.Variables>(
+                        MOVE_PRODUCT_CATEGORY,
+                        {
+                            input: {
+                                categoryId: appleCategory.id,
+                                parentId: appleCategory.id,
+                                index: 0,
+                            },
                         },
-                    },
-                );
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`Cannot move a ProductCategory into itself`),
-                );
-            }
-        });
+                    ),
+                `Cannot move a ProductCategory into itself`,
+            ),
+        );
 
-        it('throws if attempting to move into a decendant of self', async () => {
-            try {
-                await client.query<MoveProductCategory.Mutation, MoveProductCategory.Variables>(
-                    MOVE_PRODUCT_CATEGORY,
-                    {
-                        input: {
-                            categoryId: appleCategory.id,
-                            parentId: appleCategory.id,
-                            index: 0,
+        it(
+            'throws if attempting to move into a decendant of self',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<MoveProductCategory.Mutation, MoveProductCategory.Variables>(
+                        MOVE_PRODUCT_CATEGORY,
+                        {
+                            input: {
+                                categoryId: appleCategory.id,
+                                parentId: appleCategory.id,
+                                index: 0,
+                            },
                         },
-                    },
-                );
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`Cannot move a ProductCategory into itself`),
-                );
-            }
-        });
+                    ),
+                `Cannot move a ProductCategory into itself`,
+            ),
+        );
 
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
             const result = await client.query(GET_CATEGORIES);

+ 231 - 141
server/e2e/product.e2e-spec.ts

@@ -1,6 +1,20 @@
+import gql from 'graphql-tag';
+
+import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
+    CREATE_PRODUCT,
+    GENERATE_PRODUCT_VARIANTS,
+    GET_ASSET_LIST,
+    GET_PRODUCT_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    REMOVE_OPTION_GROUP_FROM_PRODUCT,
+    UPDATE_PRODUCT,
+    UPDATE_PRODUCT_VARIANTS,
+} from '../../admin-ui/src/app/data/definitions/product-definitions';
 import {
     AddOptionGroupToProduct,
     CreateProduct,
+    DeletionResult,
     GenerateProductVariants,
     GetAssetList,
     GetProductList,
@@ -14,21 +28,10 @@ import {
 } from '../../shared/generated-types';
 import { omit } from '../../shared/omit';
 
-import {
-    ADD_OPTION_GROUP_TO_PRODUCT,
-    CREATE_PRODUCT,
-    GENERATE_PRODUCT_VARIANTS,
-    GET_ASSET_LIST,
-    GET_PRODUCT_LIST,
-    GET_PRODUCT_WITH_VARIANTS,
-    REMOVE_OPTION_GROUP_FROM_PRODUCT,
-    UPDATE_PRODUCT,
-    UPDATE_PRODUCT_VARIANTS,
-} from '../../admin-ui/src/app/data/definitions/product-definitions';
-
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
@@ -324,34 +327,32 @@ describe('Product resolver', () => {
             expect(result.updateProduct.facetValues.length).toEqual(1);
         });
 
-        it('updateProduct errors with an invalid productId', async () => {
-            try {
-                await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
-                    input: {
-                        id: '999',
-                        translations: [
-                            {
-                                languageCode: LanguageCode.en,
-                                name: 'en Mashed Potato',
-                                slug: 'en-mashed-potato',
-                                description: 'A blob of mashed potato',
-                            },
-                            {
-                                languageCode: LanguageCode.de,
-                                name: 'de Mashed Potato',
-                                slug: 'de-mashed-potato',
-                                description: 'Eine blob von gemashed Erdapfel',
-                            },
-                        ],
-                    },
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`No Product with the id '999' could be found`),
-                );
-            }
-        });
+        it(
+            'updateProduct errors with an invalid productId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                        input: {
+                            id: '999',
+                            translations: [
+                                {
+                                    languageCode: LanguageCode.en,
+                                    name: 'en Mashed Potato',
+                                    slug: 'en-mashed-potato',
+                                    description: 'A blob of mashed potato',
+                                },
+                                {
+                                    languageCode: LanguageCode.de,
+                                    name: 'de Mashed Potato',
+                                    slug: 'de-mashed-potato',
+                                    description: 'Eine blob von gemashed Erdapfel',
+                                },
+                            ],
+                        },
+                    }),
+                `No Product with the id '999' could be found`,
+            ),
+        );
 
         it('addOptionGroupToProduct adds an option group', async () => {
             const result = await client.query<
@@ -365,39 +366,35 @@ describe('Product resolver', () => {
             expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_1');
         });
 
-        it('addOptionGroupToProduct errors with an invalid productId', async () => {
-            try {
-                await client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
-                    ADD_OPTION_GROUP_TO_PRODUCT,
-                    {
-                        optionGroupId: 'T_1',
-                        productId: '999',
-                    },
-                );
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`No Product with the id '999' could be found`),
-                );
-            }
-        });
-
-        it('addOptionGroupToProduct errors with an invalid optionGroupId', async () => {
-            try {
-                await client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
-                    ADD_OPTION_GROUP_TO_PRODUCT,
-                    {
-                        optionGroupId: '999',
-                        productId: newProduct.id,
-                    },
-                );
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`No ProductOptionGroup with the id '999' could be found`),
-                );
-            }
-        });
+        it(
+            'addOptionGroupToProduct errors with an invalid productId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+                        ADD_OPTION_GROUP_TO_PRODUCT,
+                        {
+                            optionGroupId: 'T_1',
+                            productId: '999',
+                        },
+                    ),
+                `No Product with the id '999' could be found`,
+            ),
+        );
+
+        it(
+            'addOptionGroupToProduct errors with an invalid optionGroupId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+                        ADD_OPTION_GROUP_TO_PRODUCT,
+                        {
+                            optionGroupId: '999',
+                            productId: newProduct.id,
+                        },
+                    ),
+                `No ProductOptionGroup with the id '999' could be found`,
+            ),
+        );
 
         it('removeOptionGroupFromProduct removes an option group', async () => {
             const result = await client.query<
@@ -410,58 +407,52 @@ describe('Product resolver', () => {
             expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
         });
 
-        it('removeOptionGroupFromProduct errors with an invalid productId', async () => {
-            try {
-                await client.query<
-                    RemoveOptionGroupFromProduct.Mutation,
-                    RemoveOptionGroupFromProduct.Variables
-                >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
-                    optionGroupId: '1',
-                    productId: '999',
-                });
-                fail('Should have thrown');
-            } catch (err) {
-                expect(err.message).toEqual(
-                    expect.stringContaining(`No Product with the id '999' could be found`),
-                );
-            }
-        });
+        it(
+            'removeOptionGroupFromProduct errors with an invalid productId',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: '1',
+                        productId: '999',
+                    }),
+                `No Product with the id '999' could be found`,
+            ),
+        );
 
         describe('variants', () => {
             let variants: ProductWithVariants.Variants[];
 
-            it('generateVariantsForProduct throws with an invalid productId', async () => {
-                try {
-                    await client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                        GENERATE_PRODUCT_VARIANTS,
-                        {
-                            productId: '999',
-                        },
-                    );
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(`No Product with the id '999' could be found`),
-                    );
-                }
-            });
+            it(
+                'generateVariantsForProduct throws with an invalid productId',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
+                            GENERATE_PRODUCT_VARIANTS,
+                            {
+                                productId: '999',
+                            },
+                        ),
+                    `No Product with the id '999' could be found`,
+                ),
+            );
 
-            it('generateVariantsForProduct throws with an invalid defaultTaxCategoryId', async () => {
-                try {
-                    await client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
-                        GENERATE_PRODUCT_VARIANTS,
-                        {
-                            productId: newProduct.id,
-                            defaultTaxCategoryId: '999',
-                        },
-                    );
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(`No TaxCategory with the id '999' could be found`),
-                    );
-                }
-            });
+            it(
+                'generateVariantsForProduct throws with an invalid defaultTaxCategoryId',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
+                            GENERATE_PRODUCT_VARIANTS,
+                            {
+                                productId: newProduct.id,
+                                defaultTaxCategoryId: '999',
+                            },
+                        ),
+                    `No TaxCategory with the id '999' could be found`,
+                ),
+            );
 
             it('generateVariantsForProduct generates variants', async () => {
                 const result = await client.query<
@@ -570,28 +561,127 @@ describe('Product resolver', () => {
                 expect(updatedVariant.facetValues[0].id).toBe('T_1');
             });
 
-            it('updateProductVariants throws with an invalid variant id', async () => {
-                try {
-                    await client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
-                        UPDATE_PRODUCT_VARIANTS,
+            it(
+                'updateProductVariants throws with an invalid variant id',
+                assertThrowsWithMessage(
+                    () =>
+                        client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                            UPDATE_PRODUCT_VARIANTS,
+                            {
+                                input: [
+                                    {
+                                        id: 'T_999',
+                                        translations: variants[0].translations,
+                                        sku: 'ABC',
+                                        price: 432,
+                                    },
+                                ],
+                            },
+                        ),
+                    `No ProductVariant with the id '999' could be found`,
+                ),
+            );
+        });
+    });
+
+    describe('deletion', () => {
+        let allProducts: GetProductList.Items[];
+        let productToDelete: GetProductList.Items;
+
+        beforeAll(async () => {
+            const result = await client.query<GetProductList.Query>(GET_PRODUCT_LIST);
+            allProducts = result.products.items;
+        });
+
+        it('deletes a product', async () => {
+            productToDelete = allProducts[0];
+            const result = await client.query(DELETE_PRODUCT, { id: productToDelete.id });
+
+            expect(result.deleteProduct).toEqual({ result: DeletionResult.DELETED });
+        });
+
+        it('cannot get a deleted product', async () => {
+            const result = await client.query<GetProductWithVariants.Query, GetProductWithVariants.Variables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    id: productToDelete.id,
+                },
+            );
+
+            expect(result.product).toBe(null);
+        });
+
+        it('deleted product omitted from list', async () => {
+            const result = await client.query<GetProductList.Query>(GET_PRODUCT_LIST);
+
+            expect(result.products.items.length).toBe(allProducts.length - 1);
+            expect(result.products.items.map(c => c.id).includes(productToDelete.id)).toBe(false);
+        });
+
+        it(
+            'updateProduct throws for deleted product',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                        input: {
+                            id: productToDelete.id,
+                            facetValueIds: ['T_1'],
+                        },
+                    }),
+                `No Product with the id '1' could be found`,
+            ),
+        );
+
+        it(
+            'addOptionGroupToProduct throws for deleted product',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+                        ADD_OPTION_GROUP_TO_PRODUCT,
                         {
-                            input: [
-                                {
-                                    id: 'T_999',
-                                    translations: variants[0].translations,
-                                    sku: 'ABC',
-                                    price: 432,
-                                },
-                            ],
+                            optionGroupId: 'T_1',
+                            productId: productToDelete.id,
                         },
-                    );
-                    fail('Should have thrown');
-                } catch (err) {
-                    expect(err.message).toEqual(
-                        expect.stringContaining(`No ProductVariant with the id '999' could be found`),
-                    );
-                }
-            });
-        });
+                    ),
+                `No Product with the id '1' could be found`,
+            ),
+        );
+
+        it(
+            'removeOptionGroupToProduct throws for deleted product',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<
+                        RemoveOptionGroupFromProduct.Mutation,
+                        RemoveOptionGroupFromProduct.Variables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: 'T_1',
+                        productId: productToDelete.id,
+                    }),
+                `No Product with the id '1' could be found`,
+            ),
+        );
+
+        it(
+            'generateVariantsForProduct throws for deleted product',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
+                        GENERATE_PRODUCT_VARIANTS,
+                        {
+                            productId: productToDelete.id,
+                        },
+                    ),
+                `No Product with the id '1' could be found`,
+            ),
+        );
     });
 });
+
+const DELETE_PRODUCT = gql`
+    mutation DeleteProduct($id: ID!) {
+        deleteProduct(id: $id) {
+            result
+        }
+    }
+`;

+ 67 - 10
server/e2e/promotion.e2e-spec.ts

@@ -1,5 +1,15 @@
+import gql from 'graphql-tag';
+
+import {
+    CREATE_PROMOTION,
+    GET_ADJUSTMENT_OPERATIONS,
+    GET_PROMOTION,
+    GET_PROMOTION_LIST,
+    UPDATE_PROMOTION,
+} from '../../admin-ui/src/app/data/definitions/promotion-definitions';
 import {
     CreatePromotion,
+    DeletionResult,
     GetAdjustmentOperations,
     GetPromotion,
     GetPromotionList,
@@ -7,20 +17,13 @@ import {
     UpdatePromotion,
 } from '../../shared/generated-types';
 import { pick } from '../../shared/pick';
-
-import {
-    CREATE_PROMOTION,
-    GET_ADJUSTMENT_OPERATIONS,
-    GET_PROMOTION,
-    GET_PROMOTION_LIST,
-    UPDATE_PROMOTION,
-} from '../../admin-ui/src/app/data/definitions/promotion-definitions';
 import { PromotionAction, PromotionOrderAction } from '../src/config/promotion/promotion-action';
 import { PromotionCondition } from '../src/config/promotion/promotion-condition';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
@@ -39,7 +42,7 @@ describe('Promotion resolver', () => {
     let promotion: Promotion.Fragment;
 
     beforeAll(async () => {
-        const token = await server.init(
+        await server.init(
             {
                 productCount: 1,
                 customerCount: 1,
@@ -58,7 +61,7 @@ describe('Promotion resolver', () => {
         await server.destroy();
     });
 
-    it('createPromotion promotion', async () => {
+    it('createPromotion', async () => {
         const result = await client.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
             CREATE_PROMOTION,
             {
@@ -131,6 +134,52 @@ describe('Promotion resolver', () => {
 
         expect(result.adjustmentOperations).toMatchSnapshot();
     });
+
+    describe('deletion', () => {
+        let allPromotions: GetPromotionList.Items[];
+        let promotionToDelete: GetPromotionList.Items;
+
+        beforeAll(async () => {
+            const result = await client.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+            allPromotions = result.promotions.items;
+        });
+
+        it('deletes a promotion', async () => {
+            promotionToDelete = allPromotions[0];
+            const result = await client.query(DELETE_PROMOTION, { id: promotionToDelete.id });
+
+            expect(result.deletePromotion).toEqual({ result: DeletionResult.DELETED });
+        });
+
+        it('cannot get a deleted promotion', async () => {
+            const result = await client.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
+                id: promotionToDelete.id,
+            });
+
+            expect(result.promotion).toBe(null);
+        });
+
+        it('deleted promotion omitted from list', async () => {
+            const result = await client.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+
+            expect(result.promotions.items.length).toBe(allPromotions.length - 1);
+            expect(result.promotions.items.map(c => c.id).includes(promotionToDelete.id)).toBe(false);
+        });
+
+        it(
+            'updatePromotion throws for deleted promotion',
+            assertThrowsWithMessage(
+                () =>
+                    client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
+                        input: {
+                            id: promotionToDelete.id,
+                            enabled: false,
+                        },
+                    }),
+                `No Promotion with the id '1' could be found`,
+            ),
+        );
+    });
 });
 
 function generateTestCondition(code: string): PromotionCondition<any> {
@@ -152,3 +201,11 @@ function generateTestAction(code: string): PromotionAction<any> {
         },
     });
 }
+
+const DELETE_PROMOTION = gql`
+    mutation DeletePromotion($id: ID!) {
+        deletePromotion(id: $id) {
+            result
+        }
+    }
+`;

+ 24 - 31
server/e2e/role.e2e-spec.ts

@@ -12,6 +12,7 @@ import {
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
 
 describe('Role resolver', () => {
     const client = new TestClient();
@@ -87,14 +88,15 @@ describe('Role resolver', () => {
         ]);
     });
 
-    it('updateRole is not allowed for SuperAdmin role', async () => {
-        const superAdminRole = defaultRoles.find(r => r.code === SUPER_ADMIN_ROLE_CODE);
-        if (!superAdminRole) {
-            fail(`Could not find SuperAdmin role`);
-            return;
-        }
-        try {
-            const result = await client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+    it(
+        'updateRole is not allowed for SuperAdmin role',
+        assertThrowsWithMessage(async () => {
+            const superAdminRole = defaultRoles.find(r => r.code === SUPER_ADMIN_ROLE_CODE);
+            if (!superAdminRole) {
+                fail(`Could not find SuperAdmin role`);
+                return;
+            }
+            return client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
                 input: {
                     id: superAdminRole.id,
                     code: 'superadmin-modified',
@@ -102,22 +104,18 @@ describe('Role resolver', () => {
                     permissions: [Permission.Authenticated],
                 },
             });
-            fail(`Should throw`);
-        } catch (err) {
-            expect(err.message).toEqual(
-                expect.stringContaining(`The role '${SUPER_ADMIN_ROLE_CODE}' cannot be modified`),
-            );
-        }
-    });
-
-    it('updateRole is not allowed for Customer role', async () => {
-        const customerRole = defaultRoles.find(r => r.code === CUSTOMER_ROLE_CODE);
-        if (!customerRole) {
-            fail(`Could not find Customer role`);
-            return;
-        }
-        try {
-            const result = await client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+        }, `The role '${SUPER_ADMIN_ROLE_CODE}' cannot be modified`),
+    );
+
+    it(
+        'updateRole is not allowed for Customer role',
+        assertThrowsWithMessage(async () => {
+            const customerRole = defaultRoles.find(r => r.code === CUSTOMER_ROLE_CODE);
+            if (!customerRole) {
+                fail(`Could not find Customer role`);
+                return;
+            }
+            return client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
                 input: {
                     id: customerRole.id,
                     code: 'customer-modified',
@@ -125,11 +123,6 @@ describe('Role resolver', () => {
                     permissions: [Permission.Authenticated, Permission.DeleteAdministrator],
                 },
             });
-            fail(`Should throw`);
-        } catch (err) {
-            expect(err.message).toEqual(
-                expect.stringContaining(`The role '${CUSTOMER_ROLE_CODE}' cannot be modified`),
-            );
-        }
-    });
+        }, `The role '${CUSTOMER_ROLE_CODE}' cannot be modified`),
+    );
 });

+ 14 - 0
server/e2e/test-utils.ts

@@ -7,3 +7,17 @@ export function setTestEnvironment() {
 export function isTestEnvironment() {
     return !!process.env[E2E_TESTING_ENV_VARIABLE];
 }
+
+/**
+ * Helper method for creating tests which assert a given error message when the operation is attempted.
+ */
+export function assertThrowsWithMessage(operation: () => Promise<any>, message: string) {
+    return async () => {
+        try {
+            await operation();
+            fail('Should have thrown');
+        } catch (err) {
+            expect(err.message).toEqual(expect.stringContaining(message));
+        }
+    };
+}

+ 162 - 0
server/e2e/zone.e2e-spec.ts

@@ -0,0 +1,162 @@
+import gql from 'graphql-tag';
+
+import {
+    ADD_MEMBERS_TO_ZONE,
+    CREATE_ZONE,
+    GET_COUNTRY_LIST,
+    GET_ZONE,
+    REMOVE_MEMBERS_FROM_ZONE,
+    UPDATE_ZONE,
+} from '../../admin-ui/src/app/data/definitions/settings-definitions';
+import {
+    AddMembersToZone,
+    CreateZone,
+    DeletionResult,
+    GetCountryList,
+    GetZone,
+    RemoveMembersFromZone,
+    UpdateZone,
+} from '../../shared/generated-types';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+// tslint:disable:no-non-null-assertion
+
+describe('Facet resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+    let countries: GetCountryList.Items[];
+    let zones: Array<{ id: string; name: string }>;
+    let oceania: { id: string; name: string };
+    let pangaea: { id: string; name: string; members: any[] };
+
+    beforeAll(async () => {
+        await server.init({
+            productCount: 2,
+            customerCount: 1,
+        });
+        await client.init();
+
+        const result = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+        countries = result.countries.items;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('zones', async () => {
+        const result = await client.query(GET_ZONE_LIST);
+
+        expect(result.zones.length).toBe(6);
+        zones = result.zones;
+        oceania = zones[0];
+    });
+
+    it('zone', async () => {
+        const result = await client.query<GetZone.Query, GetZone.Variables>(GET_ZONE, {
+            id: zones[0].id,
+        });
+
+        expect(result.zone!.name).toBe('Oceania');
+    });
+
+    it('updateZone', async () => {
+        const result = await client.query<UpdateZone.Mutation, UpdateZone.Variables>(UPDATE_ZONE, {
+            input: {
+                id: oceania.id,
+                name: 'oceania2',
+            },
+        });
+
+        expect(result.updateZone.name).toBe('oceania2');
+    });
+
+    it('createZone', async () => {
+        const result = await client.query<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
+            input: {
+                name: 'Pangaea',
+                memberIds: [countries[0].id, countries[1].id],
+            },
+        });
+
+        pangaea = result.createZone;
+        expect(pangaea.name).toBe('Pangaea');
+        expect(pangaea.members.map(m => m.name)).toEqual([countries[0].name, countries[1].name]);
+    });
+
+    it('addMembersToZone', async () => {
+        const result = await client.query<AddMembersToZone.Mutation, AddMembersToZone.Variables>(
+            ADD_MEMBERS_TO_ZONE,
+            {
+                zoneId: oceania.id,
+                memberIds: [countries[2].id, countries[3].id],
+            },
+        );
+
+        expect(result.addMembersToZone.members.map(m => m.name)).toEqual([
+            countries[0].name,
+            countries[2].name,
+            countries[3].name,
+        ]);
+    });
+
+    it('removeMembersFromZone', async () => {
+        const result = await client.query<RemoveMembersFromZone.Mutation, RemoveMembersFromZone.Variables>(
+            REMOVE_MEMBERS_FROM_ZONE,
+            {
+                zoneId: oceania.id,
+                memberIds: [countries[0].id, countries[2].id],
+            },
+        );
+
+        expect(result.removeMembersFromZone.members.map(m => m.name)).toEqual([countries[3].name]);
+    });
+
+    describe('deletion', () => {
+        it('deletes Zone not used in any TaxRate', async () => {
+            const result1 = await client.query(DELETE_ZONE, { id: pangaea.id });
+
+            expect(result1.deleteZone).toEqual({
+                result: DeletionResult.DELETED,
+                message: '',
+            });
+
+            const result2 = await client.query(GET_ZONE_LIST, {});
+            expect(result2.zones.find(c => c.id === pangaea.id)).toBeUndefined();
+        });
+
+        it('does not delete Zone that is used in one or more TaxRates', async () => {
+            const result1 = await client.query(DELETE_ZONE, { id: oceania.id });
+
+            expect(result1.deleteZone).toEqual({
+                result: DeletionResult.NOT_DELETED,
+                message:
+                    'The selected Zone cannot be deleted as it is used in the following TaxRates: Standard Tax for Oceania',
+            });
+
+            const result2 = await client.query(GET_ZONE_LIST, {});
+            expect(result2.zones.find(c => c.id === oceania.id)).not.toBeUndefined();
+        });
+    });
+});
+
+const DELETE_ZONE = gql`
+    mutation DeleteZone($id: ID!) {
+        deleteZone(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+const GET_ZONE_LIST = gql`
+    query GetZones {
+        zones {
+            id
+            name
+        }
+    }
+`;

+ 1 - 1
server/mock-data/mock-data.service.ts

@@ -435,7 +435,7 @@ export class MockDataService {
         return Array.from({ length: count }).map(() => {
             const brand = faker.company.companyName();
             return {
-                code: brand.replace(/\s/g, '_'),
+                code: brand.toLowerCase().replace(/[^a-zA-Z]+/g, '-'),
                 translations: [
                     {
                         languageCode: LanguageCode.en,

+ 2 - 0
server/src/api/common/request-context.service.ts

@@ -38,12 +38,14 @@ export class RequestContextService {
         const user = session && (session as AuthenticatedSession).user;
         const isAuthorized = this.userHasRequiredPermissionsOnChannel(requiredPermissions, channel, user);
         const authorizedAsOwnerOnly = !isAuthorized && hasOwnerPermission;
+        const translationFn = (req as any).t;
         return new RequestContext({
             channel,
             languageCode,
             session,
             isAuthorized,
             authorizedAsOwnerOnly,
+            translationFn,
         });
     }
 

+ 17 - 3
server/src/api/common/request-context.ts

@@ -1,11 +1,11 @@
+import i18next from 'i18next';
+
 import { LanguageCode } from '../../../../shared/generated-types';
 import { ID } from '../../../../shared/shared-types';
-
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { Channel } from '../../entity/channel/channel.entity';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
-import { Zone } from '../../entity/zone/zone.entity';
 
 /**
  * The RequestContext is intended to hold information relevant to the current request, which may be
@@ -17,6 +17,7 @@ export class RequestContext {
     private readonly _session?: Session;
     private readonly _isAuthorized: boolean;
     private readonly _authorizedAsOwnerOnly: boolean;
+    private readonly _translationFn: i18next.TranslationFunction;
 
     constructor(options: {
         channel: Channel;
@@ -24,14 +25,16 @@ export class RequestContext {
         languageCode?: LanguageCode;
         isAuthorized: boolean;
         authorizedAsOwnerOnly: boolean;
+        translationFn?: i18next.TranslationFunction;
     }) {
-        const { channel, session, languageCode } = options;
+        const { channel, session, languageCode, translationFn } = options;
         this._channel = channel;
         this._session = session;
         this._languageCode =
             languageCode || (channel && channel.defaultLanguageCode) || DEFAULT_LANGUAGE_CODE;
         this._isAuthorized = options.isAuthorized;
         this._authorizedAsOwnerOnly = options.authorizedAsOwnerOnly;
+        this._translationFn = translationFn || (((key: string) => key) as any);
     }
 
     get channel(): Channel {
@@ -73,6 +76,17 @@ export class RequestContext {
         return this._authorizedAsOwnerOnly;
     }
 
+    /**
+     * Translate the given i18n key
+     */
+    translate(key: string, variables?: { [k: string]: any }): string {
+        try {
+            return this._translationFn(key, variables);
+        } catch (e) {
+            return `Translation format error: ${e.message}). Original key: ${key}`;
+        }
+    }
+
     private isAuthenticatedSession(session: Session): session is AuthenticatedSession {
         return session.hasOwnProperty('user');
     }

+ 11 - 0
server/src/api/resolvers/country.resolver.ts

@@ -4,6 +4,8 @@ import {
     CountriesQueryArgs,
     CountryQueryArgs,
     CreateCountryMutationArgs,
+    DeleteCountryMutationArgs,
+    DeletionResponse,
     Permission,
     UpdateCountryMutationArgs,
 } from '../../../../shared/generated-types';
@@ -73,4 +75,13 @@ export class CountryResolver {
     ): Promise<Translated<Country>> {
         return this.countryService.update(ctx, args.input);
     }
+
+    @Mutation()
+    @Allow(Permission.DeleteSettings)
+    async deleteCountry(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteCountryMutationArgs,
+    ): Promise<DeletionResponse> {
+        return this.countryService.delete(ctx, args.id);
+    }
 }

+ 14 - 3
server/src/api/resolvers/customer.resolver.ts

@@ -5,6 +5,8 @@ import {
     CreateCustomerMutationArgs,
     CustomerQueryArgs,
     CustomersQueryArgs,
+    DeleteCustomerMutationArgs,
+    DeletionResponse,
     OrdersCustomerArgs,
     Permission,
     UpdateCustomerAddressMutationArgs,
@@ -58,7 +60,7 @@ export class CustomerResolver {
     async addresses(@Ctx() ctx: RequestContext, @Parent() customer: Customer): Promise<Address[]> {
         this.checkOwnerPermissions(ctx, customer);
         const customerId = this.idCodecService.decode(customer.id);
-        return this.customerService.findAddressesByCustomerId(customerId);
+        return this.customerService.findAddressesByCustomerId(ctx, customerId);
     }
 
     @ResolveProperty()
@@ -100,9 +102,18 @@ export class CustomerResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCustomer)
-    async updateCustomerAddress(@Args() args: UpdateCustomerAddressMutationArgs): Promise<Address> {
+    async updateCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateCustomerAddressMutationArgs,
+    ): Promise<Address> {
         const { input } = args;
-        return this.customerService.updateAddress(input);
+        return this.customerService.updateAddress(ctx, input);
+    }
+
+    @Mutation()
+    @Allow(Permission.DeleteCustomer)
+    async deleteCustomer(@Args() args: DeleteCustomerMutationArgs): Promise<DeletionResponse> {
+        return this.customerService.softDelete(args.id);
     }
 
     /**

+ 24 - 0
server/src/api/resolvers/facet.resolver.ts

@@ -3,6 +3,9 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     CreateFacetMutationArgs,
     CreateFacetValuesMutationArgs,
+    DeleteFacetMutationArgs,
+    DeleteFacetValuesMutationArgs,
+    DeletionResponse,
     FacetQueryArgs,
     FacetsQueryArgs,
     Permission,
@@ -19,6 +22,7 @@ import { FacetValueService } from '../../service/services/facet-value.service';
 import { FacetService } from '../../service/services/facet.service';
 import { RequestContext } from '../common/request-context';
 import { Allow } from '../decorators/allow.decorator';
+import { Decode } from '../decorators/decode.decorator';
 import { Ctx } from '../decorators/request-context.decorator';
 
 @Resolver('Facet')
@@ -65,8 +69,18 @@ export class FacetResolver {
         return this.facetService.update(args.input);
     }
 
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    async deleteFacet(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteFacetMutationArgs,
+    ): Promise<DeletionResponse> {
+        return this.facetService.delete(ctx, args.id, args.force || false);
+    }
+
     @Mutation()
     @Allow(Permission.CreateCatalog)
+    @Decode('facetId')
     async createFacetValues(
         @Args() args: CreateFacetValuesMutationArgs,
     ): Promise<Array<Translated<FacetValue>>> {
@@ -87,4 +101,14 @@ export class FacetResolver {
         const { input } = args;
         return Promise.all(input.map(facetValue => this.facetValueService.update(facetValue)));
     }
+
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    @Decode('ids')
+    async deleteFacetValues(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteFacetValuesMutationArgs,
+    ): Promise<DeletionResponse[]> {
+        return Promise.all(args.ids.map(id => this.facetValueService.delete(ctx, id, args.force || false)));
+    }
 }

+ 12 - 3
server/src/api/resolvers/product.resolver.ts

@@ -3,6 +3,8 @@ import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestj
 import {
     AddOptionGroupToProductMutationArgs,
     CreateProductMutationArgs,
+    DeleteProductMutationArgs,
+    DeletionResponse,
     GenerateVariantsForProductMutationArgs,
     Permission,
     ProductQueryArgs,
@@ -11,9 +13,7 @@ import {
     UpdateProductMutationArgs,
     UpdateProductVariantsMutationArgs,
 } from '../../../../shared/generated-types';
-import { ID, PaginatedList } from '../../../../shared/shared-types';
-import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
-import { EntityNotFoundError } from '../../common/error/errors';
+import { PaginatedList } from '../../../../shared/shared-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
@@ -85,6 +85,15 @@ export class ProductResolver {
         return this.productService.update(ctx, input);
     }
 
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    async deleteProduct(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteProductMutationArgs,
+    ): Promise<DeletionResponse> {
+        return this.productService.softDelete(args.id);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateCatalog)
     @Decode('productId', 'optionGroupId')

+ 8 - 0
server/src/api/resolvers/promotion.resolver.ts

@@ -2,6 +2,8 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
 import {
     CreatePromotionMutationArgs,
+    DeletePromotionMutationArgs,
+    DeletionResponse,
     Permission,
     PromotionQueryArgs,
     PromotionsQueryArgs,
@@ -57,4 +59,10 @@ export class PromotionResolver {
     ): Promise<Promotion> {
         return this.promotionService.updatePromotion(ctx, args.input);
     }
+
+    @Mutation()
+    @Allow(Permission.DeleteSettings)
+    deletePromotion(@Args() args: DeletePromotionMutationArgs): Promise<DeletionResponse> {
+        return this.promotionService.softDeletePromotion(args.id);
+    }
 }

+ 11 - 0
server/src/api/resolvers/zone.resolver.ts

@@ -3,6 +3,8 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     AddMembersToZoneMutationArgs,
     CreateZoneMutationArgs,
+    DeleteZoneMutationArgs,
+    DeletionResponse,
     Permission,
     RemoveMembersFromZoneMutationArgs,
     UpdateZoneMutationArgs,
@@ -44,6 +46,15 @@ export class ZoneResolver {
         return this.zoneService.update(ctx, args.input);
     }
 
+    @Mutation()
+    @Allow(Permission.DeleteSettings)
+    async deleteZone(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteZoneMutationArgs,
+    ): Promise<DeletionResponse> {
+        return this.zoneService.delete(ctx, args.id);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateSettings)
     @Decode('zoneId', 'memberIds')

+ 4 - 0
server/src/api/types/country.api.graphql

@@ -7,8 +7,12 @@ type Query {
 type Mutation {
     "Create a new Country"
     createCountry(input: CreateCountryInput!): Country!
+
     "Update an existing Country"
     updateCountry(input: UpdateCountryInput!): Country!
+
+    "Delete a Country"
+    deleteCountry(id: ID!): DeletionResponse!
 }
 
 type CountryList implements PaginatedList {

+ 6 - 0
server/src/api/types/customer.api.graphql

@@ -7,10 +7,16 @@ type Query {
 type Mutation {
     "Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer."
     createCustomer(input: CreateCustomerInput!, password: String): Customer!
+
     "Update an existing Customer"
     updateCustomer(input: UpdateCustomerInput!): Customer!
+
+    "Delete a Customer"
+    deleteCustomer(id: ID!): DeletionResponse!
+
     "Create a new Address and associate it with the Customer specified by customerId"
     createCustomerAddress(customerId: ID!, input: CreateAddressInput!): Address!
+
     "Update an existing Address"
     updateCustomerAddress(input: UpdateAddressInput!): Address!
 }

+ 9 - 0
server/src/api/types/facet.api.graphql

@@ -6,12 +6,21 @@ type Query {
 type Mutation {
     "Create a new Facet"
     createFacet(input: CreateFacetInput!): Facet!
+
     "Update an existing Facet"
     updateFacet(input: UpdateFacetInput!): Facet!
+
+    "Delete an existing Facet"
+    deleteFacet(id: ID!, force: Boolean): DeletionResponse!
+
     "Create one or more FacetValues"
     createFacetValues(input: [CreateFacetValueInput!]!): [FacetValue!]!
+
     "Update one or more FacetValues"
     updateFacetValues(input: [UpdateFacetValueInput!]!): [FacetValue!]!
+
+    "Delete one or more FacetValues"
+    deleteFacetValues(ids: [ID!]!, force: Boolean): [DeletionResponse!]!
 }
 
 type FacetList implements PaginatedList {

+ 3 - 0
server/src/api/types/product.api.graphql

@@ -10,6 +10,9 @@ type Mutation {
     "Update an existing Product"
     updateProduct(input: UpdateProductInput!): Product!
 
+    "Delete a Product"
+    deleteProduct(id: ID!): DeletionResponse!
+
     "Add an OptionGroup to a Product"
     addOptionGroupToProduct(productId: ID!, optionGroupId: ID!): Product!
 

+ 1 - 0
server/src/api/types/promotion.api.graphql

@@ -12,6 +12,7 @@ type AdjustmentOperations {
 type Mutation {
     createPromotion(input: CreatePromotionInput!): Promotion!
     updatePromotion(input: UpdatePromotionInput!): Promotion!
+    deletePromotion(id: ID!): DeletionResponse!
 }
 
 type PromotionList implements PaginatedList {

+ 6 - 0
server/src/api/types/zone.api.graphql

@@ -6,10 +6,16 @@ type Query {
 type Mutation {
     "Create a new Zone"
     createZone(input: CreateZoneInput!): Zone!
+
     "Update an existing Zone"
     updateZone(input: UpdateZoneInput!): Zone!
+
+    "Delete a Zone"
+    deleteZone(id: ID!): DeletionResponse!
+
     "Add members to a Zone"
     addMembersToZone(zoneId: ID!, memberIds: [ID!]!): Zone!
+
     "Remove members from a Zone"
     removeMembersFromZone(zoneId: ID!, memberIds: [ID!]!): Zone!
 }

+ 12 - 0
server/src/common/types/common-types.graphql

@@ -32,6 +32,18 @@ type AdjustmentOperation {
     description: String!
 }
 
+enum DeletionResult {
+    "The entity was successfully deleted"
+    DELETED
+    "Deletion did not take place, reason given in message"
+    NOT_DELETED
+}
+
+type DeletionResponse {
+    result: DeletionResult!
+    message: String
+}
+
 input ConfigArgInput {
     name: String!
     value: String!

+ 7 - 0
server/src/common/types/common-types.ts

@@ -10,6 +10,13 @@ export interface ChannelAware {
     channels: Channel[];
 }
 
+/**
+ * Entities which can be soft deleted should implement this interface.
+ */
+export interface SoftDeletable {
+    deletedAt: Date | null;
+}
+
 /**
  * Creates a type based on T, but with all properties non-optional
  * and readonly.

+ 3 - 3
server/src/entity/address/address.entity.ts

@@ -2,6 +2,7 @@ import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial, HasCustomFields } from '../../../../shared/shared-types';
 import { VendureEntity } from '../base/base.entity';
+import { Country } from '../country/country.entity';
 import { CustomAddressFields } from '../custom-entity-fields';
 import { Customer } from '../customer/customer.entity';
 
@@ -31,9 +32,8 @@ export class Address extends VendureEntity implements HasCustomFields {
 
     @Column({ default: '' }) postalCode: string;
 
-    @Column() country: string;
-
-    @Column() countryCode: string;
+    @ManyToOne(type => Country)
+    country: Country;
 
     @Column({ default: '' })
     phoneNumber: string;

+ 1 - 2
server/src/entity/address/address.graphql

@@ -9,8 +9,7 @@ type Address implements Node {
     city: String
     province: String
     postalCode: String
-    country: String!
-    countryCode: String!
+    country: Country!
     phoneNumber: String
     defaultShippingAddress: Boolean
     defaultBillingAddress: Boolean

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

@@ -17,6 +17,6 @@ export class CountryTranslation extends VendureEntity implements Translation<Cou
 
     @Column() name: string;
 
-    @ManyToOne(type => Country, base => base.translations)
+    @ManyToOne(type => Country, base => base.translations, { onDelete: 'CASCADE' })
     base: Country;
 }

+ 5 - 1
server/src/entity/customer/customer.entity.ts

@@ -2,6 +2,7 @@ import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne
 
 import { DeepPartial } from '../../../../shared/shared-types';
 import { HasCustomFields } from '../../../../shared/shared-types';
+import { SoftDeletable } from '../../common/types/common-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
 import { CustomCustomerFields } from '../custom-entity-fields';
@@ -10,11 +11,14 @@ import { Order } from '../order/order.entity';
 import { User } from '../user/user.entity';
 
 @Entity()
-export class Customer extends VendureEntity implements HasCustomFields {
+export class Customer extends VendureEntity implements HasCustomFields, SoftDeletable {
     constructor(input?: DeepPartial<Customer>) {
         super(input);
     }
 
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
+
     @Column({ nullable: true })
     title: string;
 

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

@@ -18,7 +18,7 @@ export class FacetValueTranslation extends VendureEntity implements Translation<
 
     @Column() name: string;
 
-    @ManyToOne(type => FacetValue, base => base.translations)
+    @ManyToOne(type => FacetValue, base => base.translations, { onDelete: 'CASCADE' })
     base: FacetValue;
 
     @Column(type => CustomFacetValueFieldsTranslation)

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

@@ -20,7 +20,7 @@ export class FacetValue extends VendureEntity implements Translatable, HasCustom
     @OneToMany(type => FacetValueTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<FacetValue>>;
 
-    @ManyToOne(type => Facet, group => group.values)
+    @ManyToOne(type => Facet, group => group.values, { onDelete: 'CASCADE' })
     facet: Facet;
 
     @Column(type => CustomFacetValueFields)

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

@@ -18,7 +18,7 @@ export class FacetTranslation extends VendureEntity implements Translation<Facet
 
     @Column() name: string;
 
-    @ManyToOne(type => Facet, base => base.translations)
+    @ManyToOne(type => Facet, base => base.translations, { onDelete: 'CASCADE' })
     base: Facet;
 
     @Column(type => CustomFacetFieldsTranslation)

+ 7 - 2
server/src/entity/product/product.entity.ts

@@ -1,7 +1,7 @@
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { DeepPartial, HasCustomFields } from '../../../../shared/shared-types';
-import { ChannelAware } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
@@ -14,10 +14,15 @@ import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductTranslation } from './product-translation.entity';
 
 @Entity()
-export class Product extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
+export class Product extends VendureEntity
+    implements Translatable, HasCustomFields, ChannelAware, SoftDeletable {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
+
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
+
     name: LocaleString;
 
     slug: LocaleString;

+ 5 - 2
server/src/entity/promotion/promotion.entity.ts

@@ -3,7 +3,7 @@ import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 import { Adjustment, AdjustmentOperation, AdjustmentType } from '../../../../shared/generated-types';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
-import { ChannelAware } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { getConfig } from '../../config/config-helpers';
 import {
     PromotionAction,
@@ -29,7 +29,7 @@ export interface ApplyOrderActionArgs {
 }
 
 @Entity()
-export class Promotion extends AdjustmentSource implements ChannelAware {
+export class Promotion extends AdjustmentSource implements ChannelAware, SoftDeletable {
     type = AdjustmentType.PROMOTION;
     private readonly allConditions: { [code: string]: PromotionCondition } = {};
     private readonly allActions: { [code: string]: PromotionItemAction | PromotionOrderAction } = {};
@@ -49,6 +49,9 @@ export class Promotion extends AdjustmentSource implements ChannelAware {
         this.allActions = actions.reduce((hash, o) => ({ ...hash, [o.code]: o }), {});
     }
 
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
+
     @Column() name: string;
 
     @Column() enabled: boolean;

+ 8 - 0
server/src/i18n/messages/en.json

@@ -21,5 +21,13 @@
     "verification-token-has-expired": "Verification token has expired. Use refreshCustomerVerification to send a new token.",
     "verification-token-not-recognized": "Verification token not recognized",
     "unauthorized": "The credentials did not match. Please check and try again"
+  },
+  "message": {
+    "country-used-in-addresses": "The selected Country cannot be deleted as it is used in {count, plural, one {1 Address} other {# Addresses}}",
+    "facet-force-deleted": "The Facet was deleted and its FacetValues were removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
+    "facet-used": "The selected Facet includes FacetValues which are assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}. To delete anyway, set \"force: true\"",
+    "facet-value-force-deleted": "The selected FacetValue was removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}} and deleted",
+    "facet-value-used": "The selected FacetValue is assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}. To delete anyway, set \"force: true\"",
+    "zone-used-in-tax-rates": "The selected Zone cannot be deleted as it is used in the following TaxRates: { taxRateNames }"
   }
 }

+ 2 - 1
server/src/service/helpers/utils/get-entity-or-throw.ts

@@ -2,6 +2,7 @@ import { Connection, FindOneOptions } from 'typeorm';
 
 import { ID, Type } from '../../../../../shared/shared-types';
 import { EntityNotFoundError } from '../../../common/error/errors';
+import { SoftDeletable } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
 /**
@@ -14,7 +15,7 @@ export async function getEntityOrThrow<T extends VendureEntity>(
     findOptions?: FindOneOptions<T>,
 ): Promise<T> {
     const entity = await connection.getRepository(entityType).findOne(id, findOptions);
-    if (!entity) {
+    if (!entity || (entity.hasOwnProperty('deletedAt') && (entity as T & SoftDeletable).deletedAt !== null)) {
         throw new EntityNotFoundError(entityType.name as any, id);
     }
     return entity;

+ 30 - 1
server/src/service/services/country.service.ts

@@ -2,17 +2,24 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
-import { CreateCountryInput, UpdateCountryInput } from '../../../../shared/generated-types';
+import {
+    CreateCountryInput,
+    DeletionResponse,
+    DeletionResult,
+    UpdateCountryInput,
+} from '../../../../shared/generated-types';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { RequestContext } from '../../api/common/request-context';
 import { UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
+import { Address } from '../../entity';
 import { CountryTranslation } from '../../entity/country/country-translation.entity';
 import { Country } from '../../entity/country/country.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 @Injectable()
@@ -75,4 +82,26 @@ export class CountryService {
         });
         return assertFound(this.findOne(ctx, country.id));
     }
+
+    async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
+        const country = await getEntityOrThrow(this.connection, Country, id);
+        const addressesUsingCountry = await this.connection
+            .getRepository(Address)
+            .createQueryBuilder('address')
+            .where('address.countryId = :id', { id })
+            .getCount();
+
+        if (0 < addressesUsingCountry) {
+            return {
+                result: DeletionResult.NOT_DELETED,
+                message: ctx.translate('message.country-used-in-addresses', { count: addressesUsingCountry }),
+            };
+        } else {
+            await this.connection.getRepository(Country).remove(country);
+            return {
+                result: DeletionResult.DELETED,
+                message: '',
+            };
+        }
+    }
 }

+ 1 - 1
server/src/service/services/customer-group.service.ts

@@ -63,6 +63,6 @@ export class CustomerGroupService {
     }
 
     private getCustomersFromIds(ids: ID[]): Promise<Customer[]> {
-        return this.connection.getRepository(Customer).findByIds(ids);
+        return this.connection.getRepository(Customer).findByIds(ids, { where: { deletedAt: null } });
     }
 }

+ 31 - 7
server/src/service/services/customer.service.ts

@@ -5,6 +5,8 @@ import { Connection } from 'typeorm';
 import {
     CreateAddressInput,
     CreateCustomerInput,
+    DeletionResponse,
+    DeletionResult,
     RegisterCustomerInput,
     UpdateAddressInput,
     UpdateCustomerInput,
@@ -21,6 +23,7 @@ import { AccountRegistrationEvent } from '../../event-bus/events/account-registr
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
+import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { CountryService } from './country.service';
 import { UserService } from './user.service';
@@ -37,29 +40,38 @@ export class CustomerService {
 
     findAll(options: ListQueryOptions<Customer> | undefined): Promise<PaginatedList<Customer>> {
         return this.listQueryBuilder
-            .build(Customer, options)
+            .build(Customer, options, { where: { deletedAt: null } })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
     findOne(id: ID): Promise<Customer | undefined> {
-        return this.connection.getRepository(Customer).findOne(id);
+        return this.connection.getRepository(Customer).findOne(id, { where: { deletedAt: null } });
     }
 
     findOneByUserId(userId: ID): Promise<Customer | undefined> {
         return this.connection.getRepository(Customer).findOne({
             where: {
                 user: { id: userId },
+                deletedAt: null,
             },
         });
     }
 
-    findAddressesByCustomerId(customerId: ID): Promise<Address[]> {
+    findAddressesByCustomerId(ctx: RequestContext, customerId: ID): Promise<Address[]> {
         return this.connection
             .getRepository(Address)
             .createQueryBuilder('address')
+            .leftJoinAndSelect('address.country', 'country')
+            .leftJoinAndSelect('country.translations', 'countryTranslation')
             .where('address.customerId = :id', { id: customerId })
-            .getMany();
+            .getMany()
+            .then(addresses => {
+                addresses.forEach(address => {
+                    address.country = translateDeep(address.country, ctx.languageCode);
+                });
+                return addresses;
+            });
     }
 
     async create(input: CreateCustomerInput, password?: string): Promise<Customer> {
@@ -161,6 +173,7 @@ export class CustomerService {
         input: CreateAddressInput,
     ): Promise<Address> {
         const customer = await this.connection.manager.findOne(Customer, customerId, {
+            where: { deletedAt: null },
             relations: ['addresses'],
         });
 
@@ -170,7 +183,7 @@ export class CustomerService {
         const country = await this.countryService.findOneByCode(ctx, input.countryCode);
         const address = new Address({
             ...input,
-            country: country.name,
+            country,
         });
         const createdAddress = await this.connection.manager.getRepository(Address).save(address);
         customer.addresses.push(createdAddress);
@@ -179,14 +192,25 @@ export class CustomerService {
         return createdAddress;
     }
 
-    async updateAddress(input: UpdateAddressInput): Promise<Address> {
-        const address = await getEntityOrThrow(this.connection, Address, input.id);
+    async updateAddress(ctx: RequestContext, input: UpdateAddressInput): Promise<Address> {
+        const address = await getEntityOrThrow(this.connection, Address, input.id, {
+            relations: ['country'],
+        });
+        address.country = translateDeep(address.country, ctx.languageCode);
         const updatedAddress = patchEntity(address, input);
         await this.connection.getRepository(Address).save(updatedAddress);
         await this.enforceSingleDefaultAddress(input.id, input);
         return updatedAddress;
     }
 
+    async softDelete(customerId: ID): Promise<DeletionResponse> {
+        await getEntityOrThrow(this.connection, Customer, customerId);
+        await this.connection.getRepository(Customer).update({ id: customerId }, { deletedAt: new Date() });
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
     private async enforceSingleDefaultAddress(addressId: ID, input: CreateAddressInput | UpdateAddressInput) {
         const result = await this.connection
             .getRepository(Address)

+ 57 - 0
server/src/service/services/facet-value.service.ts

@@ -5,6 +5,8 @@ import { Connection } from 'typeorm';
 import {
     CreateFacetValueInput,
     CreateFacetValueWithFacetInput,
+    DeletionResponse,
+    DeletionResult,
     LanguageCode,
     UpdateFacetValueInput,
 } from '../../../../shared/generated-types';
@@ -13,10 +15,12 @@ import { RequestContext } from '../../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
+import { Product, ProductVariant } from '../../entity';
 import { FacetValueTranslation } from '../../entity/facet-value/facet-value-translation.entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { Facet } from '../../entity/facet/facet.entity';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 @Injectable()
@@ -94,4 +98,57 @@ export class FacetValueService {
         });
         return assertFound(this.findOne(facetValue.id, DEFAULT_LANGUAGE_CODE));
     }
+
+    async delete(ctx: RequestContext, id: ID, force: boolean = false): Promise<DeletionResponse> {
+        const { productCount, variantCount } = await this.checkFacetValueUsage([id]);
+
+        const isInUse = !!(productCount || variantCount);
+        const both = !!(productCount && variantCount) ? 'both' : 'single';
+        const i18nVars = { products: productCount, variants: variantCount, both };
+        let message = '';
+        let result: DeletionResult;
+
+        if (!isInUse) {
+            const facetValue = await getEntityOrThrow(this.connection, FacetValue, id);
+            await this.connection.getRepository(FacetValue).remove(facetValue);
+            result = DeletionResult.DELETED;
+        } else if (force) {
+            const facetValue = await getEntityOrThrow(this.connection, FacetValue, id);
+            await this.connection.getRepository(FacetValue).remove(facetValue);
+            message = ctx.translate('message.facet-value-force-deleted', i18nVars);
+            result = DeletionResult.DELETED;
+        } else {
+            message = ctx.translate('message.facet-value-used', i18nVars);
+            result = DeletionResult.NOT_DELETED;
+        }
+
+        return {
+            result,
+            message,
+        };
+    }
+
+    /**
+     * Checks for usage of the given FacetValues in any Products or Variants, and returns the counts.
+     */
+    async checkFacetValueUsage(facetValueIds: ID[]): Promise<{ productCount: number; variantCount: number }> {
+        const consumingProducts = await this.connection
+            .getRepository(Product)
+            .createQueryBuilder('product')
+            .leftJoinAndSelect('product.facetValues', 'facetValues')
+            .where('facetValues.id IN (:...facetValueIds)', { facetValueIds })
+            .getMany();
+
+        const consumingVariants = await this.connection
+            .getRepository(ProductVariant)
+            .createQueryBuilder('variant')
+            .leftJoinAndSelect('variant.facetValues', 'facetValues')
+            .where('facetValues.id IN (:...facetValueIds)', { facetValueIds })
+            .getMany();
+
+        return {
+            productCount: consumingProducts.length,
+            variantCount: consumingVariants.length,
+        };
+    }
 }

+ 42 - 1
server/src/service/services/facet.service.ts

@@ -2,8 +2,15 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
-import { CreateFacetInput, LanguageCode, UpdateFacetInput } from '../../../../shared/generated-types';
+import {
+    CreateFacetInput,
+    DeletionResponse,
+    DeletionResult,
+    LanguageCode,
+    UpdateFacetInput,
+} from '../../../../shared/generated-types';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
+import { RequestContext } from '../../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
@@ -12,12 +19,16 @@ import { FacetTranslation } from '../../entity/facet/facet-translation.entity';
 import { Facet } from '../../entity/facet/facet.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
+import { FacetValueService } from './facet-value.service';
+
 @Injectable()
 export class FacetService {
     constructor(
         @InjectConnection() private connection: Connection,
+        private facetValueService: FacetValueService,
         private translatableSaver: TranslatableSaver,
         private listQueryBuilder: ListQueryBuilder,
     ) {}
@@ -80,4 +91,34 @@ export class FacetService {
         });
         return assertFound(this.findOne(facet.id, DEFAULT_LANGUAGE_CODE));
     }
+
+    async delete(ctx: RequestContext, id: ID, force: boolean = false): Promise<DeletionResponse> {
+        const facet = await getEntityOrThrow(this.connection, Facet, id, { relations: ['values'] });
+        const { productCount, variantCount } = await this.facetValueService.checkFacetValueUsage(
+            facet.values.map(fv => fv.id),
+        );
+
+        const isInUse = !!(productCount || variantCount);
+        const both = !!(productCount && variantCount) ? 'both' : 'single';
+        const i18nVars = { products: productCount, variants: variantCount, both };
+        let message = '';
+        let result: DeletionResult;
+
+        if (!isInUse) {
+            await this.connection.getRepository(Facet).remove(facet);
+            result = DeletionResult.DELETED;
+        } else if (force) {
+            await this.connection.getRepository(Facet).remove(facet);
+            message = ctx.translate('message.facet-force-deleted', i18nVars);
+            result = DeletionResult.DELETED;
+        } else {
+            message = ctx.translate('message.facet-used', i18nVars);
+            result = DeletionResult.NOT_DELETED;
+        }
+
+        return {
+            result,
+            message,
+        };
+    }
 }

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

@@ -163,6 +163,7 @@ export class ProductVariantService {
     ): Promise<Array<Translated<ProductVariant>>> {
         const product = await this.connection.getRepository(Product).findOne(productId, {
             relations: ['optionGroups', 'optionGroups.options'],
+            where: { deletedAt: null },
         });
 
         if (!product) {

+ 21 - 3
server/src/service/services/product.service.ts

@@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection, FindConditions, In } from 'typeorm';
 
-import { CreateProductInput, UpdateProductInput } from '../../../../shared/generated-types';
+import {
+    CreateProductInput,
+    DeletionResponse,
+    DeletionResult,
+    UpdateProductInput,
+} from '../../../../shared/generated-types';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { RequestContext } from '../../api/common/request-context';
 import { EntityNotFoundError } from '../../common/error/errors';
@@ -17,6 +22,7 @@ import { CatalogModificationEvent } from '../../event-bus/events/catalog-modific
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ChannelService } from './channel.service';
@@ -63,7 +69,7 @@ export class ProductService {
             .build(Product, options, {
                 relations: this.relations,
                 channelId: ctx.channelId,
-                where,
+                where: { ...where, deletedAt: null },
             })
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
@@ -84,6 +90,9 @@ export class ProductService {
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
         const product = await this.connection.manager.findOne(Product, productId, {
             relations: this.relations,
+            where: {
+                deletedAt: null,
+            },
         });
         if (!product) {
             return;
@@ -112,6 +121,7 @@ export class ProductService {
     }
 
     async update(ctx: RequestContext, input: UpdateProductInput): Promise<Translated<Product>> {
+        await getEntityOrThrow(this.connection, Product, input.id);
         const product = await this.translatableSaver.update({
             input,
             entityType: Product,
@@ -127,6 +137,14 @@ export class ProductService {
         return assertFound(this.findOne(ctx, product.id));
     }
 
+    async softDelete(productId: ID): Promise<DeletionResponse> {
+        await getEntityOrThrow(this.connection, Product, productId);
+        await this.connection.getRepository(Product).update({ id: productId }, { deletedAt: new Date() });
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
     async addOptionGroupToProduct(
         ctx: RequestContext,
         productId: ID,
@@ -179,7 +197,7 @@ export class ProductService {
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)
-            .findOne(productId, { relations: ['optionGroups'] });
+            .findOne(productId, { relations: ['optionGroups'], where: { deletedAt: null } });
         if (!product) {
             throw new EntityNotFoundError('Product', productId);
         }

+ 15 - 7
server/src/service/services/promotion.service.ts

@@ -6,12 +6,14 @@ import {
     AdjustmentOperation,
     AdjustmentOperationInput,
     CreatePromotionInput,
+    DeletionResponse,
+    DeletionResult,
     UpdatePromotionInput,
 } from '../../../../shared/generated-types';
 import { omit } from '../../../../shared/omit';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
+import { UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
@@ -19,6 +21,7 @@ import { PromotionAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
 import { ChannelService } from './channel.service';
@@ -46,7 +49,7 @@ export class PromotionService {
 
     findAll(options?: ListQueryOptions<Promotion>): Promise<PaginatedList<Promotion>> {
         return this.listQueryBuilder
-            .build(Promotion, options)
+            .build(Promotion, options, { where: { deletedAt: null } })
             .getManyAndCount()
             .then(([items, totalItems]) => ({
                 items,
@@ -55,7 +58,7 @@ export class PromotionService {
     }
 
     async findOne(adjustmentSourceId: ID): Promise<Promotion | undefined> {
-        return this.connection.manager.findOne(Promotion, adjustmentSourceId, {});
+        return this.connection.manager.findOne(Promotion, adjustmentSourceId, { where: { deletedAt: null } });
     }
 
     /**
@@ -103,10 +106,7 @@ export class PromotionService {
     }
 
     async updatePromotion(ctx: RequestContext, input: UpdatePromotionInput): Promise<Promotion> {
-        const adjustmentSource = await this.connection.getRepository(Promotion).findOne(input.id);
-        if (!adjustmentSource) {
-            throw new EntityNotFoundError('Promotion', input.id);
-        }
+        const adjustmentSource = await getEntityOrThrow(this.connection, Promotion, input.id);
         const updatedAdjustmentSource = patchEntity(adjustmentSource, omit(input, ['conditions', 'actions']));
         if (input.conditions) {
             updatedAdjustmentSource.conditions = input.conditions.map(c =>
@@ -122,6 +122,14 @@ export class PromotionService {
         return assertFound(this.findOne(updatedAdjustmentSource.id));
     }
 
+    async softDeletePromotion(promotionId: ID): Promise<DeletionResponse> {
+        await getEntityOrThrow(this.connection, Promotion, promotionId);
+        await this.connection.getRepository(Promotion).update({ id: promotionId }, { deletedAt: new Date() });
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
     /**
      * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
      */

+ 29 - 0
server/src/service/services/zone.service.ts

@@ -5,6 +5,8 @@ import { Connection } from 'typeorm';
 import {
     AddMembersToZoneMutationArgs,
     CreateZoneInput,
+    DeletionResponse,
+    DeletionResult,
     RemoveMembersFromZoneMutationArgs,
     UpdateZoneInput,
 } from '../../../../shared/generated-types';
@@ -12,6 +14,7 @@ import { ID } from '../../../../shared/shared-types';
 import { unique } from '../../../../shared/unique';
 import { RequestContext } from '../../api/common/request-context';
 import { assertFound } from '../../common/utils';
+import { TaxRate } from '../../entity';
 import { Country } from '../../entity/country/country.entity';
 import { Zone } from '../../entity/zone/zone.entity';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -69,6 +72,32 @@ export class ZoneService implements OnModuleInit {
         return assertFound(this.findOne(ctx, zone.id));
     }
 
+    async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
+        const zone = await getEntityOrThrow(this.connection, Zone, id);
+
+        const taxRatesUsingZone = await this.connection
+            .getRepository(TaxRate)
+            .createQueryBuilder('taxRate')
+            .where('taxRate.zoneId = :id', { id })
+            .getMany();
+
+        if (0 < taxRatesUsingZone.length) {
+            return {
+                result: DeletionResult.NOT_DELETED,
+                message: ctx.translate('message.zone-used-in-tax-rates', {
+                    taxRateNames: taxRatesUsingZone.map(t => t.name).join(', '),
+                }),
+            };
+        } else {
+            await this.connection.getRepository(Zone).remove(zone);
+            await this.updateZonesCache();
+            return {
+                result: DeletionResult.DELETED,
+                message: '',
+            };
+        }
+    }
+
     async addMembersToZone(ctx: RequestContext, input: AddMembersToZoneMutationArgs): Promise<Zone> {
         const countries = await this.getCountriesFromIds(input.memberIds);
         const zone = await getEntityOrThrow(this.connection, Zone, input.zoneId, { relations: ['members'] });

+ 148 - 8
shared/generated-types.ts

@@ -238,8 +238,7 @@ export interface Address extends Node {
     city?: string | null;
     province?: string | null;
     postalCode?: string | null;
-    country: string;
-    countryCode: string;
+    country: Country;
     phoneNumber?: string | null;
     defaultShippingAddress?: boolean | null;
     defaultBillingAddress?: boolean | null;
@@ -681,18 +680,22 @@ export interface Mutation {
     updateChannel: Channel;
     createCountry: Country;
     updateCountry: Country;
+    deleteCountry: DeletionResponse;
     createCustomerGroup: CustomerGroup;
     updateCustomerGroup: CustomerGroup;
     addCustomersToGroup: CustomerGroup;
     removeCustomersFromGroup: CustomerGroup;
     createCustomer: Customer;
     updateCustomer: Customer;
+    deleteCustomer: DeletionResponse;
     createCustomerAddress: Address;
     updateCustomerAddress: Address;
     createFacet: Facet;
     updateFacet: Facet;
+    deleteFacet: DeletionResponse;
     createFacetValues: FacetValue[];
     updateFacetValues: FacetValue[];
+    deleteFacetValues: DeletionResponse[];
     updateGlobalSettings: GlobalSettings;
     importProducts?: ImportInfo | null;
     addItemToOrder?: Order | null;
@@ -711,12 +714,14 @@ export interface Mutation {
     updateProductOptionGroup: ProductOptionGroup;
     createProduct: Product;
     updateProduct: Product;
+    deleteProduct: DeletionResponse;
     addOptionGroupToProduct: Product;
     removeOptionGroupFromProduct: Product;
     generateVariantsForProduct: Product;
     updateProductVariants: (ProductVariant | null)[];
     createPromotion: Promotion;
     updatePromotion: Promotion;
+    deletePromotion: DeletionResponse;
     createRole: Role;
     updateRole: Role;
     reindex: boolean;
@@ -728,6 +733,7 @@ export interface Mutation {
     updateTaxRate: TaxRate;
     createZone: Zone;
     updateZone: Zone;
+    deleteZone: DeletionResponse;
     addMembersToZone: Zone;
     removeMembersFromZone: Zone;
     requestStarted: number;
@@ -741,6 +747,11 @@ export interface LoginResult {
     user: CurrentUser;
 }
 
+export interface DeletionResponse {
+    result: DeletionResult;
+    message?: string | null;
+}
+
 export interface ImportInfo {
     errors?: string[] | null;
     processed: number;
@@ -1648,6 +1659,9 @@ export interface CreateCountryMutationArgs {
 export interface UpdateCountryMutationArgs {
     input: UpdateCountryInput;
 }
+export interface DeleteCountryMutationArgs {
+    id: string;
+}
 export interface CreateCustomerGroupMutationArgs {
     input: CreateCustomerGroupInput;
 }
@@ -1669,6 +1683,9 @@ export interface CreateCustomerMutationArgs {
 export interface UpdateCustomerMutationArgs {
     input: UpdateCustomerInput;
 }
+export interface DeleteCustomerMutationArgs {
+    id: string;
+}
 export interface CreateCustomerAddressMutationArgs {
     customerId: string;
     input: CreateAddressInput;
@@ -1682,12 +1699,20 @@ export interface CreateFacetMutationArgs {
 export interface UpdateFacetMutationArgs {
     input: UpdateFacetInput;
 }
+export interface DeleteFacetMutationArgs {
+    id: string;
+    force?: boolean | null;
+}
 export interface CreateFacetValuesMutationArgs {
     input: CreateFacetValueInput[];
 }
 export interface UpdateFacetValuesMutationArgs {
     input: UpdateFacetValueInput[];
 }
+export interface DeleteFacetValuesMutationArgs {
+    ids: string[];
+    force?: boolean | null;
+}
 export interface UpdateGlobalSettingsMutationArgs {
     input: UpdateGlobalSettingsInput;
 }
@@ -1744,6 +1769,9 @@ export interface CreateProductMutationArgs {
 export interface UpdateProductMutationArgs {
     input: UpdateProductInput;
 }
+export interface DeleteProductMutationArgs {
+    id: string;
+}
 export interface AddOptionGroupToProductMutationArgs {
     productId: string;
     optionGroupId: string;
@@ -1767,6 +1795,9 @@ export interface CreatePromotionMutationArgs {
 export interface UpdatePromotionMutationArgs {
     input: UpdatePromotionInput;
 }
+export interface DeletePromotionMutationArgs {
+    id: string;
+}
 export interface CreateRoleMutationArgs {
     input: CreateRoleInput;
 }
@@ -1797,6 +1828,9 @@ export interface CreateZoneMutationArgs {
 export interface UpdateZoneMutationArgs {
     input: UpdateZoneInput;
 }
+export interface DeleteZoneMutationArgs {
+    id: string;
+}
 export interface AddMembersToZoneMutationArgs {
     zoneId: string;
     memberIds: string[];
@@ -2208,6 +2242,11 @@ export enum AdjustmentType {
     SHIPPING_REFUND = 'SHIPPING_REFUND',
 }
 
+export enum DeletionResult {
+    DELETED = 'DELETED',
+    NOT_DELETED = 'NOT_DELETED',
+}
+
 export namespace QueryResolvers {
     export interface Resolvers<Context = any> {
         administrators?: AdministratorsResolver<AdministratorList, any, Context>;
@@ -3052,8 +3091,7 @@ export namespace AddressResolvers {
         city?: CityResolver<string | null, any, Context>;
         province?: ProvinceResolver<string | null, any, Context>;
         postalCode?: PostalCodeResolver<string | null, any, Context>;
-        country?: CountryResolver<string, any, Context>;
-        countryCode?: CountryCodeResolver<string, any, Context>;
+        country?: CountryResolver<Country, any, Context>;
         phoneNumber?: PhoneNumberResolver<string | null, any, Context>;
         defaultShippingAddress?: DefaultShippingAddressResolver<boolean | null, any, Context>;
         defaultBillingAddress?: DefaultBillingAddressResolver<boolean | null, any, Context>;
@@ -3090,8 +3128,7 @@ export namespace AddressResolvers {
         Parent,
         Context
     >;
-    export type CountryResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CountryCodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CountryResolver<R = Country, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type PhoneNumberResolver<R = string | null, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4391,18 +4428,22 @@ export namespace MutationResolvers {
         updateChannel?: UpdateChannelResolver<Channel, any, Context>;
         createCountry?: CreateCountryResolver<Country, any, Context>;
         updateCountry?: UpdateCountryResolver<Country, any, Context>;
+        deleteCountry?: DeleteCountryResolver<DeletionResponse, any, Context>;
         createCustomerGroup?: CreateCustomerGroupResolver<CustomerGroup, any, Context>;
         updateCustomerGroup?: UpdateCustomerGroupResolver<CustomerGroup, any, Context>;
         addCustomersToGroup?: AddCustomersToGroupResolver<CustomerGroup, any, Context>;
         removeCustomersFromGroup?: RemoveCustomersFromGroupResolver<CustomerGroup, any, Context>;
         createCustomer?: CreateCustomerResolver<Customer, any, Context>;
         updateCustomer?: UpdateCustomerResolver<Customer, any, Context>;
+        deleteCustomer?: DeleteCustomerResolver<DeletionResponse, any, Context>;
         createCustomerAddress?: CreateCustomerAddressResolver<Address, any, Context>;
         updateCustomerAddress?: UpdateCustomerAddressResolver<Address, any, Context>;
         createFacet?: CreateFacetResolver<Facet, any, Context>;
         updateFacet?: UpdateFacetResolver<Facet, any, Context>;
+        deleteFacet?: DeleteFacetResolver<DeletionResponse, any, Context>;
         createFacetValues?: CreateFacetValuesResolver<FacetValue[], any, Context>;
         updateFacetValues?: UpdateFacetValuesResolver<FacetValue[], any, Context>;
+        deleteFacetValues?: DeleteFacetValuesResolver<DeletionResponse[], any, Context>;
         updateGlobalSettings?: UpdateGlobalSettingsResolver<GlobalSettings, any, Context>;
         importProducts?: ImportProductsResolver<ImportInfo | null, any, Context>;
         addItemToOrder?: AddItemToOrderResolver<Order | null, any, Context>;
@@ -4421,12 +4462,14 @@ export namespace MutationResolvers {
         updateProductOptionGroup?: UpdateProductOptionGroupResolver<ProductOptionGroup, any, Context>;
         createProduct?: CreateProductResolver<Product, any, Context>;
         updateProduct?: UpdateProductResolver<Product, any, Context>;
+        deleteProduct?: DeleteProductResolver<DeletionResponse, any, Context>;
         addOptionGroupToProduct?: AddOptionGroupToProductResolver<Product, any, Context>;
         removeOptionGroupFromProduct?: RemoveOptionGroupFromProductResolver<Product, any, Context>;
         generateVariantsForProduct?: GenerateVariantsForProductResolver<Product, any, Context>;
         updateProductVariants?: UpdateProductVariantsResolver<(ProductVariant | null)[], any, Context>;
         createPromotion?: CreatePromotionResolver<Promotion, any, Context>;
         updatePromotion?: UpdatePromotionResolver<Promotion, any, Context>;
+        deletePromotion?: DeletePromotionResolver<DeletionResponse, any, Context>;
         createRole?: CreateRoleResolver<Role, any, Context>;
         updateRole?: UpdateRoleResolver<Role, any, Context>;
         reindex?: ReindexResolver<boolean, any, Context>;
@@ -4438,6 +4481,7 @@ export namespace MutationResolvers {
         updateTaxRate?: UpdateTaxRateResolver<TaxRate, any, Context>;
         createZone?: CreateZoneResolver<Zone, any, Context>;
         updateZone?: UpdateZoneResolver<Zone, any, Context>;
+        deleteZone?: DeleteZoneResolver<DeletionResponse, any, Context>;
         addMembersToZone?: AddMembersToZoneResolver<Zone, any, Context>;
         removeMembersFromZone?: RemoveMembersFromZoneResolver<Zone, any, Context>;
         requestStarted?: RequestStartedResolver<number, any, Context>;
@@ -4572,6 +4616,16 @@ export namespace MutationResolvers {
         input: UpdateCountryInput;
     }
 
+    export type DeleteCountryResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteCountryArgs
+    >;
+    export interface DeleteCountryArgs {
+        id: string;
+    }
+
     export type CreateCustomerGroupResolver<R = CustomerGroup, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4635,6 +4689,16 @@ export namespace MutationResolvers {
         input: UpdateCustomerInput;
     }
 
+    export type DeleteCustomerResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteCustomerArgs
+    >;
+    export interface DeleteCustomerArgs {
+        id: string;
+    }
+
     export type CreateCustomerAddressResolver<R = Address, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4676,6 +4740,17 @@ export namespace MutationResolvers {
         input: UpdateFacetInput;
     }
 
+    export type DeleteFacetResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteFacetArgs
+    >;
+    export interface DeleteFacetArgs {
+        id: string;
+        force?: boolean | null;
+    }
+
     export type CreateFacetValuesResolver<R = FacetValue[], Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4696,6 +4771,17 @@ export namespace MutationResolvers {
         input: UpdateFacetValueInput[];
     }
 
+    export type DeleteFacetValuesResolver<R = DeletionResponse[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteFacetValuesArgs
+    >;
+    export interface DeleteFacetValuesArgs {
+        ids: string[];
+        force?: boolean | null;
+    }
+
     export type UpdateGlobalSettingsResolver<R = GlobalSettings, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4876,6 +4962,16 @@ export namespace MutationResolvers {
         input: UpdateProductInput;
     }
 
+    export type DeleteProductResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteProductArgs
+    >;
+    export interface DeleteProductArgs {
+        id: string;
+    }
+
     export type AddOptionGroupToProductResolver<R = Product, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -4940,6 +5036,16 @@ export namespace MutationResolvers {
         input: UpdatePromotionInput;
     }
 
+    export type DeletePromotionResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeletePromotionArgs
+    >;
+    export interface DeletePromotionArgs {
+        id: string;
+    }
+
     export type CreateRoleResolver<R = Role, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -5041,6 +5147,16 @@ export namespace MutationResolvers {
         input: UpdateZoneInput;
     }
 
+    export type DeleteZoneResolver<R = DeletionResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        DeleteZoneArgs
+    >;
+    export interface DeleteZoneArgs {
+        id: string;
+    }
+
     export type AddMembersToZoneResolver<R = Zone, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -5108,6 +5224,24 @@ export namespace LoginResultResolvers {
     export type UserResolver<R = CurrentUser, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
+export namespace DeletionResponseResolvers {
+    export interface Resolvers<Context = any> {
+        result?: ResultResolver<DeletionResult, any, Context>;
+        message?: MessageResolver<string | null, any, Context>;
+    }
+
+    export type ResultResolver<R = DeletionResult, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type MessageResolver<R = string | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
 export namespace ImportInfoResolvers {
     export interface Resolvers<Context = any> {
         errors?: ErrorsResolver<string[] | null, any, Context>;
@@ -6650,12 +6784,18 @@ export namespace Address {
         city?: string | null;
         province?: string | null;
         postalCode?: string | null;
-        country: string;
-        countryCode: string;
+        country: Country;
         phoneNumber?: string | null;
         defaultShippingAddress?: boolean | null;
         defaultBillingAddress?: boolean | null;
     };
+
+    export type Country = {
+        __typename?: 'Country';
+        id: string;
+        code: string;
+        name: string;
+    };
 }
 
 export namespace Customer {

Some files were not shown because too many files changed in this diff