diff --git a/.changeset/cart-freeze.md b/.changeset/cart-freeze.md new file mode 100644 index 00000000..db23acf4 --- /dev/null +++ b/.changeset/cart-freeze.md @@ -0,0 +1,17 @@ +--- +"@labdigital/commercetools-mock": minor +--- + +Support the `freezeCart` and `unfreezeCart` update actions on carts. + +`cartState: "Frozen"` was unreachable, so a checkout that freezes a cart while a +payment is in flight could not be exercised — and, worse, a test asserting that +a code path left the cart alone passed whether or not the guard existed. + +`freezeCart` requires the cart to be `Active` and `unfreezeCart` requires it to +be `Frozen`; both raise `InvalidOperation` otherwise. While a cart is frozen, +update actions that would change what it costs are rejected with +`InvalidOperation`, matching the documented purpose of freezing. Actions that +leave the price alone (`setCustomerEmail`, `setCustomField`, addresses, …) are +still applied, and `unfreezeCart` earlier in the same action list lifts the +restriction for the actions that follow it. diff --git a/src/repositories/abstract.ts b/src/repositories/abstract.ts index 0a73bf4f..d22a6b90 100644 --- a/src/repositories/abstract.ts +++ b/src/repositories/abstract.ts @@ -372,6 +372,15 @@ export class AbstractUpdateHandler { } } + /** + * Hook to reject an action based on the state the resource is in at the + * point the action is applied, rather than the state it started in. + */ + protected beforeAction( + resource: BaseResource | Project, + action: UpdateAction, + ): void {} + async apply( context: RepositoryContext, resource: R, @@ -403,6 +412,11 @@ export class AbstractUpdateHandler { }); } + this.beforeAction( + updatedResource as unknown as BaseResource | Project, + action, + ); + // @ts-expect-error const updateFunc = this[action.action].bind(this); diff --git a/src/repositories/cart/actions.ts b/src/repositories/cart/actions.ts index 3db3f76e..89b7be40 100644 --- a/src/repositories/cart/actions.ts +++ b/src/repositories/cart/actions.ts @@ -11,6 +11,7 @@ import type { CartChangeCustomLineItemQuantityAction, CartChangeLineItemQuantityAction, CartChangeTaxRoundingModeAction, + CartFreezeCartAction, CartRemoveCustomLineItemAction, CartRemoveDiscountCodeAction, CartRemoveLineItemAction, @@ -42,6 +43,7 @@ import type { CartSetShippingMethodAction, CartSetShippingMethodTaxAmountAction, CartSetShippingMethodTaxRateAction, + CartUnfreezeCartAction, CartUpdateAction, CustomFields, GeneralError, @@ -89,6 +91,38 @@ import { } from "./helpers.ts"; import type { CartRepository } from "./index.ts"; +/** + * Freezing a cart locks in its prices, so the actions that would change what + * the cart costs are rejected while it is frozen. + * See https://docs.commercetools.com/api/projects/carts#freeze-cart + */ +const PRICE_CHANGING_ACTIONS = new Set([ + "addCustomLineItem", + "addDiscountCode", + "addLineItem", + "changeCustomLineItemMoney", + "changeCustomLineItemQuantity", + "changeLineItemQuantity", + "changeTaxRoundingMode", + "recalculate", + "removeCustomLineItem", + "removeDiscountCode", + "removeLineItem", + "removeShippingMethod", + "setCartTotalTax", + "setCountry", + "setCustomLineItemTaxAmount", + "setCustomLineItemTaxRate", + "setCustomShippingMethod", + "setDirectDiscounts", + "setLineItemPrice", + "setLineItemTaxAmount", + "setLineItemTaxRate", + "setShippingMethod", + "setShippingMethodTaxAmount", + "setShippingMethodTaxRate", +]); + export class CartUpdateHandler extends AbstractUpdateHandler implements Partial> @@ -123,6 +157,50 @@ export class CartUpdateHandler return updated; } + protected beforeAction( + resource: BaseResource | Project, + action: UpdateAction, + ) { + const cart = resource as unknown as Cart; + if ( + cart.cartState === "Frozen" && + PRICE_CHANGING_ACTIONS.has(action.action) + ) { + throw new CommercetoolsError({ + code: "InvalidOperation", + message: `The cart with ID '${cart.id}' is frozen and cannot be modified by the action '${action.action}'.`, + }); + } + } + + freezeCart( + _context: RepositoryContext, + resource: Writable, + _action: CartFreezeCartAction, + ) { + if (resource.cartState !== "Active") { + throw new CommercetoolsError({ + code: "InvalidOperation", + message: `The cart with ID '${resource.id}' cannot be frozen because it is in state '${resource.cartState}'.`, + }); + } + resource.cartState = "Frozen"; + } + + unfreezeCart( + _context: RepositoryContext, + resource: Writable, + _action: CartUnfreezeCartAction, + ) { + if (resource.cartState !== "Frozen") { + throw new CommercetoolsError({ + code: "InvalidOperation", + message: `The cart with ID '${resource.id}' cannot be unfrozen because it is in state '${resource.cartState}'.`, + }); + } + resource.cartState = "Active"; + } + addItemShippingAddress( context: RepositoryContext, resource: Writable, diff --git a/src/services/cart.test.ts b/src/services/cart.test.ts index 5ae87169..1d20e870 100644 --- a/src/services/cart.test.ts +++ b/src/services/cart.test.ts @@ -385,6 +385,157 @@ describe("Cart Update Actions", () => { expect(response.json().paymentInfo).toBeUndefined(); }); + test("freezeCart", async () => { + assert(cart, "cart not created"); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().version).toBe(2); + expect(response.json().cartState).toBe("Frozen"); + }); + + test("unfreezeCart", async () => { + assert(cart, "cart not created"); + + const frozen = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + expect(frozen.statusCode).toBe(200); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 2, actions: [{ action: "unfreezeCart" }] }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().version).toBe(3); + expect(response.json().cartState).toBe("Active"); + }); + + test("freezeCart on an already frozen cart", async () => { + assert(cart, "cart not created"); + + await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 2, actions: [{ action: "freezeCart" }] }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("unfreezeCart on a cart that is not frozen", async () => { + assert(cart, "cart not created"); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "unfreezeCart" }] }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("a frozen cart rejects actions that change its price", async () => { + const product = await productFactory.create(productDraft); + + assert(cart, "cart not created"); + + await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { + version: 2, + actions: [ + { + action: "addLineItem", + productId: product.id, + variantId: product.masterData.current.variants[0].id, + }, + ], + }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + + const unchanged = await ctMock.app.inject({ + method: "GET", + url: `/dummy/carts/${cart.id}`, + }); + expect(unchanged.json().version).toBe(2); + expect(unchanged.json().lineItems).toHaveLength(0); + }); + + test("a frozen cart accepts actions that do not change its price", async () => { + assert(cart, "cart not created"); + + await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { + version: 2, + actions: [{ action: "setCustomerEmail", email: "john@doe.com" }], + }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().customerEmail).toBe("john@doe.com"); + }); + + test("unfreezeCart in the same request as a price changing action", async () => { + const product = await productFactory.create(productDraft); + + assert(cart, "cart not created"); + + await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { version: 1, actions: [{ action: "freezeCart" }] }, + }); + + const response = await ctMock.app.inject({ + method: "POST", + url: `/dummy/carts/${cart.id}`, + payload: { + version: 2, + actions: [ + { action: "unfreezeCart" }, + { + action: "addLineItem", + productId: product.id, + variantId: product.masterData.current.variants[0].id, + }, + ], + }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().cartState).toBe("Active"); + expect(response.json().lineItems).toHaveLength(1); + }); + test.each([ ["EUR", 29800], ["GBP", 37800],