diff --git a/packages/api-plugin-carts/package.json b/packages/api-plugin-carts/package.json index 7d10db09434..7bca207ad9a 100644 --- a/packages/api-plugin-carts/package.json +++ b/packages/api-plugin-carts/package.json @@ -31,6 +31,7 @@ "@reactioncommerce/random": "^1.0.2", "@reactioncommerce/reaction-error": "^1.0.1", "accounting-js": "^1.1.1", + "graphql-subscriptions": "^2.0.0", "lodash": "^4.17.15", "simpl-schema": "^1.12.0" }, diff --git a/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.js b/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.js new file mode 100644 index 00000000000..f884f6d6e45 --- /dev/null +++ b/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.js @@ -0,0 +1,49 @@ +import { withFilter } from "graphql-subscriptions"; +import ReactionError from "@reactioncommerce/reaction-error"; +import hashToken from "@reactioncommerce/api-utils/hashToken.js"; + +/** + * @summary Filters the subscription result + * @param {Object} payload - The subscription payload + * @param {Object} variables - The subscription variables + * @returns {Boolean} - Whether the subscription result should be sent to the client + */ +export function filter(payload, variables) { + const { cartUpdated: cart } = payload; + const { input: { cartId, accountId, cartToken } } = variables; + + if (!cart) return false; + if (cart._id !== cartId) return false; + if (accountId) return cart.accountId === accountId; + return cart.anonymousAccessToken === hashToken(cartToken); +} + +/** + * @summary Publishes the updated cart to the client + * @param {Object} _ unused + * @param {Object} args - The arguments passed to the subscription + * @param {Object} context - The application context + * @returns {Promise} the filtered subscription result + */ +async function cartUpdated(_, args, context) { + const { input: { cartId, accountId, cartToken } } = args; + const { collections: { Cart, Accounts }, userId } = context; + + const selector = { _id: cartId }; + if (accountId) { + const account = await Accounts.findOne({ _id: accountId, userId }); + if (!account) throw new ReactionError("invalid-params", "Account id does not match user id"); + selector.accountId = accountId; + } else { + selector.anonymousAccessToken = hashToken(cartToken); + } + + const cart = await Cart.findOne(selector); + if (!cart) throw new ReactionError("not-found", "Cart not found"); + + return withFilter(() => context.pubSub.asyncIterator(["CART_UPDATED"]), filter)(_, args, context); +} + +export default { + subscribe: cartUpdated +}; diff --git a/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.test.js b/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.test.js new file mode 100644 index 00000000000..e1125a00912 --- /dev/null +++ b/packages/api-plugin-carts/src/resolvers/Subscription/cartUpdated.test.js @@ -0,0 +1,121 @@ +import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js"; +import { withFilter } from "graphql-subscriptions"; +import hashToken from "@reactioncommerce/api-utils/hashToken.js"; +import cartUpdated, { filter } from "./cartUpdated.js"; + +jest.mock("@reactioncommerce/api-utils/hashToken.js", () => jest.fn().mockName("hashToken")); +jest.mock("graphql-subscriptions", () => ({ withFilter: jest.fn() })); + +mockContext.pubSub = { + asyncIterator: jest.fn().mockName("pubSub.asyncIterator") +}; + +beforeEach(() => jest.resetAllMocks()); + +test("filter returns false when payload does't contain cart", () => { + const payload = {}; + const variables = { input: {} }; + expect(filter(payload, variables)).toBe(false); +}); + +test("filter returns false when the input does't contain cardId", () => { + const payload = { cartUpdated: { _id: "cartId" } }; + const variables = { input: { accountId: "accountId" } }; + expect(filter(payload, variables)).toBe(false); +}); + +test("filter return true when provided correct cartId and accountId input", () => { + const payload = { cartUpdated: { _id: "cartId", accountId: "accountId" } }; + const variables = { input: { cartId: "cartId", accountId: "accountId" } }; + expect(filter(payload, variables)).toBe(true); +}); + +test("filter return false when provided incorrect cartId and accountId input", () => { + const payload = { cartUpdated: { _id: "cartId", accountId: "accountId" } }; + const variables = { input: { cartId: "cartId", accountId: "incorrectAccountId" } }; + expect(filter(payload, variables)).toBe(false); +}); + +test("filter return true when provided correct cartId and cartToken input", () => { + hashToken.mockReturnValueOnce("hashToken"); + const payload = { cartUpdated: { _id: "cartId", anonymousAccessToken: "hashToken" } }; + const variables = { input: { cartId: "cartId", cartToken: "token" } }; + expect(filter(payload, variables)).toBe(true); +}); + +test("filter return false when provided incorrect cartId and cartToken input", () => { + hashToken.mockReturnValueOnce("incorrectHashToken"); + const payload = { cartUpdated: { _id: "cartId", anonymousAccessToken: "hashToken" } }; + const variables = { input: { cartId: "cartId", cartToken: "token" } }; + expect(filter(payload, variables)).toBe(false); +}); + +test("cartUpdated throws invalid-params error when input is provided accountId but context is'nt contain userId", async () => { + const context = { ...mockContext }; + const args = { input: { cartId: "cartId", accountId: "accountId" } }; + context.userId = undefined; + context.collections.Accounts.findOne.mockReturnValueOnce(Promise.resolve(null)); + + try { + await cartUpdated.subscribe(null, args, context); + } catch (error) { + expect(error.error).toEqual("invalid-params"); + } +}); + +test("cartUpdated find cart with accountId when input is provided accountId", async () => { + const mockFn = jest.fn(); + // eslint-disable-next-line no-unused-vars + withFilter.mockImplementation((subscribe, _filterFn) => { + subscribe(); + return mockFn; + }); + + const context = { ...mockContext }; + const args = { input: { cartId: "cartId", accountId: "accountId" } }; + context.userId = "userId"; + context.collections.Accounts.findOne.mockReturnValueOnce(Promise.resolve({ _id: "accountId", userId: "userId" })); + context.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve({ _id: "cartId", accountId: "accountId" })); + + await cartUpdated.subscribe(null, args, context); + expect(context.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId", accountId: "accountId" }); +}); + +test("cartUpdated find cart with anonymousAccessToken when input is provided cartToken", async () => { + const mockFn = jest.fn(); + // eslint-disable-next-line no-unused-vars + withFilter.mockImplementation((subscribe, _filterFn) => { + subscribe(); + return mockFn; + }); + hashToken.mockReturnValueOnce("hashToken"); + + const context = { ...mockContext }; + const args = { input: { cartId: "cartId", cartToken: "token" } }; + context.userId = "userId"; + context.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve({ _id: "cartId", anonymousAccessToken: "hashToken" })); + + await cartUpdated.subscribe(null, args, context); + expect(context.collections.Cart.findOne).toHaveBeenCalledWith({ _id: "cartId", anonymousAccessToken: "hashToken" }); +}); + +test("cartUpdated should throws not-found error when cart is not found", async () => { + const mockFn = jest.fn(); + // eslint-disable-next-line no-unused-vars + withFilter.mockImplementation((subscribe, _filterFn) => { + subscribe(); + return mockFn; + }); + + const context = { ...mockContext }; + const args = { input: { cartId: "cartId", accountId: "accountId" } }; + context.userId = "userId"; + context.collections.Accounts.findOne.mockReturnValueOnce(Promise.resolve({ _id: "accountId", userId: "userId" })); + context.collections.Cart.findOne.mockReturnValueOnce(Promise.resolve(null)); + + try { + await cartUpdated.subscribe(null, args, context); + } catch (error) { + expect(error.error).toEqual("not-found"); + } +}); diff --git a/packages/api-plugin-carts/src/resolvers/Subscription/index.js b/packages/api-plugin-carts/src/resolvers/Subscription/index.js new file mode 100644 index 00000000000..b421aba4ae5 --- /dev/null +++ b/packages/api-plugin-carts/src/resolvers/Subscription/index.js @@ -0,0 +1,3 @@ +import cartUpdated from "./cartUpdated.js"; + +export default { cartUpdated }; diff --git a/packages/api-plugin-carts/src/resolvers/index.js b/packages/api-plugin-carts/src/resolvers/index.js index e83815145b9..45bb8d4d0c6 100644 --- a/packages/api-plugin-carts/src/resolvers/index.js +++ b/packages/api-plugin-carts/src/resolvers/index.js @@ -3,6 +3,7 @@ import CartItem from "./CartItem/index.js"; import FulfillmentGroup from "./FulfillmentGroup/index.js"; import Mutation from "./Mutation/index.js"; import Query from "./Query/index.js"; +import Subscription from "./Subscription/index.js"; /** * Cart related GraphQL resolvers @@ -14,6 +15,7 @@ export default { CartItem, FulfillmentGroup, Mutation, + Subscription, PaymentMethodData: { __resolveType(obj) { return obj.gqlType; diff --git a/packages/api-plugin-carts/src/schemas/cart.graphql b/packages/api-plugin-carts/src/schemas/cart.graphql index 284cb89bb6c..c2d56982dde 100644 --- a/packages/api-plugin-carts/src/schemas/cart.graphql +++ b/packages/api-plugin-carts/src/schemas/cart.graphql @@ -419,6 +419,17 @@ input SetEmailOnAnonymousCartInput { email: String! } +input CartUpdatedInput { + "The cart ID" + cartId: ID!, + + "The cart account ID" + accountId: ID + + "The cart anonymous token" + cartToken: String +} + #################### # Payloads # These types are used as return values for mutation calls @@ -595,3 +606,13 @@ extend type Mutation { input: UpdateCartItemsQuantityInput! ): UpdateCartItemsQuantityPayload! } + +#################### + # Subscriptions + #################### +extend type Subscription { + "Subscribe to changes to cart" + cartUpdated( + input: CartUpdatedInput! + ): Cart! +} diff --git a/packages/api-plugin-carts/src/startup.js b/packages/api-plugin-carts/src/startup.js index 404c90fc529..c069cfebebe 100644 --- a/packages/api-plugin-carts/src/startup.js +++ b/packages/api-plugin-carts/src/startup.js @@ -1,5 +1,6 @@ import Logger from "@reactioncommerce/logger"; import updateCartItemsForVariantChanges from "./util/updateCartItemsForVariantChanges.js"; +import publishCartUpdatedEvent from "./util/publishCartUpdatedEvent.js"; import { MAX_CART_COUNT as SAVE_MANY_CARTS_LIMIT } from "./mutations/saveManyCarts.js"; const logCtx = { name: "cart", file: "startup" }; @@ -98,6 +99,10 @@ export default async function cartStartup(context) { const { appEvents, collections } = context; const { Cart } = collections; + appEvents.on("afterCartUpdate", async ({ cart: updatedCart, publishUpdatedEvent = true }) => { + publishCartUpdatedEvent(context, updatedCart, { publishUpdatedEvent }); + }); + // When an order is created, delete the source cart appEvents.on("afterOrderCreate", async ({ order }) => { const { cartId } = order; diff --git a/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.js b/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.js new file mode 100644 index 00000000000..f342e74f755 --- /dev/null +++ b/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.js @@ -0,0 +1,11 @@ +/** + * @summary Publishes a cart updated event + * @param {Object} context - The application context + * @param {Object} cart - The cart that was updated + * @param {Boolean} params.publishUpdatedEvent - Whether to prevent publishing the event + * @returns {void} - undefined + */ +export default function publishCartUpdatedEvent(context, cart, { publishUpdatedEvent = undefined }) { + if (!context.app.hasSubscriptionsEnabled || !publishUpdatedEvent) return; + context.pubSub.publish("CART_UPDATED", { cartUpdated: cart }); +} diff --git a/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.test.js b/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.test.js new file mode 100644 index 00000000000..a51698ce9fd --- /dev/null +++ b/packages/api-plugin-carts/src/util/publishCartUpdatedEvent.test.js @@ -0,0 +1,29 @@ +import publishCartUpdatedEvent from "./publishCartUpdatedEvent.js"; + +const context = { + app: { + hasSubscriptionsEnabled: true + }, + pubSub: { + publish: jest.fn() + } +}; + +test("shouldn't publish event when subscription is disabled", async () => { + context.app.hasSubscriptionsEnabled = false; + publishCartUpdatedEvent(context, {}, { publishUpdatedEvent: true }); + expect(context.pubSub.publish).not.toHaveBeenCalled(); +}); + +test("shouldn't publish event when publishUpdatedEvent arg is undefined", async () => { + context.app.hasSubscriptionsEnabled = true; + publishCartUpdatedEvent(context, {}, { publishUpdatedEvent: undefined }); + expect(context.pubSub.publish).not.toHaveBeenCalled(); +}); + +test("should publish cart updated event", async () => { + context.app.hasSubscriptionsEnabled = true; + const cart = { _id: "cartId" }; + publishCartUpdatedEvent(context, cart, { publishUpdatedEvent: true }); + expect(context.pubSub.publish).toHaveBeenCalledWith("CART_UPDATED", { cartUpdated: cart }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 819b84960ba..c996782a5df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,7 @@ importers: babel-plugin-rewire-exports: ^2.0.0 babel-plugin-transform-es2015-modules-commonjs: ^6.26.2 babel-plugin-transform-import-meta: ~1.0.0 + graphql-subscriptions: ^2.0.0 lodash: ^4.17.15 simpl-schema: ^1.12.0 dependencies: @@ -504,6 +505,7 @@ importers: '@reactioncommerce/random': link:../random '@reactioncommerce/reaction-error': link:../reaction-error accounting-js: 1.1.1 + graphql-subscriptions: 2.0.0_graphql@16.6.0 lodash: 4.17.21 simpl-schema: 1.12.3 devDependencies: @@ -9138,6 +9140,15 @@ packages: iterall: 1.3.0 dev: false + /graphql-subscriptions/2.0.0_graphql@16.6.0: + resolution: {integrity: sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==} + peerDependencies: + graphql: ^15.7.2 || ^16.0.0 + dependencies: + graphql: 16.6.0 + iterall: 1.3.0 + dev: false + /graphql-tag/2.12.6_graphql@15.8.0: resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} engines: {node: '>=10'} @@ -9197,7 +9208,6 @@ packages: /graphql/16.6.0: resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - dev: true /gridfs-stream/1.1.1: resolution: {integrity: sha512-EcELdPIjC7tpZUiZA/8trfmszLbcsZlFyDQ8DhMtyJIMDmuLi5Vzt/056OO6FqfvY/zwiTCo1eZAqwtqrhBGMQ==}