diff --git a/CHANGELOG.md b/CHANGELOG.md index e2528e8b67..3920ba7e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## 3.0.6 (2024-11-15) + + +#### 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) + ## 3.0.5 (2024-10-15) diff --git a/docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md b/docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md index 735f7678a6..96a1746ddd 100644 --- a/docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md +++ b/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 + (productSelected)="selectResult($event)"> ``` ```ts title="Signature" diff --git a/license/signatures/version1/cla.json b/license/signatures/version1/cla.json index f8f21b3f32..a296d28d49 100644 --- a/license/signatures/version1/cla.json +++ b/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 } ] -} \ No newline at end of file +} diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index fe5b3de8f3..37775583d1 100644 --- a/packages/admin-ui/package.json +++ b/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", diff --git a/packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts b/packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts index a72ea26816..6f2421153c 100644 --- a/packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts +++ b/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['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 = ( diff --git a/packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts index bf16b701b4..56b2f1c30c 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts +++ b/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 * + * (productSelected)="selectResult($event)"> * ``` * * @docsCategory components diff --git a/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts b/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts index 2e1ce65cbb..402fa0c161 100644 --- a/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts +++ b/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); } } diff --git a/packages/admin-ui/src/lib/order/src/order.routes.ts b/packages/admin-ui/src/lib/order/src/order.routes.ts index 9295260d63..770a02218d 100644 --- a/packages/admin-ui/src/lib/order/src/order.routes.ts +++ b/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'), diff --git a/packages/core/e2e/channel.e2e-spec.ts b/packages/core/e2e/channel.e2e-spec.ts index 085c1ec2ef..6c4c1d2d04 100644 --- a/packages/core/e2e/channel.e2e-spec.ts +++ b/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(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(); diff --git a/packages/core/e2e/order-merge.e2e-spec.ts b/packages/core/e2e/order-merge.e2e-spec.ts index 259813c310..d6fba19681 100644 --- a/packages/core/e2e/order-merge.e2e-spec.ts +++ b/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(), diff --git a/packages/core/src/api/schema/admin-api/order.api.graphql b/packages/core/src/api/schema/admin-api/order.api.graphql index 5a1b3b9b3d..ed64cdaea9 100644 --- a/packages/core/src/api/schema/admin-api/order.api.graphql +++ b/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! diff --git a/packages/core/src/i18n/messages/de.json b/packages/core/src/i18n/messages/de.json index 0e8f7a693d..c618cca2c5 100644 --- a/packages/core/src/i18n/messages/de.json +++ b/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", diff --git a/packages/core/src/i18n/messages/en.json b/packages/core/src/i18n/messages/en.json index b63615971f..c3b66e4ad7 100644 --- a/packages/core/src/i18n/messages/en.json +++ b/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", diff --git a/packages/core/src/service/services/channel.service.ts b/packages/core/src/service/services/channel.service.ts index 57e49e7ee8..0427310604 100644 --- a/packages/core/src/service/services/channel.service.ts +++ b/packages/core/src/service/services/channel.service.ts @@ -457,6 +457,12 @@ export class ChannelService { async delete(ctx: RequestContext, id: ID): Promise { 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); diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 21cd8e7ba9..14307c7c34 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/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); diff --git a/packages/core/src/service/services/role.service.ts b/packages/core/src/service/services/role.service.ts index f551de8bdb..1a1d9f66d6 100644 --- a/packages/core/src/service/services/role.service.ts +++ b/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 { diff --git a/packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts b/packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts index 3f8d891aff..3badfd25b5 100644 --- a/packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts +++ b/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, - }), ]; diff --git a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts index 081089a4e7..3abdef0611 100644 --- a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts +++ b/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( + 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( + 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, diff --git a/packages/payments-plugin/e2e/payment-helpers.ts b/packages/payments-plugin/e2e/payment-helpers.ts index 24a5ac971a..55687b71b2 100644 --- a/packages/payments-plugin/e2e/payment-helpers.ts +++ b/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) { diff --git a/packages/payments-plugin/src/mollie/mollie.service.ts b/packages/payments-plugin/src/mollie/mollie.service.ts index 995fe7b495..0e2dbea9ab 100644 --- a/packages/payments-plugin/src/mollie/mollie.service.ts +++ b/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'); diff --git a/packages/testing/package.json b/packages/testing/package.json index 631d00a47b..8383dba170 100644 --- a/packages/testing/package.json +++ b/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" diff --git a/packages/testing/src/simple-graphql-client.ts b/packages/testing/src/simple-graphql-client.ts index 044454c937..cb8010b85e 100644 --- a/packages/testing/src/simple-graphql-client.ts +++ b/packages/testing/src/simple-graphql-client.ts @@ -44,7 +44,10 @@ export class SimpleGraphQLClient { 'Apollo-Require-Preflight': 'true', }; - constructor(private vendureConfig: Required, private apiUrl: string = '') {} + constructor( + private vendureConfig: Required, + 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 { diff --git a/packages/testing/src/utils/create-upload-post-data.spec.ts b/packages/testing/src/utils/create-upload-post-data.spec.ts index d139b2ada8..3df46ed9a0 100644 --- a/packages/testing/src/utils/create-upload-post-data.spec.ts +++ b/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' }, + ]); }); }); diff --git a/packages/testing/src/utils/create-upload-post-data.ts b/packages/testing/src/utils/create-upload-post-data.ts index f52e760ea9..f1c66cf6fb 100644 --- a/packages/testing/src/utils/create-upload-post-data.ts +++ b/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 { + /** + * 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

( mutation: DocumentNode, @@ -40,9 +85,7 @@ export function createUploadPostData

( variables, query: print(mutation), }, - map: filePathsArray.reduce((output, filePath, i) => { - return { ...output, [i.toString()]: objectPath(variables, i).join('.') }; - }, {} as Record), + 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

( return postData; } -function objectPath(variables: any, i: number): Array { - const path: Array = ['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; } diff --git a/packages/testing/vitest.config.mts b/packages/testing/vitest.config.mts new file mode 100644 index 0000000000..f97b180653 --- /dev/null +++ b/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, + }, + }, + }), + ], +});