Quellcode durchsuchen

Merge branch 'master' into minor

Michael Bromley vor 1 Jahr
Ursprung
Commit
76666a23a6
25 geänderte Dateien mit 418 neuen und 123 gelöschten Zeilen
  1. 21 0
      CHANGELOG.md
  2. 1 1
      docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md
  3. 9 1
      license/signatures/version1/cla.json
  4. 1 1
      packages/admin-ui/package.json
  5. 5 2
      packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts
  6. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts
  7. 6 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  8. 0 1
      packages/admin-ui/src/lib/order/src/order.routes.ts
  9. 20 0
      packages/core/e2e/channel.e2e-spec.ts
  10. 19 0
      packages/core/e2e/order-merge.e2e-spec.ts
  11. 5 7
      packages/core/src/api/schema/admin-api/order.api.graphql
  12. 1 0
      packages/core/src/i18n/messages/de.json
  13. 1 0
      packages/core/src/i18n/messages/en.json
  14. 6 0
      packages/core/src/service/services/channel.service.ts
  15. 9 0
      packages/core/src/service/services/order.service.ts
  16. 6 5
      packages/core/src/service/services/role.service.ts
  17. 1 31
      packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts
  18. 45 1
      packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts
  19. 20 0
      packages/payments-plugin/e2e/payment-helpers.ts
  20. 33 17
      packages/payments-plugin/src/mollie/mollie.service.ts
  21. 2 1
      packages/testing/package.json
  22. 49 18
      packages/testing/src/simple-graphql-client.ts
  23. 59 11
      packages/testing/src/utils/create-upload-post-data.spec.ts
  24. 80 25
      packages/testing/src/utils/create-upload-post-data.ts
  25. 18 0
      packages/testing/vitest.config.mts

+ 21 - 0
CHANGELOG.md

@@ -1,3 +1,24 @@
+## <small>3.0.6 (2024-11-15)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix collection product filter dark theme (#3172) ([9f4eb9e](https://github.com/vendure-ecommerce/vendure/commit/9f4eb9e)), closes [#3172](https://github.com/vendure-ecommerce/vendure/issues/3172)
+* **admin-ui** Fix incorrect type when dealing with numeric value in list (#3094) ([76d66c6](https://github.com/vendure-ecommerce/vendure/commit/76d66c6)), closes [#3094](https://github.com/vendure-ecommerce/vendure/issues/3094) [#3093](https://github.com/vendure-ecommerce/vendure/issues/3093)
+* **admin-ui** Fix variant detail quick-jump component (#3189) ([478989e](https://github.com/vendure-ecommerce/vendure/commit/478989e)), closes [#3189](https://github.com/vendure-ecommerce/vendure/issues/3189)
+* **admin-ui** Make registerPageTab work on 'order-list' location (#3187) ([61d808b](https://github.com/vendure-ecommerce/vendure/commit/61d808b)), closes [#3187](https://github.com/vendure-ecommerce/vendure/issues/3187)
+* **admin-ui** Refund order dialog is showing the wrong field for prorated unit price (#3151) ([3777555](https://github.com/vendure-ecommerce/vendure/commit/3777555)), closes [#3151](https://github.com/vendure-ecommerce/vendure/issues/3151)
+* **admin-ui** Swedish translation adjustments (#3174) ([a21f129](https://github.com/vendure-ecommerce/vendure/commit/a21f129)), closes [#3174](https://github.com/vendure-ecommerce/vendure/issues/3174)
+* **common** Allow null on idsAreEqual function (#3171) ([7bba907](https://github.com/vendure-ecommerce/vendure/commit/7bba907)), closes [#3171](https://github.com/vendure-ecommerce/vendure/issues/3171)
+* **core** Added deprecation notices to the old refund input fields (#3119) ([7324bb3](https://github.com/vendure-ecommerce/vendure/commit/7324bb3)), closes [#3119](https://github.com/vendure-ecommerce/vendure/issues/3119)
+* **core** Disallow deletion of default channel (#3181) ([2ed3211](https://github.com/vendure-ecommerce/vendure/commit/2ed3211)), closes [#3181](https://github.com/vendure-ecommerce/vendure/issues/3181)
+* **core** Fix error on internal Administrator customFields (#3159) ([e03b7f0](https://github.com/vendure-ecommerce/vendure/commit/e03b7f0)), closes [#3159](https://github.com/vendure-ecommerce/vendure/issues/3159)
+* **core** Fix merging order with conflicting products using UseGuestStrategy (#3155) ([f0607aa](https://github.com/vendure-ecommerce/vendure/commit/f0607aa)), closes [#3155](https://github.com/vendure-ecommerce/vendure/issues/3155)
+* **core** Fix returning stale data in Role Update Event (#3154) ([71f85d2](https://github.com/vendure-ecommerce/vendure/commit/71f85d2)), closes [#3154](https://github.com/vendure-ecommerce/vendure/issues/3154)
+* **payments-plugin** Check for eligibility of Mollie method (#3200) ([a12dedc](https://github.com/vendure-ecommerce/vendure/commit/a12dedc)), closes [#3200](https://github.com/vendure-ecommerce/vendure/issues/3200)
+* **payments-plugin** prevent false positive logging (#3195) ([961297d](https://github.com/vendure-ecommerce/vendure/commit/961297d)), closes [#3195](https://github.com/vendure-ecommerce/vendure/issues/3195)
+* **testing** Make test client's `fileUploadMutation` work for more input variable shapes (#3188) ([a8938f4](https://github.com/vendure-ecommerce/vendure/commit/a8938f4)), closes [#3188](https://github.com/vendure-ecommerce/vendure/issues/3188)
+
 ## <small>3.0.5 (2024-10-15)</small>
 
 

+ 1 - 1
docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md

@@ -19,7 +19,7 @@ A component for selecting product variants via an autocomplete-style select inpu
 
 ```HTML
 <vdr-product-variant-selector
-  (productSelected)="selectResult($event)"></vdr-product-selector>
+  (productSelected)="selectResult($event)"></vdr-product-variant-selector>
 ```
 
 ```ts title="Signature"

+ 9 - 1
license/signatures/version1/cla.json

@@ -295,6 +295,14 @@
       "created_at": "2024-11-06T10:15:37Z",
       "repoId": 136938012,
       "pullRequestNo": 3192
+    },
+    {
+      "name": "agoransson",
+      "id": 487002,
+      "comment_id": 2466157456,
+      "created_at": "2024-11-09T10:08:00Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3205
     }
   ]
-}
+}

+ 1 - 1
packages/admin-ui/package.json

@@ -4,7 +4,7 @@
     "license": "GPL-3.0-or-later",
     "scripts": {
         "ng": "ng",
-        "start": "node scripts/set-version.js && ng serve",
+        "dev": "node scripts/set-version.js && ng serve",
         "build:app": "ng build vendure-admin --configuration production",
         "build": "node scripts/copy-package-json.js && node scripts/set-version.js && node scripts/build-public-api.js && ng build vendure-admin-lib --configuration production && node scripts/compile-styles.js",
         "watch": "ng build --watch=true",

+ 5 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts

@@ -31,14 +31,17 @@ export class ProductVariantQuickJumpComponent implements OnInit {
     @Input() productId: string;
     selectedVariantId: string | undefined;
     variants$: Observable<NonNullable<GetProductVariantsQuickJumpQuery['product']>['variants']>;
-    constructor(private dataService: DataService, private router: Router) {}
+    constructor(
+        private dataService: DataService,
+        private router: Router,
+    ) {}
 
     ngOnInit() {
         this.variants$ = this.dataService
             .query(GetProductVariantsQuickJumpDocument, {
                 id: this.productId,
             })
-            .mapSingle(data => data.product?.variants ?? []);
+            .mapStream(data => data.product?.variants ?? []);
     }
 
     searchFn = (

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts

@@ -13,7 +13,7 @@ import { DataService } from '../../../data/providers/data.service';
  * @example
  * ```HTML
  * <vdr-product-variant-selector
- *   (productSelected)="selectResult($event)"></vdr-product-selector>
+ *   (productSelected)="selectResult($event)"></vdr-product-variant-selector>
  * ```
  *
  * @docsCategory components

+ 6 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -187,6 +187,12 @@ export class DynamicFormInputComponent
         if (this.listItems) {
             for (const item of this.listItems) {
                 if (item.componentRef) {
+                    const { value } = item.control;
+                    const { type } = item.componentRef.instance.config || {};
+                    // fix a bug where the list item of string turns into number which lead to unexpected behavior
+                    if (typeof value === 'number' && type === 'string') {
+                        item.control.setValue(item.control.value.toString(), { emitEvent: false });
+                    }
                     this.updateBindings(changes, item.componentRef);
                 }
             }

+ 0 - 1
packages/admin-ui/src/lib/order/src/order.routes.ts

@@ -7,7 +7,6 @@ export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: '',
         component: PageComponent,
-        pathMatch: 'full',
         data: {
             locationId: 'order-list',
             breadcrumb: _('breadcrumb.orders'),

+ 20 - 0
packages/core/e2e/channel.e2e-spec.ts

@@ -370,6 +370,26 @@ describe('Channels', () => {
         expect(product!.channels.map(c => c.id)).toEqual(['T_1']);
     });
 
+    it('Fail to delete the default channel', async () => {
+        await adminClient.asSuperAdmin();
+
+        const defaultChannelId = (
+            await adminClient.query<Codegen.GetChannelsQuery, Codegen.GetChannelsQueryVariables>(GET_CHANNELS)
+        ).channels.items.find(channel => channel.code === DEFAULT_CHANNEL_CODE)?.id;
+
+        expect(defaultChannelId).not.toBeUndefined();
+
+        const mutation = await adminClient.query<
+            Codegen.DeleteChannelMutation,
+            Codegen.DeleteChannelMutationVariables
+        >(DELETE_CHANNEL, { id: defaultChannelId! });
+
+        expect(mutation.deleteChannel).toEqual({
+            result: DeletionResult.NOT_DELETED,
+            message: 'The default Channel cannot be deleted',
+        });
+    });
+
     describe('currencyCode support', () => {
         beforeAll(async () => {
             await adminClient.asSuperAdmin();

+ 19 - 0
packages/core/e2e/order-merge.e2e-spec.ts

@@ -193,6 +193,25 @@ describe('Order merging', () => {
         ).toEqual([{ productVariantId: 'T_5', quantity: 3 }]);
     });
 
+    it('UseGuestStrategy with conflicting lines', async () => {
+        const result = await testMerge({
+            strategy: new UseGuestStrategy(),
+            customerEmailAddress: customers[8].emailAddress,
+            existingOrderLines: [
+                { productVariantId: 'T_7', quantity: 1 },
+                { productVariantId: 'T_8', quantity: 1 },
+            ],
+            guestOrderLines: [{ productVariantId: 'T_8', quantity: 3 }],
+        });
+
+        expect(
+            (result?.lines || []).sort(sortById).map(line => ({
+                productVariantId: line.productVariant.id,
+                quantity: line.quantity,
+            })),
+        ).toEqual([{ productVariantId: 'T_8', quantity: 3 }]);
+    });
+
     it('UseGuestIfExistingEmptyStrategy with empty existing', async () => {
         const result = await testMerge({
             strategy: new UseGuestIfExistingEmptyStrategy(),

+ 5 - 7
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -115,14 +115,12 @@ input CancelOrderInput {
 }
 
 input RefundOrderInput {
-    lines: [OrderLineInput!]!
-    shipping: Money!
-    adjustment: Money!
+    lines: [OrderLineInput!] @deprecated(reason: "Use the `amount` field instead")
+    shipping: Money @deprecated(reason: "Use the `amount` field instead")
+    adjustment: Money @deprecated(reason: "Use the `amount` field instead")
     """
-    If an amount is specified, this value will be used to create a Refund rather than calculating the
-    amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
-    amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
-    version.
+    The amount to be refunded to this particular payment. This was introduced in v2.2.0 as the preferred way to specify the refund amount.
+    Can be as much as the total amount of the payment minus the sum of all previous refunds.
     """
     amount: Money
     paymentId: ID!

+ 1 - 0
packages/core/src/i18n/messages/de.json

@@ -1,6 +1,7 @@
 {
   "error": {
     "cannot-delete-role": "Die Rolle \"{ roleCode }\" kann nicht gelöscht werden",
+    "cannot-delete-default-channel": "Der Standardkanal kann nicht gelöscht werden",
     "cannot-locate-customer-for-user": "Es konnte kein Kunde für den Nutzer gefunden werden",
     "cannot-modify-role": "Die Rolle \"{ roleCode }\" kann nicht geändert werden",
     "cannot-create-sales-for-active-order": "Es kann kein Sale für eine aktive Bestellung erstellt werden",

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -4,6 +4,7 @@
     "available-currency-codes-must-include-default": "availableCurrencyCodes must include the defaultCurrencyCode ({ defaultCurrencyCode })",
     "cannot-delete-role": "The role \"{ roleCode }\" cannot be deleted",
     "cannot-delete-sole-superadmin": "The sole SuperAdmin cannot be deleted",
+    "cannot-delete-default-channel": "The default Channel cannot be deleted",
     "cannot-locate-customer-for-user": "Cannot locate a Customer for the user",
     "cannot-modify-role": "The role \"{ roleCode }\" cannot be modified",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",

+ 6 - 0
packages/core/src/service/services/channel.service.ts

@@ -457,6 +457,12 @@ export class ChannelService {
 
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const channel = await this.connection.getEntityOrThrow(ctx, Channel, id);
+        if (channel.code === DEFAULT_CHANNEL_CODE)
+            return {
+                result: DeletionResult.NOT_DELETED,
+                message: ctx.translate('error.cannot-delete-default-channel'),
+            };
+
         const deletedChannel = new Channel(channel);
         await this.connection.getRepository(ctx, Session).delete({ activeChannelId: id });
         await this.connection.getRepository(ctx, Channel).delete(id);

+ 9 - 0
packages/core/src/service/services/order.service.ts

@@ -1806,6 +1806,15 @@ export class OrderService {
         if (orderToDelete) {
             await this.deleteOrder(ctx, orderToDelete);
         }
+        if (order && linesToDelete) {
+            const orderId = order.id;
+            for (const line of linesToDelete) {
+                const result = await this.removeItemFromOrder(ctx, orderId, line.orderLineId);
+                if (!isGraphQlErrorResult(result)) {
+                    order = result;
+                }
+            }
+        }
         if (order && linesToInsert) {
             const orderId = order.id;
             const result = await this.addItemsToOrder(ctx, orderId, linesToInsert);

+ 6 - 5
packages/core/src/service/services/role.service.ts

@@ -267,7 +267,7 @@ export class RoleService {
                 input.permissions,
             );
         }
-        const updatedRole = patchEntity(role, {
+        patchEntity(role, {
             code: input.code,
             description: input.description,
             permissions: input.permissions
@@ -275,11 +275,12 @@ export class RoleService {
                 : undefined,
         });
         if (targetChannels) {
-            updatedRole.channels = targetChannels;
+            role.channels = targetChannels;
         }
-        await this.connection.getRepository(ctx, Role).save(updatedRole, { reload: false });
-        await this.eventBus.publish(new RoleEvent(ctx, role, 'updated', input));
-        return await assertFound(this.findOne(ctx, role.id));
+        await this.connection.getRepository(ctx, Role).save(role, { reload: false });
+        const updatedRole = await assertFound(this.findOne(ctx, role.id));
+        await this.eventBus.publish(new RoleEvent(ctx, updatedRole, 'updated', input));
+        return updatedRole;
     }
 
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {

+ 1 - 31
packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts

@@ -1,5 +1,4 @@
-import { PageLocationId, addNavMenuSection, registerPageTab } from '@vendure/admin-ui/core';
-import { AngularUiComponent } from './angular-components/angular-ui/angular-ui.component';
+import { addNavMenuSection } from '@vendure/admin-ui/core';
 
 export default [
     addNavMenuSection({
@@ -18,33 +17,4 @@ export default [
             },
         ],
     }),
-    //Testing page tabs on custom angular components
-    registerPageTab({
-        location: 'angular-ui' as PageLocationId,
-        tab: 'Example Tab 1',
-        route: '/extensions/ui-library/angular-ui',
-        tabIcon: 'star',
-        component: AngularUiComponent,
-    }),
-    registerPageTab({
-        location: 'angular-ui' as PageLocationId,
-        tab: 'Example Tab 2',
-        route: '/extensions/ui-library/angular-ui2',
-        tabIcon: 'star',
-        component: AngularUiComponent,
-    }),
-    registerPageTab({
-        location: 'react-ui' as PageLocationId,
-        tab: 'Example Tab 1',
-        route: '/extensions/ui-library/angular-ui',
-        tabIcon: 'star',
-        component: AngularUiComponent,
-    }),
-    registerPageTab({
-        location: 'react-ui' as PageLocationId,
-        tab: 'Example Tab 2',
-        route: '/extensions/ui-library/angular-ui2',
-        tabIcon: 'star',
-        component: AngularUiComponent,
-    }),
 ];

+ 45 - 1
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -41,12 +41,15 @@ import {
 import {
     AddItemToOrderMutation,
     AddItemToOrderMutationVariables,
+    AdjustOrderLineMutation,
+    AdjustOrderLineMutationVariables,
     GetOrderByCodeQuery,
     GetOrderByCodeQueryVariables,
     TestOrderFragmentFragment,
 } from './graphql/generated-shop-types';
 import {
     ADD_ITEM_TO_ORDER,
+    ADJUST_ORDER_LINE,
     APPLY_COUPON_CODE,
     GET_ORDER_BY_CODE
 } from './graphql/shop-queries';
@@ -58,6 +61,7 @@ import {
     GET_MOLLIE_PAYMENT_METHODS,
     refundOrderLine,
     setShipping,
+    testPaymentEligibilityChecker,
 } from './payment-helpers';
 
 const mockData = {
@@ -179,6 +183,9 @@ describe('Mollie payments', () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
+            paymentOptions: {
+                paymentMethodEligibilityCheckers: [testPaymentEligibilityChecker],
+            },
         });
         const env = createTestEnvironment(devConfig);
         serverPort = devConfig.apiOptions.port;
@@ -222,6 +229,10 @@ describe('Mollie payments', () => {
             input: {
                 code: mockData.methodCode,
                 enabled: true,
+                checker: {
+                    code: testPaymentEligibilityChecker.code,
+                    arguments: [],
+                },
                 handler: {
                     code: molliePaymentHandler.code,
                     arguments: [
@@ -388,7 +399,41 @@ describe('Mollie payments', () => {
             });
         });
 
+        it('Should not allow creating intent if payment method is not eligible', async () => {
+            // Set quantity to 9, which is not allowe by our test eligibility checker
+            await shopClient.query<AdjustOrderLineMutation, AdjustOrderLineMutationVariables>(
+                ADJUST_ORDER_LINE,
+                {
+                    orderLineId: order.lines[0].id,
+                    quantity: 9,
+                },
+            );
+            let mollieRequest: any | undefined;
+            nock('https://api.mollie.com/')
+                .post('/v2/orders', body => {
+                    mollieRequest = body;
+                    return true;
+                })
+                .reply(200, mockData.mollieOrderResponse);
+            const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+                input: {
+                    paymentMethodCode: mockData.methodCode,
+                    redirectUrl: 'given-storefront-redirect-url',
+                },
+            });
+            expect(createMolliePaymentIntent.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
+            expect(createMolliePaymentIntent.message).toContain('is not eligible for order');
+        });
+
         it('Should get payment url with deducted amount if a payment is already made', async () => {
+            // Change quantity back to 10
+            await shopClient.query<AdjustOrderLineMutation, AdjustOrderLineMutationVariables>(
+                ADJUST_ORDER_LINE,
+                {
+                    orderLineId: order.lines[0].id,
+                    quantity: 10,
+                },
+            );
             let mollieRequest: any | undefined;
             nock('https://api.mollie.com/')
                 .post('/v2/orders', body => {
@@ -700,7 +745,6 @@ describe('Mollie payments', () => {
                 >(CREATE_PAYMENT_METHOD, {
                     input: {
                         code: mockData.methodCodeBroken,
-
                         enabled: true,
                         handler: {
                             code: molliePaymentHandler.code,

+ 20 - 0
packages/payments-plugin/e2e/payment-helpers.ts

@@ -2,7 +2,9 @@ import { ID } from '@vendure/common/lib/shared-types';
 import {
     ChannelService,
     ErrorResult,
+    LanguageCode,
     OrderService,
+    PaymentMethodEligibilityChecker,
     PaymentService,
     RequestContext,
     assertFound,
@@ -189,6 +191,24 @@ export async function createFreeShippingCoupon(
     }
 }
 
+/**
+ * Test payment eligibility checker that doesn't allow orders with quantity 9 on an order line,
+ * just so that we can easily mock non-eligibility
+ */
+export const testPaymentEligibilityChecker = new PaymentMethodEligibilityChecker({
+    code: 'test-payment-eligibility-checker',
+    description: [{ languageCode: LanguageCode.en, value: 'Do not allow 9 items' }],
+    args: {},
+    check: (ctx, order, args) => {
+        const hasLineWithQuantity9 = order.lines.find(line => line.quantity === 9);
+        if (hasLineWithQuantity9) {
+            return false;
+        } else {
+            return true;
+        }
+    },
+});
+
 export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
     mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) {
         createMolliePaymentIntent(input: $input) {

+ 33 - 17
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -13,6 +13,7 @@ import {
     EntityHydrator,
     ErrorResult,
     ID,
+    idsAreEqual,
     Injector,
     LanguageCode,
     Logger,
@@ -94,16 +95,33 @@ export class MollieService {
         if (order instanceof PaymentIntentError) {
             return order;
         }
-        await this.entityHydrator.hydrate(ctx, order, {
-            relations: [
-                'customer',
-                'surcharges',
-                'lines.productVariant',
-                'lines.productVariant.translations',
-                'shippingLines.shippingMethod',
-                'payments',
-            ],
-        });
+        if (!paymentMethod) {
+            return new PaymentIntentError(`No paymentMethod found with code ${String(paymentMethodCode)}`);
+        }
+        const [eligiblePaymentMethods] = await Promise.all([
+            this.orderService.getEligiblePaymentMethods(ctx, order.id),
+            await this.entityHydrator.hydrate(ctx, order, {
+                relations: [
+                    'customer',
+                    'surcharges',
+                    'lines.productVariant',
+                    'lines.productVariant.translations',
+                    'shippingLines.shippingMethod',
+                    'payments',
+                ],
+            }),
+        ]);
+        if (
+            !eligiblePaymentMethods.find(
+                eligibleMethod =>
+                    idsAreEqual(eligibleMethod.id, paymentMethod?.id) && eligibleMethod.isEligible,
+            )
+        ) {
+            // Given payment method code is not eligible for this order
+            return new InvalidInputError(
+                `Payment method ${paymentMethod?.code} is not eligible for order ${order.code}`,
+            );
+        }
         if (order.state !== 'ArrangingPayment' && order.state !== 'ArrangingAdditionalPayment') {
             // Pre-check if order is transitionable to ArrangingPayment, because that will happen after Mollie payment
             try {
@@ -125,9 +143,6 @@ export class MollieService {
                 'Cannot create payment intent for order with customer that has no lastName set',
             );
         }
-        if (!paymentMethod) {
-            return new PaymentIntentError(`No paymentMethod found with code ${String(paymentMethodCode)}`);
-        }
         let redirectUrl = input.redirectUrl;
         if (!redirectUrl) {
             // Use fallback redirect if no redirectUrl is given
@@ -264,7 +279,12 @@ export class MollieService {
                 `Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`,
             );
         }
+        if (mollieOrder.status === OrderStatus.expired) {
+            // Expired is fine, a customer can retry the payment later
+            return;
+        }
         if (order.orderPlacedAt) {
+            // Verify if the Vendure order isn't already paid for, and log if so
             const paymentWithSameTransactionId = order.payments.find(
                 p => p.transactionId === mollieOrder.id && p.state === 'Settled',
             );
@@ -293,10 +313,6 @@ export class MollieService {
             return;
         }
         const amount = amountToCents(mollieOrder.amount);
-        if (mollieOrder.status === OrderStatus.expired) {
-            // Expired is fine, a customer can retry the payment later
-            return;
-        }
         if (mollieOrder.status === OrderStatus.paid) {
             // Paid is only used by 1-step payments without Authorized state. This will settle immediately
             await this.addPayment(ctx, order, amount, mollieOrder, paymentMethod.code, 'Settled');

+ 2 - 1
packages/testing/package.json

@@ -30,7 +30,8 @@
         "build": "tsc -p ./tsconfig.build.json",
         "watch": "tsc -p ./tsconfig.build.json -w",
         "lint": "eslint --fix .",
-        "ci": "npm run build"
+        "ci": "npm run build",
+        "test": "vitest --config vitest.config.mts --run"
     },
     "bugs": {
         "url": "https://github.com/vendure-ecommerce/vendure/issues"

+ 49 - 18
packages/testing/src/simple-graphql-client.ts

@@ -44,7 +44,10 @@ export class SimpleGraphQLClient {
         'Apollo-Require-Preflight': 'true',
     };
 
-    constructor(private vendureConfig: Required<VendureConfig>, private apiUrl: string = '') {}
+    constructor(
+        private vendureConfig: Required<VendureConfig>,
+        private apiUrl: string = '',
+    ) {}
 
     /**
      * @description
@@ -136,15 +139,13 @@ export class SimpleGraphQLClient {
     async asUserWithCredentials(username: string, password: string) {
         // first log out as the current user
         if (this.authToken) {
-            await this.query(
-                gql`
-                    mutation {
-                        logout {
-                            success
-                        }
+            await this.query(gql`
+                mutation {
+                    logout {
+                        success
                     }
-                `,
-            );
+                }
+            `);
         }
         const result = await this.query(LOGIN, { username, password });
         if (result.login.channels?.length === 1) {
@@ -170,15 +171,13 @@ export class SimpleGraphQLClient {
      * Logs out so that the client is then treated as an anonymous user.
      */
     async asAnonymousUser() {
-        await this.query(
-            gql`
-                mutation {
-                    logout {
-                        success
-                    }
+        await this.query(gql`
+            mutation {
+                logout {
+                    success
                 }
-            `,
-        );
+            }
+        `);
     }
 
     private async makeGraphQlRequest(
@@ -214,7 +213,36 @@ export class SimpleGraphQLClient {
      * Perform a file upload mutation.
      *
      * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+     *
      * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
+     *
+     * @param mutation - GraphQL document for a mutation that has input files
+     * with the Upload type.
+     * @param filePaths - Array of paths to files, in the same order that the
+     * corresponding Upload fields appear in the variables for the mutation.
+     * @param mapVariables - Function that must return the variables for the
+     * mutation, with `null` as the value for each `Upload` field.
+     *
+     * @example
+     * // Testing a custom mutation:
+     * const result = await client.fileUploadMutation({
+     *   mutation: gql`
+     *     mutation AddSellerImages($input: AddSellerImagesInput!) {
+     *       addSellerImages(input: $input) {
+     *         id
+     *         name
+     *       }
+     *     }
+     *   `,
+     *   filePaths: ['./images/profile-picture.jpg', './images/logo.png'],
+     *   mapVariables: () => ({
+     *     name: "George's Pans",
+     *     profilePicture: null,  // corresponds to filePaths[0]
+     *     branding: {
+     *       logo: null  // corresponds to filePaths[1]
+     *     }
+     *   })
+     * });
      */
     async fileUploadMutation(options: {
         mutation: DocumentNode;
@@ -256,7 +284,10 @@ export class SimpleGraphQLClient {
 }
 
 export class ClientError extends Error {
-    constructor(public response: any, public request: any) {
+    constructor(
+        public response: any,
+        public request: any,
+    ) {
         super(ClientError.extractMessage(response));
     }
     private static extractMessage(response: any): string {

+ 59 - 11
packages/testing/src/utils/create-upload-post-data.spec.ts

@@ -1,4 +1,5 @@
 import gql from 'graphql-tag';
+import { describe, it, assert } from 'vitest';
 
 import { createUploadPostData } from './create-upload-post-data';
 
@@ -8,8 +9,16 @@ describe('createUploadPostData()', () => {
             gql`
                 mutation CreateAssets($input: [CreateAssetInput!]!) {
                     createAssets(input: $input) {
-                        id
-                        name
+                        ... on Asset {
+                            id
+                            name
+                        }
+                        ... on MimeTypeError {
+                            errorCode
+                            message
+                            fileName
+                            mimeType
+                        }
                     }
                 }
             `,
@@ -19,15 +28,18 @@ describe('createUploadPostData()', () => {
             }),
         );
 
-        expect(result.operations.operationName).toBe('CreateAssets');
-        expect(result.operations.variables).toEqual({
+        assert.equal(result.operations.operationName, 'CreateAssets');
+        assert.deepEqual(result.operations.variables, {
             input: [{ file: null }, { file: null }],
         });
-        expect(result.map).toEqual({
+        assert.deepEqual(result.map, {
             0: 'variables.input.0.file',
             1: 'variables.input.1.file',
         });
-        expect(result.filePaths).toEqual([{ name: '0', file: 'a.jpg' }, { name: '1', file: 'b.jpg' }]);
+        assert.deepEqual(result.filePaths, [
+            { name: '0', file: 'a.jpg' },
+            { name: '1', file: 'b.jpg' },
+        ]);
     });
 
     it('creates correct output for importProducts mutation', () => {
@@ -36,7 +48,7 @@ describe('createUploadPostData()', () => {
                 mutation ImportProducts($input: Upload!) {
                     importProducts(csvFile: $input) {
                         errors
-                        importedCount
+                        imported
                     }
                 }
             `,
@@ -44,11 +56,47 @@ describe('createUploadPostData()', () => {
             () => ({ csvFile: null }),
         );
 
-        expect(result.operations.operationName).toBe('ImportProducts');
-        expect(result.operations.variables).toEqual({ csvFile: null });
-        expect(result.map).toEqual({
+        assert.equal(result.operations.operationName, 'ImportProducts');
+        assert.deepEqual(result.operations.variables, { csvFile: null });
+        assert.deepEqual(result.map, {
             0: 'variables.csvFile',
         });
-        expect(result.filePaths).toEqual([{ name: '0', file: 'data.csv' }]);
+        assert.deepEqual(result.filePaths, [{ name: '0', file: 'data.csv' }]);
+    });
+
+    it('creates correct output for a mutation with nested Upload and non-Upload fields', () => {
+        // this is not meant to be a real mutation; it's just an example of one
+        // that could exist
+        const result = createUploadPostData(
+            gql`
+                mutation ComplexUpload($input: ComplexTypeIncludingUpload!) {
+                    complexUpload(input: $input) {
+                        results
+                        errors
+                    }
+                }
+            `,
+            // the two file paths that are specified must appear in the same
+            // order as the `null` variables that stand in for the Upload fields
+            ['logo.png', 'profilePicture.jpg'],
+            () => ({ name: 'George', sellerLogo: null, someOtherThing: { profilePicture: null } }),
+        );
+
+        assert.equal(result.operations.operationName, 'ComplexUpload');
+        assert.deepEqual(result.operations.variables, {
+            name: 'George',
+            sellerLogo: null,
+            someOtherThing: { profilePicture: null },
+        });
+        // `result.map` should map `result.filePaths` onto the Upload fields
+        // implied by `variables`
+        assert.deepEqual(result.map, {
+            0: 'variables.sellerLogo',
+            1: 'variables.someOtherThing.profilePicture',
+        });
+        assert.deepEqual(result.filePaths, [
+            { name: '0', file: 'logo.png' },
+            { name: '1', file: 'profilePicture.jpg' },
+        ]);
     });
 });

+ 80 - 25
packages/testing/src/utils/create-upload-post-data.ts

@@ -1,27 +1,72 @@
 import { DocumentNode, Kind, OperationDefinitionNode, print } from 'graphql';
 
-export interface FilePlaceholder {
-    file: null;
-}
 export interface UploadPostData<V = any> {
+    /**
+     * Data from a GraphQL document that takes the Upload type as input
+     */
     operations: {
         operationName: string;
         variables: V;
         query: string;
     };
 
+    /**
+     * A map from index values to variable paths. Maps files in the `filePaths`
+     * array to fields with the Upload type in the GraphQL mutation input.
+     *
+     * If this was the GraphQL mutation input type:
+     * ```graphql
+     * input ImageReceivingInput {
+     *   bannerImage: Upload!
+     *   logo: Upload!
+     * }
+     * ```
+     *
+     * And this was the GraphQL mutation:
+     * ```graphql
+     * addSellerImages(input: ImageReceivingInput!): Seller
+     * ```
+     *
+     * Then this would be the value for `map`:
+     * ```js
+     * {
+     *   0: 'variables.input.bannerImage',
+     *   1: 'variables.input.logo'
+     * }
+     * ```
+     */
     map: {
         [index: number]: string;
     };
+
+    /**
+     * Array of file paths. Mapped to a GraphQL mutation input variable by
+     * `map`.
+     */
     filePaths: Array<{
+        /**
+         * Index of the file path as a string.
+         */
         name: string;
+        /**
+         * The actual file path
+         */
         file: string;
     }>;
 }
 
 /**
- * Creates a data structure which can be used to mae a curl request to upload files to a mutation using
- * the Upload type.
+ * Creates a data structure which can be used to make a POST request to upload
+ * files to a mutation using the Upload type.
+ *
+ * @param mutation - The GraphQL document for a mutation that takes an Upload
+ * type as an input
+ * @param filePaths - Either a single path or an array of paths to the files
+ * that should be uploaded
+ * @param mapVariables - A function that will receive `filePaths` and return an
+ * object containing the input variables for the mutation, where every field
+ * with the Upload type has the value `null`.
+ * @returns an UploadPostData object.
  */
 export function createUploadPostData<P extends string[] | string, V>(
     mutation: DocumentNode,
@@ -40,9 +85,7 @@ export function createUploadPostData<P extends string[] | string, V>(
             variables,
             query: print(mutation),
         },
-        map: filePathsArray.reduce((output, filePath, i) => {
-            return { ...output, [i.toString()]: objectPath(variables, i).join('.') };
-        }, {} as Record<number, string>),
+        map: objectPath(variables).reduce((acc, path, i) => ({ ...acc, [i.toString()]: path }), {}),
         filePaths: filePathsArray.map((filePath, i) => ({
             name: i.toString(),
             file: filePath,
@@ -51,23 +94,35 @@ export function createUploadPostData<P extends string[] | string, V>(
     return postData;
 }
 
-function objectPath(variables: any, i: number): Array<string | number> {
-    const path: Array<string | number> = ['variables'];
-    let current = variables;
-    while (current !== null) {
-        const props = Object.getOwnPropertyNames(current);
-        if (props) {
-            const firstProp = props[0];
-            const val = current[firstProp];
-            if (Array.isArray(val)) {
-                path.push(firstProp);
-                path.push(i);
-                current = val[0];
-            } else {
-                path.push(firstProp);
-                current = val;
+/**
+ * This function visits each property in the `variables` object, including
+ * nested ones, and returns the path of each null value, in order.
+ *
+ * @example
+ * // variables:
+ * {
+ *   input: {
+ *     name: "George's Pots and Pans",
+ *     logo: null,
+ *     user: {
+ *       profilePicture: null
+ *     }
+ *   }
+ * }
+ * // return value:
+ * ['variables.input.logo', 'variables.input.user.profilePicture']
+ */
+function objectPath(variables: any): string[] {
+    const pathsToNulls: string[] = [];
+    const checkValue = (pathSoFar: string, value: any) => {
+        if (value === null) {
+            pathsToNulls.push(pathSoFar);
+        } else if (typeof value === 'object') {
+            for (const key of Object.getOwnPropertyNames(value)) {
+                checkValue(`${pathSoFar}.${key}`, value[key]);
             }
         }
-    }
-    return path;
+    };
+    checkValue('variables', variables);
+    return pathsToNulls;
 }

+ 18 - 0
packages/testing/vitest.config.mts

@@ -0,0 +1,18 @@
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+    plugins: [
+        // SWC required to support decorators used in test plugins
+        // See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479
+        // Vite plugin
+        swc.vite({
+            jsc: {
+                transform: {
+                    // See https://github.com/vendure-ecommerce/vendure/issues/2099
+                    useDefineForClassFields: false,
+                },
+            },
+        }),
+    ],
+});