Browse Source

test(core): Add e2e tests for entity duplication

Relates to #627
Michael Bromley 1 year ago
parent
commit
0fa19b2f24

+ 37 - 0
packages/common/src/generated-types.ts

@@ -1550,6 +1550,26 @@ export type Discount = {
   type: AdjustmentType;
 };
 
+export type DuplicateEntityError = ErrorResult & {
+  __typename?: 'DuplicateEntityError';
+  duplicationError: Scalars['String']['output'];
+  errorCode: ErrorCode;
+  message: Scalars['String']['output'];
+};
+
+export type DuplicateEntityInput = {
+  duplicatorInput: ConfigurableOperationInput;
+  entityId: Scalars['ID']['input'];
+  entityName: Scalars['String']['input'];
+};
+
+export type DuplicateEntityResult = DuplicateEntityError | DuplicateEntitySuccess;
+
+export type DuplicateEntitySuccess = {
+  __typename?: 'DuplicateEntitySuccess';
+  newEntityId: Scalars['ID']['output'];
+};
+
 /** Returned when attempting to create a Customer with an email address already registered to an existing User. */
 export type EmailAddressConflictError = ErrorResult & {
   __typename?: 'EmailAddressConflictError';
@@ -1570,6 +1590,15 @@ export type EntityCustomFields = {
   entityName: Scalars['String']['output'];
 };
 
+export type EntityDuplicatorDefinition = {
+  __typename?: 'EntityDuplicatorDefinition';
+  args: Array<ConfigArgDefinition>;
+  code: Scalars['String']['output'];
+  description: Scalars['String']['output'];
+  forEntities: Array<Scalars['String']['output']>;
+  requiresPermission: Array<Permission>;
+};
+
 export enum ErrorCode {
   ALREADY_REFUNDED_ERROR = 'ALREADY_REFUNDED_ERROR',
   CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
@@ -1579,6 +1608,7 @@ export enum ErrorCode {
   COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
   COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
   CREATE_FULFILLMENT_ERROR = 'CREATE_FULFILLMENT_ERROR',
+  DUPLICATE_ENTITY_ERROR = 'DUPLICATE_ENTITY_ERROR',
   EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
   EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
   FACET_IN_USE_ERROR = 'FACET_IN_USE_ERROR',
@@ -2803,6 +2833,7 @@ export type Mutation = {
   deleteZone: DeletionResponse;
   /** Delete a Zone */
   deleteZones: Array<DeletionResponse>;
+  duplicateEntity: DuplicateEntityResult;
   flushBufferedJobs: Success;
   importProducts?: Maybe<ImportInfo>;
   /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
@@ -3416,6 +3447,11 @@ export type MutationDeleteZonesArgs = {
 };
 
 
+export type MutationDuplicateEntityArgs = {
+  input: DuplicateEntityInput;
+};
+
+
 export type MutationFlushBufferedJobsArgs = {
   bufferIds?: InputMaybe<Array<Scalars['String']['input']>>;
 };
@@ -4955,6 +4991,7 @@ export type Query = {
   customers: CustomerList;
   /** Returns a list of eligible shipping methods for the draft Order */
   eligibleShippingMethodsForDraftOrder: Array<ShippingMethodQuote>;
+  entityDuplicators: Array<EntityDuplicatorDefinition>;
   facet?: Maybe<Facet>;
   facetValues: FacetValueList;
   facets: FacetList;

+ 324 - 0
packages/core/e2e/duplicate-entity.e2e-spec.ts

@@ -0,0 +1,324 @@
+import {
+    Collection,
+    CollectionService,
+    defaultEntityDuplicators,
+    EntityDuplicator,
+    LanguageCode,
+    mergeConfig,
+    PermissionDefinition,
+    TransactionalConnection,
+} from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import {
+    AdministratorFragment,
+    CreateAdministratorMutation,
+    CreateAdministratorMutationVariables,
+    CreateRoleMutation,
+    CreateRoleMutationVariables,
+    Permission,
+    RoleFragment,
+} from './graphql/generated-e2e-admin-types';
+import { CREATE_ADMINISTRATOR, CREATE_ROLE, GET_COLLECTIONS } from './graphql/shared-definitions';
+
+const customPermission = new PermissionDefinition({
+    name: 'custom',
+});
+
+let collectionService: CollectionService;
+let connection: TransactionalConnection;
+
+const customCollectionDuplicator = new EntityDuplicator({
+    code: 'custom-collection-duplicator',
+    description: [{ languageCode: LanguageCode.en, value: 'Custom Collection Duplicator' }],
+    args: {
+        throwError: {
+            type: 'boolean',
+            defaultValue: false,
+        },
+    },
+    forEntities: ['Collection'],
+    requiresPermission: customPermission.Permission,
+    init(injector) {
+        collectionService = injector.get(CollectionService);
+        connection = injector.get(TransactionalConnection);
+    },
+    duplicate: async input => {
+        const { ctx, id, args } = input;
+
+        const original = await connection.getEntityOrThrow(ctx, Collection, id, {
+            relations: {
+                assets: true,
+                featuredAsset: true,
+            },
+        });
+        const newCollection = await collectionService.create(ctx, {
+            isPrivate: original.isPrivate,
+            customFields: original.customFields,
+            assetIds: original.assets.map(a => a.id),
+            featuredAssetId: original.featuredAsset?.id,
+            parentId: original.parentId,
+            filters: original.filters.map(f => ({
+                code: f.code,
+                arguments: f.args,
+            })),
+            inheritFilters: original.inheritFilters,
+            translations: original.translations.map(t => ({
+                languageCode: t.languageCode,
+                name: `${t.name} (copy)`,
+                slug: `${t.slug}-copy`,
+                description: t.description,
+                customFields: t.customFields,
+            })),
+        });
+
+        if (args.throwError) {
+            throw new Error('Dummy error');
+        }
+
+        return newCollection;
+    },
+});
+
+describe('Duplicating entities', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            authOptions: {
+                customPermissions: [customPermission],
+            },
+            entityOptions: {
+                entityDuplicators: [/* ...defaultEntityDuplicators */ customCollectionDuplicator],
+            },
+        }),
+    );
+
+    const duplicateEntityGuard: ErrorResultGuard<{ newEntityId: string }> = createErrorResultGuard(
+        result => !!result.newEntityId,
+    );
+
+    let testRole: RoleFragment;
+    let testAdmin: AdministratorFragment;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        // create a new role and Admin and sign in as that Admin
+        const { createRole } = await adminClient.query<CreateRoleMutation, CreateRoleMutationVariables>(
+            CREATE_ROLE,
+            {
+                input: {
+                    channelIds: ['T_1'],
+                    code: 'test-role',
+                    description: 'Testing custom permissions',
+                    permissions: [
+                        Permission.CreateCollection,
+                        Permission.UpdateCollection,
+                        Permission.ReadCollection,
+                    ],
+                },
+            },
+        );
+        testRole = createRole;
+        const { createAdministrator } = await adminClient.query<
+            CreateAdministratorMutation,
+            CreateAdministratorMutationVariables
+        >(CREATE_ADMINISTRATOR, {
+            input: {
+                firstName: 'Test',
+                lastName: 'Admin',
+                emailAddress: 'test@admin.com',
+                password: 'test',
+                roleIds: [testRole.id],
+            },
+        });
+
+        testAdmin = createAdministrator;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('get entity duplicators', async () => {
+        const { entityDuplicators } = await adminClient.query<Codegen.GetEntityDuplicatorsQuery>(
+            GET_ENTITY_DUPLICATORS,
+        );
+
+        expect(entityDuplicators).toEqual([
+            {
+                args: [
+                    {
+                        defaultValue: false,
+                        name: 'throwError',
+                        type: 'boolean',
+                    },
+                ],
+                code: 'custom-collection-duplicator',
+                description: 'Custom Collection Duplicator',
+                forEntities: ['Collection'],
+                requiresPermission: ['custom'],
+            },
+        ]);
+    });
+
+    it('cannot duplicate if lacking permissions', async () => {
+        await adminClient.asUserWithCredentials(testAdmin.emailAddress, 'test');
+
+        const { duplicateEntity } = await adminClient.query<
+            Codegen.DuplicateEntityMutation,
+            Codegen.DuplicateEntityMutationVariables
+        >(DUPLICATE_ENTITY, {
+            input: {
+                entityName: 'Collection',
+                entityId: 'T_2',
+                duplicatorInput: {
+                    code: 'custom-collection-duplicator',
+                    arguments: [
+                        {
+                            name: 'throwError',
+                            value: 'false',
+                        },
+                    ],
+                },
+            },
+        });
+
+        duplicateEntityGuard.assertErrorResult(duplicateEntity);
+
+        expect(duplicateEntity.message).toBe('The entity could not be duplicated');
+        expect(duplicateEntity.duplicationError).toBe(
+            'You do not have the required permissions to duplicate this entity',
+        );
+    });
+
+    it('errors thrown in duplicator cause ErrorResult', async () => {
+        await adminClient.asSuperAdmin();
+
+        const { duplicateEntity } = await adminClient.query<
+            Codegen.DuplicateEntityMutation,
+            Codegen.DuplicateEntityMutationVariables
+        >(DUPLICATE_ENTITY, {
+            input: {
+                entityName: 'Collection',
+                entityId: 'T_2',
+                duplicatorInput: {
+                    code: 'custom-collection-duplicator',
+                    arguments: [
+                        {
+                            name: 'throwError',
+                            value: 'true',
+                        },
+                    ],
+                },
+            },
+        });
+
+        duplicateEntityGuard.assertErrorResult(duplicateEntity);
+
+        expect(duplicateEntity.message).toBe('The entity could not be duplicated');
+        expect(duplicateEntity.duplicationError).toBe('Dummy error');
+    });
+
+    it('errors thrown cause all DB changes to be rolled back', async () => {
+        await adminClient.asSuperAdmin();
+
+        const { collections } = await adminClient.query<Codegen.GetCollectionsQuery>(GET_COLLECTIONS);
+
+        expect(collections.items.length).toBe(1);
+        expect(collections.items.map(i => i.name)).toEqual(['Plants']);
+    });
+
+    it('returns ID of new entity', async () => {
+        await adminClient.asSuperAdmin();
+
+        const { duplicateEntity } = await adminClient.query<
+            Codegen.DuplicateEntityMutation,
+            Codegen.DuplicateEntityMutationVariables
+        >(DUPLICATE_ENTITY, {
+            input: {
+                entityName: 'Collection',
+                entityId: 'T_2',
+                duplicatorInput: {
+                    code: 'custom-collection-duplicator',
+                    arguments: [
+                        {
+                            name: 'throwError',
+                            value: 'false',
+                        },
+                    ],
+                },
+            },
+        });
+
+        duplicateEntityGuard.assertSuccess(duplicateEntity);
+
+        expect(duplicateEntity.newEntityId).toBe('T_3');
+    });
+
+    it('duplicate gets created', async () => {
+        const { collection } = await adminClient.query<
+            Codegen.GetDuplicatedCollectionQuery,
+            Codegen.GetDuplicatedCollectionQueryVariables
+        >(GET_DUPLICATED_COLLECTION, {
+            id: 'T_3',
+        });
+
+        expect(collection).toEqual({
+            id: 'T_3',
+            name: 'Plants (copy)',
+            slug: 'plants-copy',
+        });
+    });
+});
+
+const GET_ENTITY_DUPLICATORS = gql`
+    query GetEntityDuplicators {
+        entityDuplicators {
+            code
+            description
+            requiresPermission
+            forEntities
+            args {
+                name
+                type
+                defaultValue
+            }
+        }
+    }
+`;
+
+const DUPLICATE_ENTITY = gql`
+    mutation DuplicateEntity($input: DuplicateEntityInput!) {
+        duplicateEntity(input: $input) {
+            ... on DuplicateEntitySuccess {
+                newEntityId
+            }
+            ... on DuplicateEntityError {
+                message
+                duplicationError
+            }
+        }
+    }
+`;
+
+export const GET_DUPLICATED_COLLECTION = gql`
+    query GetDuplicatedCollection($id: ID) {
+        collection(id: $id) {
+            id
+            name
+            slug
+        }
+    }
+`;

File diff suppressed because it is too large
+ 19 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 4 - 2
packages/core/src/config/entity/entity-duplicator.ts

@@ -41,8 +41,10 @@ export class EntityDuplicator<T extends ConfigArgs = ConfigArgs> extends Configu
         return this._forEntities;
     }
 
-    get requiresPermission() {
-        return this._requiresPermission;
+    get requiresPermission(): Permission[] {
+        return (Array.isArray(this._requiresPermission)
+            ? this._requiresPermission
+            : [this._requiresPermission]) as any as Permission[];
     }
 
     constructor(config: EntityDuplicatorConfig<T>) {

+ 1 - 2
packages/core/src/config/fulfillment/fulfillment-handler.ts

@@ -1,4 +1,4 @@
-import { ConfigArg, FulfillOrderInput, OrderLineInput } from '@vendure/common/lib/generated-types';
+import { ConfigArg, OrderLineInput } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../api/common/request-context';
 import {
@@ -14,7 +14,6 @@ import {
     FulfillmentState,
     FulfillmentTransitionData,
 } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
-import { CalculateShippingFnResult } from '../shipping-method/shipping-calculator';
 
 /**
  * @docsCategory fulfillment

+ 26 - 18
packages/core/src/service/helpers/entity-duplicator/entity-duplicator.service.ts

@@ -9,11 +9,16 @@ import {
 import { RequestContext } from '../../../api/index';
 import { DuplicateEntityError } from '../../../common/index';
 import { ConfigService } from '../../../config/index';
+import { TransactionalConnection } from '../../../connection/index';
 import { ConfigArgService } from '../config-arg/config-arg.service';
 
 @Injectable()
 export class EntityDuplicatorService {
-    constructor(private configService: ConfigService, private configArgService: ConfigArgService) {}
+    constructor(
+        private configService: ConfigService,
+        private configArgService: ConfigArgService,
+        private connection: TransactionalConnection,
+    ) {}
 
     getEntityDuplicators(ctx: RequestContext): EntityDuplicatorDefinition[] {
         return this.configArgService.getDefinitions('EntityDuplicator').map(x => ({
@@ -38,10 +43,10 @@ export class EntityDuplicatorService {
         }
 
         // Check permissions
-        const permissionsArray = Array.isArray(duplicator.requiresPermission)
-            ? duplicator.requiresPermission
-            : [duplicator.requiresPermission];
-        if (permissionsArray.length === 0 || !ctx.userHasPermissions(permissionsArray as Permission[])) {
+        if (
+            duplicator.requiresPermission.length === 0 ||
+            !ctx.userHasPermissions(duplicator.requiresPermission)
+        ) {
             return new DuplicateEntityError({
                 duplicationError: ctx.translate(`message.entity-duplication-no-permission`),
             });
@@ -49,18 +54,21 @@ export class EntityDuplicatorService {
 
         const parsedInput = this.configArgService.parseInput('EntityDuplicator', input.duplicatorInput);
 
-        try {
-            const newEntity = await duplicator.duplicate({
-                ctx,
-                entityName: input.entityName,
-                id: input.entityId,
-                args: parsedInput.args,
-            });
-            return { newEntityId: newEntity.id };
-        } catch (e: any) {
-            return new DuplicateEntityError({
-                duplicationError: e.message ?? e.toString(),
-            });
-        }
+        return await this.connection.withTransaction(ctx, async innerCtx => {
+            try {
+                const newEntity = await duplicator.duplicate({
+                    ctx: innerCtx,
+                    entityName: input.entityName,
+                    id: input.entityId,
+                    args: parsedInput.args,
+                });
+                return { newEntityId: newEntity.id };
+            } catch (e: any) {
+                await this.connection.rollBackTransaction(innerCtx);
+                return new DuplicateEntityError({
+                    duplicationError: e.message ?? e.toString(),
+                });
+            }
+        });
     }
 }

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