Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/cart-freeze.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions src/repositories/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<R extends BaseResource | Project>(
context: RepositoryContext,
resource: R,
Expand Down Expand Up @@ -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);

Expand Down
78 changes: 78 additions & 0 deletions src/repositories/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
CartChangeCustomLineItemQuantityAction,
CartChangeLineItemQuantityAction,
CartChangeTaxRoundingModeAction,
CartFreezeCartAction,
CartRemoveCustomLineItemAction,
CartRemoveDiscountCodeAction,
CartRemoveLineItemAction,
Expand Down Expand Up @@ -42,6 +43,7 @@ import type {
CartSetShippingMethodAction,
CartSetShippingMethodTaxAmountAction,
CartSetShippingMethodTaxRateAction,
CartUnfreezeCartAction,
CartUpdateAction,
CustomFields,
GeneralError,
Expand Down Expand Up @@ -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<UpdateHandlerInterface<Cart, CartUpdateAction>>
Expand Down Expand Up @@ -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<InvalidOperationError>({
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<Cart>,
_action: CartFreezeCartAction,
) {
if (resource.cartState !== "Active") {
throw new CommercetoolsError<InvalidOperationError>({
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<Cart>,
_action: CartUnfreezeCartAction,
) {
if (resource.cartState !== "Frozen") {
throw new CommercetoolsError<InvalidOperationError>({
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<Cart>,
Expand Down
151 changes: 151 additions & 0 deletions src/services/cart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading