From bf43896d1942d352efcf8b900f4a31fef0fe215d Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Fri, 17 Sep 2021 08:27:46 +0200 Subject: [PATCH 01/19] fix: customer endpoints shouldn't use customer id already provided through authentication (#402) * Updated customers/:id to customers/me - untested * fix: integration +unit tests * docs: fix oas docs Co-authored-by: ColdMeekly <20516479+ColdMeekly@users.noreply.github.com> --- .../api/__tests__/store/customer.js | 148 +++++++++--------- .../customers/__tests__/update-customer.js | 111 +++++-------- .../store/customers/authorize-customer.js | 12 -- .../routes/store/customers/create-address.js | 2 +- .../routes/store/customers/delete-address.js | 3 +- .../routes/store/customers/get-customer.js | 6 +- .../store/customers/get-payment-methods.js | 8 +- .../src/api/routes/store/customers/index.js | 15 +- .../api/routes/store/customers/list-orders.js | 8 +- .../store/customers/reset-password-token.js | 8 +- .../routes/store/customers/reset-password.js | 9 +- .../routes/store/customers/update-address.js | 6 +- .../routes/store/customers/update-customer.js | 6 +- 13 files changed, 139 insertions(+), 203 deletions(-) delete mode 100644 packages/medusa/src/api/routes/store/customers/authorize-customer.js diff --git a/integration-tests/api/__tests__/store/customer.js b/integration-tests/api/__tests__/store/customer.js index 5b08322bdd..52189ca8c0 100644 --- a/integration-tests/api/__tests__/store/customer.js +++ b/integration-tests/api/__tests__/store/customer.js @@ -1,67 +1,67 @@ -const path = require("path"); -const { Address, Customer } = require("@medusajs/medusa"); +const path = require("path") +const { Address, Customer } = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const customerSeeder = require("../../helpers/customer-seeder"); +const customerSeeder = require("../../helpers/customer-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/store/customers", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection const doAfterEach = async () => { - const db = useDb(); - await db.teardown(); - }; + const db = useDb() + await db.teardown() + } beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); - medusaProcess.kill(); - }); + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) describe("POST /store/customers", () => { beforeEach(async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager await manager.insert(Customer, { id: "test_customer", first_name: "John", last_name: "Deere", email: "john@deere.com", has_account: true, - }); - }); + }) + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("creates a customer", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/customers", { first_name: "James", last_name: "Bond", email: "james@bond.com", password: "test", - }); + }) - expect(response.status).toEqual(200); - expect(response.data.customer).not.toHaveProperty("password_hash"); - }); + expect(response.status).toEqual(200) + expect(response.data.customer).not.toHaveProperty("password_hash") + }) it("responds 409 on duplicate", async () => { - const api = useApi(); + const api = useApi() const response = await api .post("/store/customers", { @@ -70,15 +70,15 @@ describe("/store/customers", () => { email: "john@deere.com", password: "test", }) - .catch((err) => err.response); + .catch((err) => err.response) - expect(response.status).toEqual(402); - }); - }); + expect(response.status).toEqual(402) + }) + }) - describe("POST /store/customers/:id", () => { + describe("POST /store/customers/me", () => { beforeEach(async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager await manager.insert(Address, { id: "addr_test", first_name: "String", @@ -88,7 +88,7 @@ describe("/store/customers", () => { postal_code: "1236", province: "ca", country_code: "us", - }); + }) await manager.insert(Customer, { id: "test_customer", @@ -98,26 +98,26 @@ describe("/store/customers", () => { password_hash: "c2NyeXB0AAEAAAABAAAAAVMdaddoGjwU1TafDLLlBKnOTQga7P2dbrfgf3fB+rCD/cJOMuGzAvRdKutbYkVpuJWTU39P7OpuWNkUVoEETOVLMJafbI8qs8Qx/7jMQXkN", // password matching "test" has_account: true, - }); - }); + }) + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("updates a customer", async () => { - const api = useApi(); + const api = useApi() const authResponse = await api.post("/store/auth", { email: "john@deere.com", password: "test", - }); + }) - const customerId = authResponse.data.customer.id; - const [authCookie] = authResponse.headers["set-cookie"][0].split(";"); + const customerId = authResponse.data.customer.id + const [authCookie] = authResponse.headers["set-cookie"][0].split(";") const response = await api.post( - `/store/customers/${customerId}`, + `/store/customers/me`, { password: "test", metadata: { key: "value" }, @@ -127,30 +127,30 @@ describe("/store/customers", () => { Cookie: authCookie, }, } - ); + ) - expect(response.status).toEqual(200); - expect(response.data.customer).not.toHaveProperty("password_hash"); + expect(response.status).toEqual(200) + expect(response.data.customer).not.toHaveProperty("password_hash") expect(response.data.customer).toEqual( expect.objectContaining({ metadata: { key: "value" }, }) - ); - }); + ) + }) it("updates customer billing address", async () => { - const api = useApi(); + const api = useApi() const authResponse = await api.post("/store/auth", { email: "john@deere.com", password: "test", - }); + }) - const customerId = authResponse.data.customer.id; - const [authCookie] = authResponse.headers["set-cookie"][0].split(";"); + const customerId = authResponse.data.customer.id + const [authCookie] = authResponse.headers["set-cookie"][0].split(";") const response = await api.post( - `/store/customers/${customerId}`, + `/store/customers/me`, { billing_address: { first_name: "test", @@ -167,10 +167,10 @@ describe("/store/customers", () => { Cookie: authCookie, }, } - ); + ) - expect(response.status).toEqual(200); - expect(response.data.customer).not.toHaveProperty("password_hash"); + expect(response.status).toEqual(200) + expect(response.data.customer).not.toHaveProperty("password_hash") expect(response.data.customer.billing_address).toEqual( expect.objectContaining({ first_name: "test", @@ -181,22 +181,22 @@ describe("/store/customers", () => { province: "ca", country_code: "us", }) - ); - }); + ) + }) it("updates customer billing address with string", async () => { - const api = useApi(); + const api = useApi() const authResponse = await api.post("/store/auth", { email: "john@deere.com", password: "test", - }); + }) - const customerId = authResponse.data.customer.id; - const [authCookie] = authResponse.headers["set-cookie"][0].split(";"); + const customerId = authResponse.data.customer.id + const [authCookie] = authResponse.headers["set-cookie"][0].split(";") const response = await api.post( - `/store/customers/${customerId}`, + `/store/customers/me`, { billing_address: "addr_test", }, @@ -205,10 +205,10 @@ describe("/store/customers", () => { Cookie: authCookie, }, } - ); + ) - expect(response.status).toEqual(200); - expect(response.data.customer).not.toHaveProperty("password_hash"); + expect(response.status).toEqual(200) + expect(response.data.customer).not.toHaveProperty("password_hash") expect(response.data.customer.billing_address).toEqual( expect.objectContaining({ first_name: "String", @@ -219,7 +219,7 @@ describe("/store/customers", () => { province: "ca", country_code: "us", }) - ); - }); - }); -}); + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js index baab0d5261..a1a9c7925e 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js @@ -7,21 +7,17 @@ describe("POST /store/customers/:id", () => { describe("successfully updates a customer", () => { let subject beforeAll(async () => { - subject = await request( - "POST", - `/store/customers/${IdMap.getId("lebron")}`, - { - payload: { - first_name: "LeBron", - last_name: "James", + subject = await request("POST", `/store/customers/me`, { + payload: { + first_name: "LeBron", + last_name: "James", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), }, - clientSession: { - jwt: { - customer_id: IdMap.getId("lebron"), - }, - }, - } - ) + }, + }) }) afterAll(() => { @@ -59,20 +55,16 @@ describe("POST /store/customers/:id", () => { describe("successfully updates a customer with billing address id", () => { let subject beforeAll(async () => { - subject = await request( - "POST", - `/store/customers/${IdMap.getId("lebron")}`, - { - payload: { - billing_address: "test", + subject = await request("POST", `/store/customers/me`, { + payload: { + billing_address: "test", + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), }, - clientSession: { - jwt: { - customer_id: IdMap.getId("lebron"), - }, - }, - } - ) + }, + }) }) afterAll(() => { @@ -97,28 +89,24 @@ describe("POST /store/customers/:id", () => { describe("successfully updates a customer with billing address object", () => { let subject beforeAll(async () => { - subject = await request( - "POST", - `/store/customers/${IdMap.getId("lebron")}`, - { - payload: { - billing_address: { - first_name: "Olli", - last_name: "Juhl", - address_1: "Laksegade", - city: "Copenhagen", - country_code: "dk", - postal_code: "2100", - phone: "+1 (222) 333 4444", - }, + subject = await request("POST", `/store/customers/me`, { + payload: { + billing_address: { + first_name: "Olli", + last_name: "Juhl", + address_1: "Laksegade", + city: "Copenhagen", + country_code: "dk", + postal_code: "2100", + phone: "+1 (222) 333 4444", }, - clientSession: { - jwt: { - customer_id: IdMap.getId("lebron"), - }, + }, + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), }, - } - ) + }, + }) }) afterAll(() => { @@ -147,33 +135,4 @@ describe("POST /store/customers/:id", () => { expect(subject.status).toEqual(200) }) }) - - describe("fails if not authenticated", () => { - let subject - beforeAll(async () => { - subject = await request( - "POST", - `/store/customers/${IdMap.getId("customer1")}`, - { - payload: { - first_name: "LeBron", - last_name: "James", - }, - clientSession: { - jwt: { - customer_id: IdMap.getId("lebron"), - }, - }, - } - ) - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - it("status code 400", () => { - expect(subject.status).toEqual(400) - }) - }) }) diff --git a/packages/medusa/src/api/routes/store/customers/authorize-customer.js b/packages/medusa/src/api/routes/store/customers/authorize-customer.js deleted file mode 100644 index c789c84bed..0000000000 --- a/packages/medusa/src/api/routes/store/customers/authorize-customer.js +++ /dev/null @@ -1,12 +0,0 @@ -import { MedusaError } from "medusa-core-utils" - -export default async (req, res, next, id) => { - if (!(req.user && req.user.customer_id === id)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "You must be logged in to update" - ) - } else { - next() - } -} diff --git a/packages/medusa/src/api/routes/store/customers/create-address.js b/packages/medusa/src/api/routes/store/customers/create-address.js index 390f07e1a2..2ecf2bbd31 100644 --- a/packages/medusa/src/api/routes/store/customers/create-address.js +++ b/packages/medusa/src/api/routes/store/customers/create-address.js @@ -30,7 +30,7 @@ import { defaultRelations, defaultFields } from "./" * $ref: "#/components/schemas/customer" */ export default async (req, res) => { - const { id } = req.params + const id = req.user.customer_id const schema = Validator.object().keys({ address: Validator.address().required(), diff --git a/packages/medusa/src/api/routes/store/customers/delete-address.js b/packages/medusa/src/api/routes/store/customers/delete-address.js index 4829da15ac..a47ed0edc4 100644 --- a/packages/medusa/src/api/routes/store/customers/delete-address.js +++ b/packages/medusa/src/api/routes/store/customers/delete-address.js @@ -21,7 +21,8 @@ import { defaultRelations, defaultFields } from "./" * $ref: "#/components/schemas/customer" */ export default async (req, res) => { - const { id, address_id } = req.params + const id = req.user.customer_id + const { address_id } = req.params const customerService = req.scope.resolve("customerService") try { diff --git a/packages/medusa/src/api/routes/store/customers/get-customer.js b/packages/medusa/src/api/routes/store/customers/get-customer.js index 53db7b0e5e..48cc2bcf8a 100644 --- a/packages/medusa/src/api/routes/store/customers/get-customer.js +++ b/packages/medusa/src/api/routes/store/customers/get-customer.js @@ -1,12 +1,10 @@ import { defaultRelations, defaultFields } from "./" /** - * @oas [get] /customers/{id} + * @oas [get] /customers/me * operationId: GetCustomersCustomer * summary: Retrieves a Customer * description: "Retrieves a Customer - the Customer must be logged in to retrieve their details." - * parameters: - * - (path) id=* {string} The id of the Customer. * tags: * - Customer * responses: @@ -20,7 +18,7 @@ import { defaultRelations, defaultFields } from "./" * $ref: "#/components/schemas/customer" */ export default async (req, res) => { - const { id } = req.params + const id = req.user.customer_id try { const customerService = req.scope.resolve("customerService") const customer = await customerService.retrieve(id, { diff --git a/packages/medusa/src/api/routes/store/customers/get-payment-methods.js b/packages/medusa/src/api/routes/store/customers/get-payment-methods.js index 73cb812c30..5d84696916 100644 --- a/packages/medusa/src/api/routes/store/customers/get-payment-methods.js +++ b/packages/medusa/src/api/routes/store/customers/get-payment-methods.js @@ -1,5 +1,5 @@ /** - * @oas [get] /customers/{id}/payment-methods + * @oas [get] /customers/me/payment-methods * operationId: GetCustomersCustomerPaymentMethods * summary: Retrieve saved payment methods * description: "Retrieves a list of a Customer's saved payment methods. Payment methods are saved with Payment Providers and it is their responsibility to fetch saved methods." @@ -26,7 +26,7 @@ * description: The data needed for the Payment Provider to use the saved payment method. */ export default async (req, res) => { - const { id } = req.params + const id = req.user.customer_id try { const storeService = req.scope.resolve("storeService") const paymentProviderService = req.scope.resolve("paymentProviderService") @@ -37,11 +37,11 @@ export default async (req, res) => { const store = await storeService.retrieve(["payment_providers"]) const methods = await Promise.all( - store.payment_providers.map(async next => { + store.payment_providers.map(async (next) => { const provider = paymentProviderService.retrieveProvider(next) const pMethods = await provider.retrieveSavedMethods(customer) - return pMethods.map(m => ({ + return pMethods.map((m) => ({ provider_id: next, data: m, })) diff --git a/packages/medusa/src/api/routes/store/customers/index.js b/packages/medusa/src/api/routes/store/customers/index.js index 79938d1ac2..ab578c7585 100644 --- a/packages/medusa/src/api/routes/store/customers/index.js +++ b/packages/medusa/src/api/routes/store/customers/index.js @@ -7,7 +7,6 @@ export default (app, container) => { const middlewareService = container.resolve("middlewareService") app.use("/customers", route) - route.param("id", middlewares.wrap(require("./authorize-customer").default)) // Inject plugin routes const routers = middlewareService.getRouters("store/customers") @@ -30,28 +29,28 @@ export default (app, container) => { // Authenticated endpoints route.use(middlewares.authenticate()) - route.get("/:id", middlewares.wrap(require("./get-customer").default)) - route.post("/:id", middlewares.wrap(require("./update-customer").default)) + route.get("/me", middlewares.wrap(require("./get-customer").default)) + route.post("/me", middlewares.wrap(require("./update-customer").default)) - route.get("/:id/orders", middlewares.wrap(require("./list-orders").default)) + route.get("/me/orders", middlewares.wrap(require("./list-orders").default)) route.post( - "/:id/addresses", + "/me/addresses", middlewares.wrap(require("./create-address").default) ) route.post( - "/:id/addresses/:address_id", + "/me/addresses/:address_id", middlewares.wrap(require("./update-address").default) ) route.delete( - "/:id/addresses/:address_id", + "/me/addresses/:address_id", middlewares.wrap(require("./delete-address").default) ) route.get( - "/:id/payment-methods", + "/me/payment-methods", middlewares.wrap(require("./get-payment-methods").default) ) diff --git a/packages/medusa/src/api/routes/store/customers/list-orders.js b/packages/medusa/src/api/routes/store/customers/list-orders.js index 47491805f4..30cb695fd7 100644 --- a/packages/medusa/src/api/routes/store/customers/list-orders.js +++ b/packages/medusa/src/api/routes/store/customers/list-orders.js @@ -7,7 +7,7 @@ import { } from "../orders" /** - * @oas [get] /customers/{id}/orders + * @oas [get] /customers/me/orders * operationId: GetCustomersCustomerOrders * summary: Retrieve Customer Orders * description: "Retrieves a list of a Customer's Orders." @@ -28,7 +28,7 @@ import { * $ref: "#/components/schemas/order" */ export default async (req, res) => { - const { id } = req.params + const id = req.user.customer_id try { const orderService = req.scope.resolve("orderService") @@ -42,13 +42,13 @@ export default async (req, res) => { let includeFields = [] if ("fields" in req.query) { includeFields = req.query.fields.split(",") - includeFields = includeFields.filter(f => allowedFields.includes(f)) + includeFields = includeFields.filter((f) => allowedFields.includes(f)) } let expandFields = [] if ("expand" in req.query) { expandFields = req.query.expand.split(",") - expandFields = expandFields.filter(f => allowedRelations.includes(f)) + expandFields = expandFields.filter((f) => allowedRelations.includes(f)) } const listConfig = { diff --git a/packages/medusa/src/api/routes/store/customers/reset-password-token.js b/packages/medusa/src/api/routes/store/customers/reset-password-token.js index 6898fd8a47..3fe343b70b 100644 --- a/packages/medusa/src/api/routes/store/customers/reset-password-token.js +++ b/packages/medusa/src/api/routes/store/customers/reset-password-token.js @@ -1,12 +1,10 @@ import { MedusaError, Validator } from "medusa-core-utils" /** - * @oas [post] /customers/{id}/password-token + * @oas [post] /customers/password-token * operationId: PostCustomersCustomerPasswordToken * summary: Creates a reset password token * description: "Creates a reset password token to be used in a subsequent /reset-password request. The password token should be sent out of band e.g. via email and will not be returned." - * parameters: - * - (path) id=* {string} The id of the Customer. * tags: * - Customer * responses: @@ -15,9 +13,7 @@ import { MedusaError, Validator } from "medusa-core-utils" */ export default async (req, res) => { const schema = Validator.object().keys({ - email: Validator.string() - .email() - .required(), + email: Validator.string().email().required(), }) const { value, error } = schema.validate(req.body) diff --git a/packages/medusa/src/api/routes/store/customers/reset-password.js b/packages/medusa/src/api/routes/store/customers/reset-password.js index 4c0f09045c..4242e0e8f5 100644 --- a/packages/medusa/src/api/routes/store/customers/reset-password.js +++ b/packages/medusa/src/api/routes/store/customers/reset-password.js @@ -2,12 +2,11 @@ import { MedusaError, Validator } from "medusa-core-utils" import jwt from "jsonwebtoken" /** - * @oas [post] /customers/{id}/reset-password - * operationId: PostCustomersCustomerResetPassword + * @oas [post] /customers/reset-password + * operationId: PostCustomersResetPassword * summary: Resets Customer password * description: "Resets a Customer's password using a password token created by a previous /password-token request." * parameters: - * - (path) id=* {string} The id of the Customer. * - (body) email=* {string} The Customer's email. * - (body) token=* {string} The password token created by a /password-token request. * - (body) password=* {string} The new password to set for the Customer. @@ -25,9 +24,7 @@ import jwt from "jsonwebtoken" */ export default async (req, res) => { const schema = Validator.object().keys({ - email: Validator.string() - .email() - .required(), + email: Validator.string().email().required(), token: Validator.string().required(), password: Validator.string().required(), }) diff --git a/packages/medusa/src/api/routes/store/customers/update-address.js b/packages/medusa/src/api/routes/store/customers/update-address.js index 6c495451a2..d0c835416d 100644 --- a/packages/medusa/src/api/routes/store/customers/update-address.js +++ b/packages/medusa/src/api/routes/store/customers/update-address.js @@ -2,12 +2,11 @@ import { Validator, MedusaError } from "medusa-core-utils" import { defaultRelations, defaultFields } from "./" /** - * @oas [post] /customers/{id}/addresses/{address_id} + * @oas [post] /customers/me/addresses/{address_id} * operationId: PostCustomersCustomerAddressesAddress * summary: "Update a Shipping Address" * description: "Updates a Customer's saved Shipping Address." * parameters: - * - (path) id=* {String} The Customer id. * - (path) address_id=* {String} The id of the Address to update. * requestBody: * content: @@ -31,7 +30,8 @@ import { defaultRelations, defaultFields } from "./" * $ref: "#/components/schemas/customer" */ export default async (req, res) => { - const { id, address_id } = req.params + const id = req.user.customer_id + const { address_id } = req.params const schema = Validator.object().keys({ address: Validator.address().required(), diff --git a/packages/medusa/src/api/routes/store/customers/update-customer.js b/packages/medusa/src/api/routes/store/customers/update-customer.js index 6101f4fcaa..a3635f9ec5 100644 --- a/packages/medusa/src/api/routes/store/customers/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/update-customer.js @@ -2,12 +2,10 @@ import { Validator, MedusaError } from "medusa-core-utils" import { defaultRelations, defaultFields } from "./" /** - * @oas [post] /customers/{id} + * @oas [post] /customers/me * operationId: PostCustomersCustomer * summary: Update Customer details * description: "Updates a Customer's saved details." - * parameters: - * - (path) id=* {string} The id of the Customer. * requestBody: * content: * application/json: @@ -45,7 +43,7 @@ import { defaultRelations, defaultFields } from "./" * $ref: "#/components/schemas/customer" */ export default async (req, res) => { - const { id } = req.params + const id = req.user.customer_id const schema = Validator.object().keys({ billing_address: Validator.address().optional(), From 75b608330b51a2c4ac22e7e63766346d17dda9a7 Mon Sep 17 00:00:00 2001 From: Sebastian Mateos Nicolajsen <80953876+sebastiannicolajsen@users.noreply.github.com> Date: Fri, 17 Sep 2021 10:35:56 +0200 Subject: [PATCH 02/19] feat: AWS S3 file service plugin (#376) --- packages/medusa-file-s3/.babelrc | 13 ++++ packages/medusa-file-s3/.eslintrc | 9 +++ packages/medusa-file-s3/.gitignore | 16 +++++ packages/medusa-file-s3/.npmignore | 9 +++ packages/medusa-file-s3/.prettierrc | 7 +++ packages/medusa-file-s3/index.js | 1 + packages/medusa-file-s3/package.json | 46 ++++++++++++++ packages/medusa-file-s3/src/services/s3.js | 73 ++++++++++++++++++++++ 8 files changed, 174 insertions(+) create mode 100644 packages/medusa-file-s3/.babelrc create mode 100644 packages/medusa-file-s3/.eslintrc create mode 100644 packages/medusa-file-s3/.gitignore create mode 100644 packages/medusa-file-s3/.npmignore create mode 100644 packages/medusa-file-s3/.prettierrc create mode 100644 packages/medusa-file-s3/index.js create mode 100644 packages/medusa-file-s3/package.json create mode 100644 packages/medusa-file-s3/src/services/s3.js diff --git a/packages/medusa-file-s3/.babelrc b/packages/medusa-file-s3/.babelrc new file mode 100644 index 0000000000..4d2dfe8f09 --- /dev/null +++ b/packages/medusa-file-s3/.babelrc @@ -0,0 +1,13 @@ +{ + "plugins": [ + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-instanceof", + "@babel/plugin-transform-classes" + ], + "presets": ["@babel/preset-env"], + "env": { + "test": { + "plugins": ["@babel/plugin-transform-runtime"] + } + } +} diff --git a/packages/medusa-file-s3/.eslintrc b/packages/medusa-file-s3/.eslintrc new file mode 100644 index 0000000000..2a889697f0 --- /dev/null +++ b/packages/medusa-file-s3/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier"], + "extends": ["prettier"], + "rules": { + "prettier/prettier": "error", + "semi": "error", + "no-unused-expressions": "true" + } +} diff --git a/packages/medusa-file-s3/.gitignore b/packages/medusa-file-s3/.gitignore new file mode 100644 index 0000000000..2ca7f03256 --- /dev/null +++ b/packages/medusa-file-s3/.gitignore @@ -0,0 +1,16 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock + +/dist + +/api +/services +/models +/subscribers +/__mocks__ + diff --git a/packages/medusa-file-s3/.npmignore b/packages/medusa-file-s3/.npmignore new file mode 100644 index 0000000000..486581be18 --- /dev/null +++ b/packages/medusa-file-s3/.npmignore @@ -0,0 +1,9 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock + + diff --git a/packages/medusa-file-s3/.prettierrc b/packages/medusa-file-s3/.prettierrc new file mode 100644 index 0000000000..48e90e8d40 --- /dev/null +++ b/packages/medusa-file-s3/.prettierrc @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/medusa-file-s3/index.js b/packages/medusa-file-s3/index.js new file mode 100644 index 0000000000..172f1ae6a4 --- /dev/null +++ b/packages/medusa-file-s3/index.js @@ -0,0 +1 @@ +// noop diff --git a/packages/medusa-file-s3/package.json b/packages/medusa-file-s3/package.json new file mode 100644 index 0000000000..b97ceb62af --- /dev/null +++ b/packages/medusa-file-s3/package.json @@ -0,0 +1,46 @@ +{ + "name": "medusa-file-s3", + "version": "1.0.0", + "description": "AWS s3 file connector for Medusa", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/medusa-file-s3" + }, + "author": "Sebastian Mateos Nicolajsen", + "license": "MIT", + "devDependencies": { + "@babel/cli": "^7.7.5", + "@babel/core": "^7.7.5", + "@babel/node": "^7.7.4", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-transform-instanceof": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.7.6", + "@babel/preset-env": "^7.7.5", + "@babel/register": "^7.7.4", + "@babel/runtime": "^7.9.6", + "client-sessions": "^0.8.0", + "cross-env": "^5.2.1", + "eslint": "^6.8.0", + "jest": "^25.5.2", + "medusa-test-utils": "^0.3.0" + }, + "scripts": { + "build": "babel src -d .", + "prepare": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir . --ignore **/__tests__", + "test": "jest" + }, + "peerDependencies": { + "medusa-interfaces": "1.x" + }, + "dependencies": { + "@babel/plugin-transform-classes": "^7.15.4", + "aws-sdk": "^2.983.0", + "body-parser": "^1.19.0", + "express": "^4.17.1", + "medusa-core-utils": "^1.1.20", + "medusa-test-utils": "^1.1.23" + } +} diff --git a/packages/medusa-file-s3/src/services/s3.js b/packages/medusa-file-s3/src/services/s3.js new file mode 100644 index 0000000000..85541260eb --- /dev/null +++ b/packages/medusa-file-s3/src/services/s3.js @@ -0,0 +1,73 @@ +import fs from "fs" +import aws from "aws-sdk" +import { FileService } from "medusa-interfaces" + +class S3Service extends FileService { + constructor({}, options) { + super() + + this.bucket_ = options.bucket + this.s3Url_ = options.s3_url + this.accessKeyId_ = options.access_key_id + this.secretAccessKey_ = options.secret_access_key + this.region_ = options.region + this.endpoint_ = options.endpoint + } + + upload(file) { + aws.config.setPromisesDependency() + aws.config.update({ + accessKeyId: this.accessKeyId_, + secretAccessKey: this.secretAccessKey_, + region: this.region_, + endpoint: this.endpoint_, + }) + + const s3 = new aws.S3() + var params = { + ACL: "public-read", + Bucket: this.bucket_, + Body: fs.createReadStream(file.path), + Key: `${file.originalname}`, + } + + return new Promise((resolve, reject) => { + s3.upload(params, (err, data) => { + if (err) { + reject(err) + return + } + + resolve({ url: data.Location }) + }) + }) + } + + delete(file) { + aws.config.setPromisesDependency() + aws.config.update({ + accessKeyId: this.accessKeyId_, + secretAccessKey: this.secretAccessKey_, + region: this.region_, + endpoint: this.endpoint_, + }) + + const s3 = new aws.S3() + var params = { + Bucket: this.bucket_, + Key: `${file}`, + } + + return new Promise((resolve, reject) => { + s3.deleteObject(params, (err, data) => { + if (err) { + reject(err) + return + } + resolve(data) + }) + }) + } +} + +export default S3Service From 00ab03f3a2b0c59049f5c5a2af2cb5eee9d4c72d Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Sun, 19 Sep 2021 15:33:43 +0200 Subject: [PATCH 03/19] feat: Allow backorder on swaps (#404) --- .../admin/__snapshots__/product.js.snap | 382 +++++++++++++++ .../api/__tests__/admin/swaps.js | 112 ++--- .../store/__snapshots__/cart.js.snap | 8 + .../store/__snapshots__/swaps.js.snap | 2 + integration-tests/api/__tests__/store/cart.js | 451 ++++++++++-------- integration-tests/api/helpers/cart-seeder.js | 152 +++--- integration-tests/api/helpers/swap-seeder.js | 51 +- integration-tests/api/package.json | 6 +- integration-tests/api/yarn.lock | 104 ++-- .../api/routes/admin/orders/create-swap.js | 5 + .../api/routes/store/carts/complete-cart.js | 38 +- .../1630505790603-allow_backorder_swaps.ts | 24 + packages/medusa/src/models/cart.ts | 3 + packages/medusa/src/models/swap.ts | 7 + .../medusa/src/services/__tests__/order.js | 3 + .../medusa/src/services/__tests__/swap.js | 22 + packages/medusa/src/services/cart.js | 21 +- packages/medusa/src/services/order.js | 7 + .../medusa/src/services/payment-provider.js | 2 +- packages/medusa/src/services/swap.js | 31 +- 20 files changed, 1038 insertions(+), 393 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/product.js.snap create mode 100644 packages/medusa/src/migrations/1630505790603-allow_backorder_swaps.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap new file mode 100644 index 0000000000..4cd2e9683e --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap @@ -0,0 +1,382 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/products GET /admin/products returns a list of products with child entities 1`] = ` +Array [ + Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "title": "Test collection", + "updated_at": Any, + }, + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "handle": "test-product", + "height": null, + "hs_code": null, + "id": StringMatching /\\^test-\\*/, + "images": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "url": "test-image.png", + }, + ], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "product_id": StringMatching /\\^test-\\*/, + "title": "test-option", + "updated_at": Any, + }, + ], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^tag\\*/, + "metadata": null, + "updated_at": Any, + "value": "123", + }, + ], + "thumbnail": null, + "title": "Test product", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "value": "test-type", + }, + "type_id": "test-type", + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": "test-barcode", + "created_at": Any, + "deleted_at": null, + "ean": "test-ean", + "height": null, + "hs_code": null, + "id": "test-variant", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku", + "title": "Test variant", + "upc": "test-upc", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean2", + "height": null, + "hs_code": null, + "id": "test-variant_2", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 2", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku2", + "title": "Test variant rank (2)", + "upc": "test-upc2", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": "test-barcode 1", + "created_at": Any, + "deleted_at": null, + "ean": "test-ean1", + "height": null, + "hs_code": null, + "id": "test-variant_1", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 1", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku1", + "title": "Test variant rank (1)", + "upc": "test-upc1", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, + }, + Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "title": "Test collection", + "updated_at": Any, + }, + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description1", + "discountable": true, + "handle": "test-product1", + "height": null, + "hs_code": null, + "id": StringMatching /\\^test-\\*/, + "images": Array [], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^tag\\*/, + "metadata": null, + "updated_at": Any, + "value": "123", + }, + ], + "thumbnail": null, + "title": "Test product1", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-\\*/, + "metadata": null, + "updated_at": Any, + "value": "test-type", + }, + "type_id": "test-type", + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean4", + "height": null, + "hs_code": null, + "id": "test-variant_4", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 4", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku4", + "title": "Test variant rank (2)", + "upc": "test-upc4", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": "test-ean3", + "height": null, + "hs_code": null, + "id": "test-variant_3", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^test-variant-option\\*/, + "metadata": null, + "option_id": StringMatching /\\^test-opt\\*/, + "updated_at": Any, + "value": "Default variant 3", + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^test-price\\*/, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": StringMatching /\\^test-variant\\*/, + }, + ], + "product_id": StringMatching /\\^test-\\*/, + "sku": "test-sku3", + "title": "Test variant rank (2)", + "upc": "test-upc3", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/swaps.js b/integration-tests/api/__tests__/admin/swaps.js index efcc142e65..78ec6db9e8 100644 --- a/integration-tests/api/__tests__/admin/swaps.js +++ b/integration-tests/api/__tests__/admin/swaps.js @@ -1,49 +1,49 @@ -const path = require("path"); +const path = require("path") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const orderSeeder = require("../../helpers/order-seeder"); -const swapSeeder = require("../../helpers/swap-seeder"); -const adminSeeder = require("../../helpers/admin-seeder"); +const orderSeeder = require("../../helpers/order-seeder") +const swapSeeder = require("../../helpers/swap-seeder") +const adminSeeder = require("../../helpers/admin-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/swaps", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); - medusaProcess.kill(); - }); + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) describe("GET /admin/swaps/:id", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); - await swapSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) + await swapSeeder(dbConnection) } catch (err) { - throw err; + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("gets a swap with cart and totals", async () => { - const api = useApi(); + const api = useApi() const response = await api .get("/admin/swaps/test-swap", { @@ -52,46 +52,46 @@ describe("/admin/swaps", () => { }, }) .catch((err) => { - console.log(err); - }); - expect(response.status).toEqual(200); + console.log(err) + }) + expect(response.status).toEqual(200) expect(response.data.swap).toEqual( expect.objectContaining({ id: "test-swap", }) - ); + ) expect(response.data.swap.cart).toEqual( expect.objectContaining({ - id: "test-cart", + id: "test-cart-w-swap", shipping_total: 1000, subtotal: 1000, total: 2000, }) - ); - expect(response.data.swap.cart).toHaveProperty("discount_total"); - expect(response.data.swap.cart).toHaveProperty("gift_card_total"); - }); - }); + ) + expect(response.data.swap.cart).toHaveProperty("discount_total") + expect(response.data.swap.cart).toHaveProperty("gift_card_total") + }) + }) describe("GET /admin/swaps/", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); - await swapSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) + await swapSeeder(dbConnection) } catch (err) { - throw err; + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("lists all swaps", async () => { - const api = useApi(); + const api = useApi() const response = await api .get("/admin/swaps/", { @@ -100,18 +100,18 @@ describe("/admin/swaps", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data).toHaveProperty("count"); - expect(response.data.offset).toBe(0); - expect(response.data.limit).toBe(50); + expect(response.status).toEqual(200) + expect(response.data).toHaveProperty("count") + expect(response.data.offset).toBe(0) + expect(response.data.limit).toBe(50) expect(response.data.swaps).toContainEqual( expect.objectContaining({ id: "test-swap", }) - ); - }); - }); -}); + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/__snapshots__/cart.js.snap b/integration-tests/api/__tests__/store/__snapshots__/cart.js.snap index 9f6476ca0a..520fe3a3ae 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/cart.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/cart.js.snap @@ -8,6 +8,14 @@ Object { } `; +exports[`/store/carts POST /store/carts/:id fails to complete swap cart with items inventory not/partially covered 1`] = ` +Object { + "code": "insufficient_inventory", + "message": "Variant with id: test-variant-2 does not have the required inventory", + "type": "not_allowed", +} +`; + exports[`/store/carts POST /store/carts/:id returns early, if cart is already completed 1`] = ` Object { "code": "cart_incompatible_state", diff --git a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap index befb1e4768..5f3522721f 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap @@ -91,6 +91,7 @@ Object { "parent_order_id": "test-order", "swap_id": StringMatching /\\^swap_\\*/, }, + "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", "shipping_address_id": "test-shipping-address", @@ -260,6 +261,7 @@ Object { "parent_order_id": "test-order", "swap_id": StringMatching /\\^swap_\\*/, }, + "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", "shipping_address_id": "test-shipping-address", diff --git a/integration-tests/api/__tests__/store/cart.js b/integration-tests/api/__tests__/store/cart.js index 8527218273..afd35f4e69 100644 --- a/integration-tests/api/__tests__/store/cart.js +++ b/integration-tests/api/__tests__/store/cart.js @@ -1,155 +1,161 @@ -const path = require("path"); -const { Region, LineItem, GiftCard } = require("@medusajs/medusa"); +const path = require("path") +const { Region, LineItem, GiftCard } = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const cartSeeder = require("../../helpers/cart-seeder"); +const cartSeeder = require("../../helpers/cart-seeder") +const swapSeeder = require("../../helpers/swap-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/store/carts", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection const doAfterEach = async () => { - const db = useDb(); - return await db.teardown(); - }; + const db = useDb() + return await db.teardown() + } beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + try { + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + } catch (error) { + console.log(error) + } + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); - medusaProcess.kill(); - }); + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) describe("POST /store/carts", () => { beforeEach(async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager await manager.insert(Region, { id: "region", name: "Test Region", currency_code: "usd", tax_rate: 0, - }); + }) await manager.query( `UPDATE "country" SET region_id='region' WHERE iso_2 = 'us'` - ); - }); + ) + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("creates a cart", async () => { - const api = useApi(); + const api = useApi() - const response = await api.post("/store/carts"); - expect(response.status).toEqual(200); + const response = await api.post("/store/carts") + expect(response.status).toEqual(200) - const getRes = await api.post(`/store/carts/${response.data.cart.id}`); - expect(getRes.status).toEqual(200); - }); + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + }) it("creates a cart with country", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/carts", { country_code: "us", - }); - expect(response.status).toEqual(200); - expect(response.data.cart.shipping_address.country_code).toEqual("us"); + }) + expect(response.status).toEqual(200) + expect(response.data.cart.shipping_address.country_code).toEqual("us") - const getRes = await api.post(`/store/carts/${response.data.cart.id}`); - expect(getRes.status).toEqual(200); - }); + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + }) it("creates a cart with context", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/carts", { context: { test_id: "test", }, - }); - expect(response.status).toEqual(200); + }) + expect(response.status).toEqual(200) - const getRes = await api.post(`/store/carts/${response.data.cart.id}`); - expect(getRes.status).toEqual(200); + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) - const cart = getRes.data.cart; + const cart = getRes.data.cart expect(cart.context).toEqual({ ip: "::ffff:127.0.0.1", user_agent: "axios/0.21.1", test_id: "test", - }); - }); - }); + }) + }) + }) describe("POST /store/carts/:id", () => { beforeEach(async () => { try { - await cartSeeder(dbConnection); + await cartSeeder(dbConnection) + await swapSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("fails on apply discount if limit has been reached", async () => { - const api = useApi(); + const api = useApi() try { await api.post("/store/carts/test-cart", { discounts: [{ code: "CREATED" }], - }); + }) } catch (error) { - expect(error.response.status).toEqual(400); + expect(error.response.status).toEqual(400) expect(error.response.data.message).toEqual( "Discount has been used maximum allowed times" - ); + ) } - }); + }) it("updates cart customer id", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/carts/test-cart", { customer_id: "test-customer-2", - }); + }) - expect(response.status).toEqual(200); - }); + expect(response.status).toEqual(200) + }) it("updates address using string id", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/carts/test-cart", { billing_address: "test-general-address", shipping_address: "test-general-address", - }); + }) expect(response.data.cart.shipping_address_id).toEqual( "test-general-address" - ); + ) expect(response.data.cart.billing_address_id).toEqual( "test-general-address" - ); - expect(response.status).toEqual(200); - }); + ) + expect(response.status).toEqual(200) + }) it("updates address", async () => { - const api = useApi(); + const api = useApi() const response = await api.post("/store/carts/test-cart", { shipping_address: { @@ -160,14 +166,14 @@ describe("/store/carts", () => { country_code: "us", postal_code: "something", }, - }); + }) - expect(response.data.cart.shipping_address.first_name).toEqual("clark"); - expect(response.status).toEqual(200); - }); + expect(response.data.cart.shipping_address.first_name).toEqual("clark") + expect(response.status).toEqual(200) + }) it("adds free shipping to cart then removes it again", async () => { - const api = useApi(); + const api = useApi() let cart = await api.post( "/store/carts/test-cart", @@ -175,10 +181,10 @@ describe("/store/carts", () => { discounts: [{ code: "FREE_SHIPPING" }, { code: "CREATED" }], }, { withCredentials: true } - ); + ) - expect(cart.data.cart.shipping_total).toBe(0); - expect(cart.status).toEqual(200); + expect(cart.data.cart.shipping_total).toBe(0) + expect(cart.status).toEqual(200) cart = await api.post( "/store/carts/test-cart", @@ -186,68 +192,68 @@ describe("/store/carts", () => { discounts: [{ code: "CREATED" }], }, { withCredentials: true } - ); + ) - expect(cart.data.cart.shipping_total).toBe(1000); - expect(cart.status).toEqual(200); - }); + expect(cart.data.cart.shipping_total).toBe(1000) + expect(cart.status).toEqual(200) + }) it("complete cart with giftcard total 0", async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager await manager.insert(GiftCard, { id: "gift_test", code: "GC_TEST", value: 20000, balance: 20000, region_id: "test-region", - }); + }) - const api = useApi(); + const api = useApi() await api.post(`/store/carts/test-cart-3`, { gift_cards: [{ code: "GC_TEST" }], - }); + }) const getRes = await api .post(`/store/carts/test-cart-3/complete`) .catch((err) => { - console.log(err.response.data); - }); + console.log(err.response.data) + }) - expect(getRes.status).toEqual(200); - expect(getRes.data.type).toEqual("order"); - }); + expect(getRes.status).toEqual(200) + expect(getRes.data.type).toEqual("order") + }) it("complete cart with items inventory covered", async () => { - const api = useApi(); - const getRes = await api.post(`/store/carts/test-cart-2/complete-cart`); + const api = useApi() + const getRes = await api.post(`/store/carts/test-cart-2/complete-cart`) - expect(getRes.status).toEqual(200); + expect(getRes.status).toEqual(200) - const variantRes = await api.get("/store/variants/test-variant"); - expect(variantRes.data.variant.inventory_quantity).toEqual(0); - }); + const variantRes = await api.get("/store/variants/test-variant") + expect(variantRes.data.variant.inventory_quantity).toEqual(0) + }) it("returns early, if cart is already completed", async () => { - const manager = dbConnection.manager; - const api = useApi(); + const manager = dbConnection.manager + const api = useApi() await manager.query( `UPDATE "cart" SET completed_at=current_timestamp WHERE id = 'test-cart-2'` - ); + ) try { - await api.post(`/store/carts/test-cart-2/complete-cart`); + await api.post(`/store/carts/test-cart-2/complete-cart`) } catch (error) { expect(error.response.data).toMatchSnapshot({ code: "not_allowed", message: "Cart has already been completed", code: "cart_incompatible_state", - }); - expect(error.response.status).toEqual(409); + }) + expect(error.response.status).toEqual(409) } - }); + }) it("fails to complete cart with items inventory not/partially covered", async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager const li = manager.create(LineItem, { id: "test-item", @@ -258,37 +264,110 @@ describe("/store/carts", () => { quantity: 99, variant_id: "test-variant-2", cart_id: "test-cart-2", - }); - await manager.save(li); + }) + await manager.save(li) - const api = useApi(); + const api = useApi() try { - await api.post(`/store/carts/test-cart-2/complete-cart`); + await api.post(`/store/carts/test-cart-2/complete-cart`) } catch (e) { expect(e.response.data).toMatchSnapshot({ code: "insufficient_inventory", - }); - expect(e.response.status).toBe(409); + }) + expect(e.response.status).toBe(409) } - //check to see if payment has been cancelled - const res = await api.get(`/store/carts/test-cart-2`); - expect(res.data.cart.payment.canceled_at).not.toBe(null); - }); - }); + //check to see if payment has been cancelled and cart is not completed + const res = await api.get(`/store/carts/test-cart-2`) + expect(res.data.cart.payment.canceled_at).not.toBe(null) + expect(res.data.cart.completed_at).toBe(null) + }) + + it("fails to complete swap cart with items inventory not/partially covered", async () => { + const manager = dbConnection.manager + + const li = manager.create(LineItem, { + id: "test-item", + title: "Line Item", + description: "Line Item Desc", + thumbnail: "https://test.js/1234", + unit_price: 8000, + quantity: 99, + variant_id: "test-variant-2", + cart_id: "swap-cart", + }) + await manager.save(li) + + await manager.query( + "UPDATE swap SET cart_id='swap-cart' where id='test-swap'" + ) + + const api = useApi() + + try { + await api.post(`/store/carts/swap-cart/complete-cart`) + } catch (e) { + expect(e.response.data).toMatchSnapshot({ + code: "insufficient_inventory", + }) + expect(e.response.status).toBe(409) + } + + //check to see if payment has been cancelled and cart is not completed + const res = await api.get(`/store/carts/swap-cart`) + expect(res.data.cart.payment_authorized_at).toBe(null) + expect(res.data.cart.payment.canceled_at).not.toBe(null) + }) + + it("successfully completes swap cart with items inventory not/partially covered due to backorder flag", async () => { + const manager = dbConnection.manager + + const li = manager.create(LineItem, { + id: "test-item", + title: "Line Item", + description: "Line Item Desc", + thumbnail: "https://test.js/1234", + unit_price: 8000, + quantity: 99, + variant_id: "test-variant-2", + cart_id: "swap-cart", + }) + await manager.save(li) + await manager.query( + "UPDATE swap SET cart_id='swap-cart' where id='test-swap'" + ) + await manager.query( + "UPDATE swap SET allow_backorder=true where id='test-swap'" + ) + await manager.query("DELETE FROM payment where swap_id='test-swap'") + + const api = useApi() + + try { + await api.post(`/store/carts/swap-cart/complete-cart`) + } catch (error) { + console.log(error) + } + + //check to see if payment is authorized and cart is completed + const res = await api.get(`/store/carts/swap-cart`) + expect(res.data.cart.payment_authorized_at).not.toBe(null) + expect(res.data.cart.completed_at).not.toBe(null) + }) + }) describe("POST /store/carts/:id/shipping-methods", () => { beforeEach(async () => { - await cartSeeder(dbConnection); - }); + await cartSeeder(dbConnection) + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("adds a shipping method to cart", async () => { - const api = useApi(); + const api = useApi() const cartWithShippingMethod = await api.post( "/store/carts/test-cart/shipping-methods", @@ -296,16 +375,16 @@ describe("/store/carts", () => { option_id: "test-option", }, { withCredentials: true } - ); + ) expect(cartWithShippingMethod.data.cart.shipping_methods).toContainEqual( expect.objectContaining({ shipping_option_id: "test-option" }) - ); - expect(cartWithShippingMethod.status).toEqual(200); - }); + ) + expect(cartWithShippingMethod.status).toEqual(200) + }) it("adds a giftcard to cart, but ensures discount only applied to discountable items", async () => { - const api = useApi(); + const api = useApi() // Add standard line item to cart await api.post( @@ -315,7 +394,7 @@ describe("/store/carts", () => { quantity: 1, }, { withCredentials: true } - ); + ) // Add gift card to cart await api.post( @@ -325,7 +404,7 @@ describe("/store/carts", () => { quantity: 1, }, { withCredentials: true } - ); + ) // Add a 10% discount to the cart const cartWithGiftcard = await api.post( @@ -334,16 +413,16 @@ describe("/store/carts", () => { discounts: [{ code: "10PERCENT" }], }, { withCredentials: true } - ); + ) // Ensure that the discount is only applied to the standard item - expect(cartWithGiftcard.data.cart.total).toBe(1900); // 1000 (giftcard) + 900 (standard item with 10% discount) - expect(cartWithGiftcard.data.cart.discount_total).toBe(100); - expect(cartWithGiftcard.status).toEqual(200); - }); + expect(cartWithGiftcard.data.cart.total).toBe(1900) // 1000 (giftcard) + 900 (standard item with 10% discount) + expect(cartWithGiftcard.data.cart.discount_total).toBe(100) + expect(cartWithGiftcard.status).toEqual(200) + }) it("adds no more than 1 shipping method per shipping profile", async () => { - const api = useApi(); + const api = useApi() const addShippingMethod = async (option_id) => { return await api.post( "/store/carts/test-cart/shipping-methods", @@ -351,17 +430,17 @@ describe("/store/carts", () => { option_id, }, { withCredentials: true } - ); - }; + ) + } - await addShippingMethod("test-option"); + await addShippingMethod("test-option") const cartWithAnotherShippingMethod = await addShippingMethod( "test-option-2" - ); + ) expect( cartWithAnotherShippingMethod.data.cart.shipping_methods.length - ).toEqual(1); + ).toEqual(1) expect( cartWithAnotherShippingMethod.data.cart.shipping_methods ).toContainEqual( @@ -369,30 +448,30 @@ describe("/store/carts", () => { shipping_option_id: "test-option-2", price: 500, }) - ); - expect(cartWithAnotherShippingMethod.status).toEqual(200); - }); - }); + ) + expect(cartWithAnotherShippingMethod.status).toEqual(200) + }) + }) describe("DELETE /store/carts/:id/discounts/:code", () => { beforeEach(async () => { try { - await cartSeeder(dbConnection); + await cartSeeder(dbConnection) await dbConnection.manager.query( `INSERT INTO "cart_discounts" (cart_id, discount_id) VALUES ('test-cart', 'free-shipping')` - ); + ) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("removes free shipping and updates shipping total", async () => { - const api = useApi(); + const api = useApi() const cartWithFreeShipping = await api.post( "/store/carts/test-cart", @@ -400,36 +479,36 @@ describe("/store/carts", () => { discounts: [{ code: "FREE_SHIPPING" }], }, { withCredentials: true } - ); + ) - expect(cartWithFreeShipping.data.cart.shipping_total).toBe(0); - expect(cartWithFreeShipping.status).toEqual(200); + expect(cartWithFreeShipping.data.cart.shipping_total).toBe(0) + expect(cartWithFreeShipping.status).toEqual(200) const response = await api.delete( "/store/carts/test-cart/discounts/FREE_SHIPPING" - ); + ) - expect(response.data.cart.shipping_total).toBe(1000); - expect(response.status).toEqual(200); - }); - }); + expect(response.data.cart.shipping_total).toBe(1000) + expect(response.status).toEqual(200) + }) + }) describe("get-cart with session customer", () => { beforeEach(async () => { try { - await cartSeeder(dbConnection); + await cartSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - await doAfterEach(); - }); + await doAfterEach() + }) it("updates empty cart.customer_id on cart retrieval", async () => { - const api = useApi(); + const api = useApi() let customer = await api.post( "/store/customers", @@ -440,29 +519,25 @@ describe("/store/carts", () => { last_name: "oli", }, { withCredentials: true } - ); + ) - const cookie = customer.headers["set-cookie"][0]; + const cookie = customer.headers["set-cookie"][0] - const cart = await api.post( - "/store/carts", - {}, - { withCredentials: true } - ); + const cart = await api.post("/store/carts", {}, { withCredentials: true }) const response = await api.get(`/store/carts/${cart.data.cart.id}`, { headers: { cookie, }, withCredentials: true, - }); + }) - expect(response.data.cart.customer_id).toEqual(customer.data.customer.id); - expect(response.status).toEqual(200); - }); + expect(response.data.cart.customer_id).toEqual(customer.data.customer.id) + expect(response.status).toEqual(200) + }) it("updates cart.customer_id on cart retrieval if cart.customer_id differ from session customer", async () => { - const api = useApi(); + const api = useApi() let customer = await api.post( "/store/customers", @@ -473,15 +548,15 @@ describe("/store/carts", () => { last_name: "oli", }, { withCredentials: true } - ); + ) - const cookie = customer.headers["set-cookie"][0]; + const cookie = customer.headers["set-cookie"][0] - const cart = await api.post("/store/carts"); + const cart = await api.post("/store/carts") const updatedCart = await api.post(`/store/carts/${cart.data.cart.id}`, { customer_id: "test-customer", - }); + }) const response = await api.get( `/store/carts/${updatedCart.data.cart.id}`, @@ -490,10 +565,10 @@ describe("/store/carts", () => { cookie, }, } - ); + ) - expect(response.data.cart.customer_id).toEqual(customer.data.customer.id); - expect(response.status).toEqual(200); - }); - }); -}); + expect(response.data.cart.customer_id).toEqual(customer.data.customer.id) + expect(response.status).toEqual(200) + }) + }) +}) diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index 1d769fb0c0..c59ea96e3f 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -14,31 +14,31 @@ const { LineItem, Payment, PaymentSession, -} = require("@medusajs/medusa"); +} = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { - const manager = connection.manager; + const manager = connection.manager const defaultProfile = await manager.findOne(ShippingProfile, { type: "default", - }); + }) const gcProfile = await manager.findOne(ShippingProfile, { type: "gift_card", - }); + }) await manager.insert(Address, { id: "test-general-address", first_name: "superman", country_code: "us", - }); + }) const r = manager.create(Region, { id: "test-region", name: "Test Region", currency_code: "usd", tax_rate: 0, - }); + }) const freeRule = manager.create(DiscountRule, { id: "free-shipping-rule", @@ -46,18 +46,18 @@ module.exports = async (connection, data = {}) => { type: "free_shipping", value: 100, allocation: "total", - }); + }) const freeDisc = manager.create(Discount, { id: "free-shipping", code: "FREE_SHIPPING", is_dynamic: false, is_disabled: false, - }); + }) - freeDisc.regions = [r]; - freeDisc.rule = freeRule; - await manager.save(freeDisc); + freeDisc.regions = [r] + freeDisc.rule = freeRule + await manager.save(freeDisc) const tenPercentRule = manager.create(DiscountRule, { id: "tenpercent-rule", @@ -65,25 +65,25 @@ module.exports = async (connection, data = {}) => { type: "percentage", value: 10, allocation: "total", - }); + }) const tenPercent = manager.create(Discount, { id: "10Percent", code: "10PERCENT", is_dynamic: false, is_disabled: false, - }); + }) - tenPercent.regions = [r]; - tenPercent.rule = tenPercentRule; - await manager.save(tenPercent); + tenPercent.regions = [r] + tenPercent.rule = tenPercentRule + await manager.save(tenPercent) const d = await manager.create(Discount, { id: "test-discount", code: "CREATED", is_dynamic: false, is_disabled: false, - }); + }) const dr = await manager.create(DiscountRule, { id: "test-discount-rule", @@ -91,31 +91,31 @@ module.exports = async (connection, data = {}) => { type: "fixed", value: 10000, allocation: "total", - }); + }) - d.rule = dr; - d.regions = [r]; + d.rule = dr + d.regions = [r] - await manager.save(d); + await manager.save(d) await manager.query( `UPDATE "country" SET region_id='test-region' WHERE iso_2 = 'us'` - ); + ) await manager.insert(Customer, { id: "test-customer", email: "test@email.com", - }); + }) await manager.insert(Customer, { id: "test-customer-2", email: "test-2@email.com", - }); + }) await manager.insert(Customer, { id: "some-customer", email: "some-customer@email.com", - }); + }) await manager.insert(ShippingOption, { id: "test-option", @@ -126,7 +126,7 @@ module.exports = async (connection, data = {}) => { price_type: "flat_rate", amount: 1000, data: {}, - }); + }) await manager.insert(ShippingOption, { id: "gc-option", @@ -137,7 +137,7 @@ module.exports = async (connection, data = {}) => { price_type: "flat_rate", amount: 0, data: {}, - }); + }) await manager.insert(ShippingOption, { id: "test-option-2", @@ -148,7 +148,7 @@ module.exports = async (connection, data = {}) => { price_type: "flat_rate", amount: 500, data: {}, - }); + }) await manager.insert(Product, { id: "giftcard-product", @@ -157,7 +157,7 @@ module.exports = async (connection, data = {}) => { discountable: false, profile_id: gcProfile.id, options: [{ id: "denom", title: "Denomination" }], - }); + }) await manager.insert(ProductVariant, { id: "giftcard-denom", @@ -170,14 +170,14 @@ module.exports = async (connection, data = {}) => { value: "1000", }, ], - }); + }) await manager.insert(Product, { id: "test-product", title: "test product", profile_id: defaultProfile.id, options: [{ id: "test-option", title: "Size" }], - }); + }) await manager.insert(ProductVariant, { id: "test-variant", @@ -190,7 +190,7 @@ module.exports = async (connection, data = {}) => { value: "Size", }, ], - }); + }) await manager.insert(ProductVariant, { id: "test-variant-2", @@ -203,31 +203,31 @@ module.exports = async (connection, data = {}) => { value: "Size", }, ], - }); + }) const ma = manager.create(MoneyAmount, { variant_id: "test-variant", currency_code: "usd", amount: 1000, - }); + }) - await manager.save(ma); + await manager.save(ma) const ma2 = manager.create(MoneyAmount, { variant_id: "test-variant-2", currency_code: "usd", amount: 8000, - }); + }) - await manager.save(ma2); + await manager.save(ma2) const ma3 = manager.create(MoneyAmount, { variant_id: "giftcard-denom", currency_code: "usd", amount: 1000, - }); + }) - await manager.save(ma3); + await manager.save(ma3) const cart = manager.create(Cart, { id: "test-cart", @@ -241,9 +241,9 @@ module.exports = async (connection, data = {}) => { region_id: "test-region", currency_code: "usd", items: [], - }); + }) - await manager.save(cart); + await manager.save(cart) const cart2 = manager.create(Cart, { id: "test-cart-2", @@ -258,7 +258,26 @@ module.exports = async (connection, data = {}) => { currency_code: "usd", completed_at: null, items: [], - }); + }) + + const swapCart = manager.create(Cart, { + id: "swap-cart", + type: "swap", + customer_id: "some-customer", + email: "some-customer@email.com", + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + completed_at: null, + items: [], + metadata: { + swap_id: "test-swap", + }, + }) const pay = manager.create(Payment, { id: "test-payment", @@ -267,13 +286,25 @@ module.exports = async (connection, data = {}) => { amount_refunded: 0, provider_id: "test-pay", data: {}, - }); + }) - await manager.save(pay); + const swapPay = manager.create(Payment, { + id: "test-swap-payment", + amount: 10000, + currency_code: "usd", + amount_refunded: 0, + provider_id: "test-pay", + data: {}, + }) - cart2.payment = pay; + await manager.save(pay) + await manager.save(swapPay) - await manager.save(cart2); + cart2.payment = pay + swapCart.payment = swapPay + + await manager.save(cart2) + await manager.save(swapCart) await manager.insert(PaymentSession, { id: "test-session", @@ -282,7 +313,16 @@ module.exports = async (connection, data = {}) => { is_selected: true, data: {}, status: "authorized", - }); + }) + + await manager.insert(PaymentSession, { + id: "test-swap-session", + cart_id: "swap-cart", + provider_id: "test-pay", + is_selected: true, + data: {}, + status: "authorized", + }) await manager.insert(ShippingMethod, { id: "test-method", @@ -290,7 +330,7 @@ module.exports = async (connection, data = {}) => { cart_id: "test-cart", price: 1000, data: {}, - }); + }) const li = manager.create(LineItem, { id: "test-item", @@ -301,8 +341,8 @@ module.exports = async (connection, data = {}) => { quantity: 1, variant_id: "test-variant", cart_id: "test-cart-2", - }); - await manager.save(li); + }) + await manager.save(li) const cart3 = manager.create(Cart, { id: "test-cart-3", @@ -317,8 +357,8 @@ module.exports = async (connection, data = {}) => { currency_code: "usd", completed_at: null, items: [], - }); - await manager.save(cart3); + }) + await manager.save(cart3) await manager.insert(ShippingMethod, { id: "test-method-2", @@ -326,7 +366,7 @@ module.exports = async (connection, data = {}) => { cart_id: "test-cart-3", price: 0, data: {}, - }); + }) const li2 = manager.create(LineItem, { id: "test-item-2", @@ -337,6 +377,6 @@ module.exports = async (connection, data = {}) => { quantity: 1, variant_id: "test-variant", cart_id: "test-cart-3", - }); - await manager.save(li2); -}; + }) + await manager.save(li2) +} diff --git a/integration-tests/api/helpers/swap-seeder.js b/integration-tests/api/helpers/swap-seeder.js index 7fcc9e6395..e19203493f 100644 --- a/integration-tests/api/helpers/swap-seeder.js +++ b/integration-tests/api/helpers/swap-seeder.js @@ -14,10 +14,10 @@ const { Swap, Cart, Return, -} = require("@medusajs/medusa"); +} = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { - const manager = connection.manager; + const manager = connection.manager let orderWithSwap = manager.create(Order, { id: "order-with-swap", @@ -50,12 +50,12 @@ module.exports = async (connection, data = {}) => { ], items: [], ...data, - }); + }) - orderWithSwap = await manager.save(orderWithSwap); + orderWithSwap = await manager.save(orderWithSwap) const cart = manager.create(Cart, { - id: "test-cart", + id: "test-cart-w-swap", customer_id: "test-customer", email: "test-customer@email.com", shipping_address_id: "test-shipping-address", @@ -66,16 +66,16 @@ module.exports = async (connection, data = {}) => { swap_id: "test-swap", parent_order_id: orderWithSwap.id, }, - }); + }) - await manager.save(cart); + await manager.save(cart) const swap = manager.create(Swap, { id: "test-swap", order_id: "order-with-swap", payment_status: "captured", fulfillment_status: "fulfilled", - cart_id: "test-cart", + cart_id: "test-cart-w-swap", payment: { id: "test-payment-swap", amount: 10000, @@ -94,12 +94,12 @@ module.exports = async (connection, data = {}) => { unit_price: 9000, quantity: 1, variant_id: "test-variant-2", - cart_id: "test-cart", + cart_id: "test-cart-w-swap", }, ], - }); + }) - await manager.save(swap); + await manager.save(swap) const cartTemplate = async (cartId) => { const cart = manager.create(Cart, { @@ -176,9 +176,9 @@ module.exports = async (connection, data = {}) => { variant_id: "test-variant", order_id: orderWithSwap.id, cart_id: cart.id, - }); + }) - await manager.save(li); + await manager.save(li) const li2 = manager.create(LineItem, { id: "test-item-many", @@ -190,34 +190,33 @@ module.exports = async (connection, data = {}) => { quantity: 4, variant_id: "test-variant", order_id: orderWithSwap.id, - }); + }) - await manager.save(li2); + await manager.save(li2) const swapReturn = await manager.create(Return, { swap_id: swap.id, order_id: orderWithSwap.id, item_id: li.id, refund_amount: li.quantity * li.unit_price, - // shipping_method_id: , - }); + }) - await manager.save(swapReturn); + await manager.save(swapReturn) const return_item1 = manager.create(LineItem, { ...li, unit_price: -1 * li.unit_price, - }); + }) - await manager.save(return_item1); + await manager.save(return_item1) await manager.insert(ShippingMethod, { id: "another-test-method", shipping_option_id: "test-option", - cart_id: "test-cart", + cart_id: "test-cart-w-swap", price: 1000, data: {}, - }); + }) const swapOnSwap = manager.create(Swap, { id: "swap-on-swap", @@ -255,9 +254,9 @@ module.exports = async (connection, data = {}) => { variant_id: "test-variant", }, ], - }); + }) - await manager.save(swapOnSwap); + await manager.save(swapOnSwap) await manager.insert(ShippingMethod, { id: "test-method-swap-order", @@ -265,5 +264,5 @@ module.exports = async (connection, data = {}) => { order_id: "order-with-swap", price: 1000, data: {}, - }); -}; + }) +} diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index adc7e6d917..7abd7c9b69 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,15 +8,15 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.1.40-dev-1631178030541", - "medusa-interfaces": "1.1.21", + "@medusajs/medusa": "1.1.40-dev-1631630701835", + "medusa-interfaces": "1.1.21-dev-1631630701835", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.13", + "babel-preset-medusa-package": "1.1.13-dev-1631630701835", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index afe977164c..2ba33d481a 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1223,10 +1223,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@medusajs/medusa-cli@^1.1.16": - version "1.1.16" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.16.tgz#3ddcd5b16388a387c430116b962bb27a933ee85e" - integrity sha512-QvE7IYkR3NFiy4seZklfX+Xs/dJannVLbKfxLQbxCV2Sso3ZtJbSJt1BpTUwDxYjOFWXyTxRRjO1kEnA1yqCBA== +"@medusajs/medusa-cli@1.1.16-dev-1631630701835": + version "1.1.16-dev-1631630701835" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.16-dev-1631630701835.tgz#7fcb95cb9a45e0367cc5becfff7f5d1533b46b5f" + integrity sha512-UomtR8B1lBFDb3h1y060fOcWcZi812Jwt8Kgjxqtpn+aRj6Bu7+I3WJGHBVSx4VnUBINSYbtiQMpEwqVGTCKnw== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1244,8 +1244,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "^0.1.27" - medusa-telemetry "^0.0.3" + medusa-core-utils "1.1.20-dev-1631630701835" + medusa-telemetry "0.0.3-dev-1631630701835" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1259,13 +1259,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.1.40-dev-1631178030541": - version "1.1.40-dev-1631178030541" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.40-dev-1631178030541.tgz#d693bdd9461e2281d387b26ad54b72c30e53218b" - integrity sha512-hhh67dltQ9dZYXBzA8FB8NrCaQ8bdALEm48t7oZ0J+GueG0fa6kcjU7Ud0uyy1vdov2zN4NOZmxGpcAw3n4alg== +"@medusajs/medusa@1.1.40-dev-1631630701835": + version "1.1.40-dev-1631630701835" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.40-dev-1631630701835.tgz#fa67ceda5887fd31196b3bcfd3115a9e02d68448" + integrity sha512-svPsKonuBrwRgtYod7U7ho9bN84K7N/QorMJG9+wklEO4jp6zXG+U5DQcfVAKQ00cHHe50OcnfX1ZS0kVNovYw== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "^1.1.16" + "@medusajs/medusa-cli" "1.1.16-dev-1631630701835" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1286,8 +1286,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "^1.1.20" - medusa-test-utils "^1.1.23" + medusa-core-utils "1.1.20-dev-1631630701835" + medusa-test-utils "1.1.23-dev-1631630701835" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -1932,10 +1932,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.13: - version "1.1.13" - resolved "https://registry.yarnpkg.com/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.13.tgz#9dc4e64e08436fb7b3536cef0f363a535e126474" - integrity sha512-Q9t06udxwMnfwyx7gyxoUKiZj/dtYSSXBtQ+K4ntY1hzMhOK2hBBInuiTgnLQS1cxc4j+FN2oYYPCpspX/acaw== +babel-preset-medusa-package@1.1.13-dev-1631630701835: + version "1.1.13-dev-1631630701835" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.13-dev-1631630701835.tgz#5b66b3738e4904e31b2db30a6ea8e68eb0f8f641" + integrity sha512-V7sXlktlvEON7FLhxe+Y3NVe8l8DQyB5oJTryG4Bhw8y1AaUFOiQ5Vat3XuoL3qRcUSVMGL4VHw0m0O78t0PuA== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -2867,6 +2867,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dom-walk@^0.1.0: + version "0.1.2" + resolved "http://localhost:4873/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -3588,6 +3593,14 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl once "^1.3.0" path-is-absolute "^1.0.0" +global@^4.4.0: + version "4.4.0" + resolved "http://localhost:4873/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5091,50 +5104,43 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@^0.1.27: - version "0.1.39" - resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-0.1.39.tgz#d57816c9bd43f9a92883650c1e66add1665291df" - integrity sha512-R8+U1ile7if+nR6Cjh5exunx0ETV0OfkWUUBUpz1KmHSDv0V0CcvQqU9lcZesPFDEbu3Y2iEjsCqidVA4nG2nQ== - dependencies: - "@hapi/joi" "^16.1.8" - joi-objectid "^3.0.1" - -medusa-core-utils@^1.1.20: - version "1.1.20" - resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.20.tgz#676c0dc863a206b80cc53299a984c532d07df65f" - integrity sha512-gf+/L5eeqHea3xgjwD7YZEzfUGlxbjfvaeiiGWi3Wfu0dLa+G1B4S0TsX+upR+oVeWPmk66VMqWC80h3e4csqw== +medusa-core-utils@1.1.20-dev-1631630701835: + version "1.1.20-dev-1631630701835" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.20-dev-1631630701835.tgz#1fa7ccd2551b7891127d4f07f708029c585f4ea8" + integrity sha512-KKBo6W1QI47Ig3KMV4UXQnQN5JilMfjR6Cx7hDNj4frJoNiWa/YKDYqUr6SmY2+iJtKetnLkrKaPsDyyhZrxcw== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.1.21: - version "1.1.21" - resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.21.tgz#ca86808e939b7ecc21a6d316008a4e41f163619f" - integrity sha512-mlHHoMIOFBc+Exs+uVIQsfeEP2C1Pi6IZHcpbm7O00tYBdQdqRjJre9+Z/I/Z37wt5IwA28/TIoVkYG71iQYxw== +medusa-interfaces@1.1.21-dev-1631630701835: + version "1.1.21-dev-1631630701835" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.21-dev-1631630701835.tgz#af29b2ef0c987bded1b2d295ac6cf39880af551e" + integrity sha512-rTASRjOdcS3J9fP95p9vJzCpatMpUhTum5ddfAA0s42pZx2gsPlf1f+rUSNz5QfeC5RdIEzRfOmAGfvMpAbYGw== dependencies: - medusa-core-utils "^1.1.20" + medusa-core-utils "1.1.20-dev-1631630701835" -medusa-telemetry@^0.0.3: - version "0.0.3" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.3.tgz#c11e5e0f3cc969f3eaee41d1c24f78a5c0715362" - integrity sha512-Qb/sgOwO8t2Sjjo4nKyBa6hKZ/SjniT4eEWenygEaJDqXZhfogVYGhWc5gn4tLlFFNEHXzDTlrqX2LvzfEJWIw== +medusa-telemetry@0.0.3-dev-1631630701835: + version "0.0.3-dev-1631630701835" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.3-dev-1631630701835.tgz#d56c01d261fa30ccedc6d9976971b9744b9d8c0f" + integrity sha512-FS1L1DOIOSdRZgeIQWaM5nhFG5NtbnC/Pntfac51vQxLkzFuHy7ZEtg11CXKE+x6NWlqT1rqqgxq0EabFzEZzw== dependencies: axios "^0.21.1" axios-retry "^3.1.9" boxen "^5.0.1" ci-info "^3.2.0" configstore "5.0.1" + global "^4.4.0" is-docker "^2.2.1" remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@^1.1.23: - version "1.1.23" - resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.23.tgz#e8380df499979cd0b97a5bb87779662f4da9d722" - integrity sha512-okyUgB4t7bqDieE0XO+HkbVVemn6hE1tTAtF9PXRi2igmKmcnyW/Ljk3lqrKYVhjei4z3Z/b+K2b0oNwhopbGQ== +medusa-test-utils@1.1.23-dev-1631630701835: + version "1.1.23-dev-1631630701835" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.23-dev-1631630701835.tgz#8995d636caf2dea9ebb184f1e15b0c364c4d1b93" + integrity sha512-A8xRL+sZS22qXZSHpVfdV8f/egZxXs4iExRO2xUkTP6I/OgMhFBSg6nEd/DXVdVfpsHZCDEv8PA3ewaeAkoYhQ== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "^1.1.20" + medusa-core-utils "1.1.20-dev-1631630701835" randomatic "^3.1.1" merge-descriptors@1.0.1: @@ -5218,6 +5224,13 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-document@^2.19.0: + version "2.19.0" + resolved "http://localhost:4873/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= + dependencies: + dom-walk "^0.1.0" + minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -6060,6 +6073,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process@^0.11.10: + version "0.11.10" + resolved "http://localhost:4873/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + promise.prototype.finally@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/promise.prototype.finally/-/promise.prototype.finally-3.1.2.tgz#b8af89160c9c673cefe3b4c4435b53cfd0287067" diff --git a/packages/medusa/src/api/routes/admin/orders/create-swap.js b/packages/medusa/src/api/routes/admin/orders/create-swap.js index 25ad45604e..16374a9a20 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-swap.js +++ b/packages/medusa/src/api/routes/admin/orders/create-swap.js @@ -48,6 +48,9 @@ import { defaultFields, defaultRelations } from "./" * no_notification: * description: If set to true no notification will be send related to this Swap. * type: boolean + * allow_backorder: + * description: If true, swaps can be completed with items out of stock + * type: boolean * tags: * - Order * responses: @@ -83,6 +86,7 @@ export default async (req, res) => { quantity: Validator.number().required(), }), no_notification: Validator.boolean().optional(), + allow_backorder: Validator.boolean().default(true), }) const { value, error } = schema.validate(req.body) @@ -141,6 +145,7 @@ export default async (req, res) => { { idempotency_key: idempotencyKey.idempotency_key, no_notification: value.no_notification, + allow_backorder: value.allow_backorder, } ) diff --git a/packages/medusa/src/api/routes/store/carts/complete-cart.js b/packages/medusa/src/api/routes/store/carts/complete-cart.js index df72d086fa..5ed859b16b 100644 --- a/packages/medusa/src/api/routes/store/carts/complete-cart.js +++ b/packages/medusa/src/api/routes/store/carts/complete-cart.js @@ -138,18 +138,36 @@ export default async (req, res) => { // If cart is part of swap, we register swap as complete switch (cart.type) { case "swap": { - const swapId = cart.metadata?.swap_id - let swap = await swapService - .withTransaction(manager) - .registerCartCompletion(swapId) + try { + const swapId = cart.metadata?.swap_id + let swap = await swapService + .withTransaction(manager) + .registerCartCompletion(swapId) - swap = await swapService - .withTransaction(manager) - .retrieve(swap.id, { relations: ["shipping_address"] }) + swap = await swapService + .withTransaction(manager) + .retrieve(swap.id, { relations: ["shipping_address"] }) - return { - response_code: 200, - response_body: { data: swap, type: "swap" }, + return { + response_code: 200, + response_body: { data: swap, type: "swap" }, + } + } catch (error) { + if ( + error && + error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY + ) { + return { + response_code: 409, + response_body: { + message: error.message, + type: error.type, + code: error.code, + }, + } + } else { + throw error + } } } // case "payment_link": diff --git a/packages/medusa/src/migrations/1630505790603-allow_backorder_swaps.ts b/packages/medusa/src/migrations/1630505790603-allow_backorder_swaps.ts new file mode 100644 index 0000000000..1ecbfd4026 --- /dev/null +++ b/packages/medusa/src/migrations/1630505790603-allow_backorder_swaps.ts @@ -0,0 +1,24 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class allowBackorderSwaps1630505790603 implements MigrationInterface { + name = 'allowBackorderSwaps1630505790603' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "swap" ADD "allow_backorder" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "cart" ADD "payment_authorized_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TYPE "swap_payment_status_enum" RENAME TO "swap_payment_status_enum_old"`); + await queryRunner.query(`CREATE TYPE "swap_payment_status_enum" AS ENUM('not_paid', 'awaiting', 'captured', 'confirmed', 'canceled', 'difference_refunded', 'partially_refunded', 'refunded', 'requires_action')`); + await queryRunner.query(`ALTER TABLE "swap" ALTER COLUMN "payment_status" TYPE "swap_payment_status_enum" USING "payment_status"::"text"::"swap_payment_status_enum"`); + await queryRunner.query(`DROP TYPE "swap_payment_status_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "swap_payment_status_enum_old" AS ENUM('not_paid', 'awaiting', 'captured', 'canceled', 'difference_refunded', 'partially_refunded', 'refunded', 'requires_action')`); + await queryRunner.query(`ALTER TABLE "swap" ALTER COLUMN "payment_status" TYPE "swap_payment_status_enum_old" USING "payment_status"::"text"::"swap_payment_status_enum_old"`); + await queryRunner.query(`DROP TYPE "swap_payment_status_enum"`); + await queryRunner.query(`ALTER TYPE "swap_payment_status_enum_old" RENAME TO "swap_payment_status_enum"`); + await queryRunner.query(`ALTER TABLE "cart" DROP COLUMN "payment_authorized_at"`); + await queryRunner.query(`ALTER TABLE "swap" DROP COLUMN "allow_backorder"`); + } + +} diff --git a/packages/medusa/src/models/cart.ts b/packages/medusa/src/models/cart.ts index 1786681024..d6c251ad40 100644 --- a/packages/medusa/src/models/cart.ts +++ b/packages/medusa/src/models/cart.ts @@ -230,6 +230,9 @@ export class Cart { @Column({ type: resolveDbType("timestamptz"), nullable: true }) completed_at: Date + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + payment_authorized_at: Date + @CreateDateColumn({ type: resolveDbType("timestamptz") }) created_at: Date diff --git a/packages/medusa/src/models/swap.ts b/packages/medusa/src/models/swap.ts index 9628c81732..cb44a825cd 100644 --- a/packages/medusa/src/models/swap.ts +++ b/packages/medusa/src/models/swap.ts @@ -38,6 +38,7 @@ export enum PaymentStatus { NOT_PAID = "not_paid", AWAITING = "awaiting", CAPTURED = "captured", + CONFIRMED = "confirmed", CANCELED = "canceled", DIFFERENCE_REFUNDED = "difference_refunded", PARTIALLY_REFUNDED = "partially_refunded", @@ -137,6 +138,9 @@ export class Swap { @Column({ type: "boolean", nullable: true }) no_notification: Boolean + @Column({ type: "boolean", default: false }) + allow_backorder: Boolean + @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: any @@ -219,6 +223,9 @@ export class Swap { * cart_id: * description: "The id of the Cart that the Customer will use to confirm the Swap." * type: string + * allow_backorder: + * description: "If true, swaps can be completed with items out of stock" + * type: boolean * confirmed_at: * description: "The date with timezone at which the Swap was confirmed by the Customer." * type: string diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index 5da04f644e..76db532917 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -203,6 +203,7 @@ describe("OrderService", () => { } orderService.cartService_.retrieve = jest.fn(() => Promise.resolve(cart)) + orderService.cartService_.update = jest.fn(() => Promise.resolve()) await orderService.createFromCart("cart_id") const order = { @@ -305,6 +306,7 @@ describe("OrderService", () => { orderService.cartService_.retrieve = () => { return Promise.resolve(cart) } + orderService.cartService_.update = () => Promise.resolve() await orderService.createFromCart("cart_id") const order = { @@ -453,6 +455,7 @@ describe("OrderService", () => { total: 100, } orderService.cartService_.retrieve = () => Promise.resolve(cart) + orderService.cartService_.update = () => Promise.resolve() const res = orderService.createFromCart(cart) await expect(res).rejects.toThrow( "Variant with id: variant-1 does not have the required inventory" diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js index ebf9c2fd47..9cc4088174 100644 --- a/packages/medusa/src/services/__tests__/swap.js +++ b/packages/medusa/src/services/__tests__/swap.js @@ -741,6 +741,13 @@ describe("SwapService", () => { }, } + const cartService = { + update: jest.fn(), + withTransaction: function() { + return this + }, + } + const swapRepo = MockRepository({ findOneWithRelations: () => Promise.resolve(existing), }) @@ -752,6 +759,7 @@ describe("SwapService", () => { lineItemService, eventBusService, fulfillmentService, + cartService, }) it("creates a shipment", async () => { @@ -831,6 +839,15 @@ describe("SwapService", () => { }, } + const cartService = { + update: () => { + return Promise.resolve() + }, + withTransaction: function() { + return this + }, + } + const paymentProviderService = { getStatus: jest.fn(() => { return Promise.resolve("authorized") @@ -838,6 +855,9 @@ describe("SwapService", () => { updatePayment: jest.fn(() => { return Promise.resolve() }), + cancelPayment: jest.fn(() => { + return Promise.resolve() + }), withTransaction: function() { return this }, @@ -872,6 +892,7 @@ describe("SwapService", () => { eventBusService, swapRepository: swapRepo, totalsService, + cartService, paymentProviderService, eventBusService, shippingOptionService, @@ -933,6 +954,7 @@ describe("SwapService", () => { eventBusService, swapRepository: swapRepo, totalsService, + cartService, paymentProviderService, eventBusService, shippingOptionService, diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 5052d57cce..1dfba8d51c 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -682,6 +682,14 @@ class CartService extends BaseService { } } + if ("completed_at" in update) { + cart.completed_at = update.completed_at + } + + if ("payment_authorized_at" in update) { + cart.payment_authorized_at = update.payment_authorized_at + } + const result = await cartRepo.save(cart) if ("email" in update || "customer_id" in update) { @@ -1027,7 +1035,7 @@ class CartService extends BaseService { // If cart total is 0, we don't perform anything payment related if (cart.total <= 0) { - cart.completed_at = new Date() + cart.payment_authorized_at = new Date() return cartRepository.save(cart) } @@ -1046,7 +1054,7 @@ class CartService extends BaseService { .createPayment(freshCart) freshCart.payment = payment - freshCart.completed_at = new Date() + freshCart.payment_authorized_at = new Date() } const updated = await cartRepository.save(freshCart) @@ -1352,7 +1360,7 @@ class CartService extends BaseService { * @return {Promise} the result of the update operation */ async setRegion_(cart, regionId, countryCode) { - if (cart.completed_at) { + if (cart.completed_at || cart.payment_authorized_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, "Cannot change the region of a completed cart" @@ -1494,6 +1502,13 @@ class CartService extends BaseService { ) } + if (cart.payment_authorized_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Can't delete a cart with an authorized payment" + ) + } + const cartRepo = manager.getCustomRepository(this.cartRepository_) return cartRepo.remove(cartId) }) diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 374689742e..86d43f4d78 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -477,6 +477,9 @@ class OrderService extends BaseService { .withTransaction(manager) .cancelPayment(payment) } + await this.cartService_ + .withTransaction(manager) + .update(cart.id, { payment_authorized_at: null }) throw err } } @@ -595,6 +598,10 @@ class OrderService extends BaseService { no_notification: result.no_notification, }) + await this.cartService_ + .withTransaction(manager) + .update(cart.id, { completed_at: new Date() }) + return result }) } diff --git a/packages/medusa/src/services/payment-provider.js b/packages/medusa/src/services/payment-provider.js index 354985cb17..efbfff9f3d 100644 --- a/packages/medusa/src/services/payment-provider.js +++ b/packages/medusa/src/services/payment-provider.js @@ -328,7 +328,7 @@ class PaymentProviderService extends BaseService { payment.canceled_at = now.toISOString() const paymentRepo = manager.getCustomRepository(this.paymentRepository_) - return paymentRepo.save(payment) + return await paymentRepo.save(payment) }) } diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js index 5559d04e35..886a6f70c0 100644 --- a/packages/medusa/src/services/swap.js +++ b/packages/medusa/src/services/swap.js @@ -665,20 +665,33 @@ class SwapService extends BaseService { } const cart = swap.cart + const { payment } = cart const items = swap.cart.items - for (const item of items) { - await this.inventoryService_ - .withTransaction(manager) - .confirmInventory(item.variant_id, item.quantity) + if (!swap.allow_backorder) { + for (const item of items) { + try { + await this.inventoryService_ + .withTransaction(manager) + .confirmInventory(item.variant_id, item.quantity) + } catch (err) { + if (payment) { + await this.paymentProviderService_ + .withTransaction(manager) + .cancelPayment(payment) + } + await this.cartService_ + .withTransaction(manager) + .update(cart.id, { payment_authorized_at: null }) + throw err + } + } } const total = await this.totalsService_.getTotal(cart) if (total > 0) { - const { payment } = cart - if (!payment) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, @@ -717,7 +730,7 @@ class SwapService extends BaseService { swap.shipping_address_id = cart.shipping_address_id swap.shipping_methods = cart.shipping_methods swap.confirmed_at = now.toISOString() - swap.payment_status = total === 0 ? "difference_refunded" : "awaiting" + swap.payment_status = total === 0 ? "confirmed" : "awaiting" const swapRepo = manager.getCustomRepository(this.swapRepository_) const result = await swapRepo.save(swap) @@ -737,6 +750,10 @@ class SwapService extends BaseService { no_notification: swap.no_notification, }) + await this.cartService_ + .withTransaction(manager) + .update(cart.id, { completed_at: new Date() }) + return result }) } From 49a132976ded4d094a5129029743f73a020ecf04 Mon Sep 17 00:00:00 2001 From: Zakaria El Asri <33696020+zakariaelas@users.noreply.github.com> Date: Tue, 21 Sep 2021 10:15:33 +0100 Subject: [PATCH 04/19] fix: ILIKE operator not supported in sqlite (#393) * fix: replace ILIKE operator with ILike function where possible * remove: unused import * fix: remove alias from test case as it is not needed * fix: product variant query * add: integration tests + fallback to original query for all queries searching on the display_id field * remove: console.log --- .../api/__tests__/admin/customer.js | 100 +-- .../api/__tests__/admin/discount.js | 61 ++ .../api/__tests__/admin/gift-cards.js | 180 ++++- .../api/__tests__/admin/order.js | 687 +++++++++--------- .../api/__tests__/admin/variant.js | 153 ++++ .../medusa/src/services/__tests__/discount.js | 3 - packages/medusa/src/services/customer.js | 18 +- packages/medusa/src/services/discount.js | 8 +- packages/medusa/src/services/draft-order.js | 2 +- packages/medusa/src/services/gift-card.js | 7 +- packages/medusa/src/services/order.js | 7 +- .../medusa/src/services/product-variant.js | 15 +- 12 files changed, 793 insertions(+), 448 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/variant.js diff --git a/integration-tests/api/__tests__/admin/customer.js b/integration-tests/api/__tests__/admin/customer.js index c208e3086f..1458311eff 100644 --- a/integration-tests/api/__tests__/admin/customer.js +++ b/integration-tests/api/__tests__/admin/customer.js @@ -1,50 +1,50 @@ -const { dropDatabase } = require("pg-god"); -const path = require("path"); +const { dropDatabase } = require("pg-god") +const path = require("path") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { useDb, initDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { useDb, initDb } = require("../../../helpers/use-db") -const customerSeeder = require("../../helpers/customer-seeder"); -const adminSeeder = require("../../helpers/admin-seeder"); +const customerSeeder = require("../../helpers/customer-seeder") +const adminSeeder = require("../../helpers/admin-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/customers", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); + const db = useDb() + await db.shutdown() - medusaProcess.kill(); - }); + medusaProcess.kill() + }) describe("GET /admin/customers", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await customerSeeder(dbConnection); + await adminSeeder(dbConnection) + await customerSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("lists customers and query count", async () => { - const api = useApi(); + const api = useApi() const response = await api .get("/admin/customers", { @@ -53,11 +53,11 @@ describe("/admin/customers", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.count).toEqual(3); + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) expect(response.data.customers).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -70,24 +70,24 @@ describe("/admin/customers", () => { id: "test-customer-3", }), ]) - ); - }); + ) + }) it("lists customers with specific query", async () => { - const api = useApi(); + const api = useApi() const response = await api - .get("/admin/customers?q=test2@email.com", { + .get("/admin/customers?q=est2@", { headers: { Authorization: "Bearer test_token", }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.count).toEqual(1); + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) expect(response.data.customers).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -95,11 +95,11 @@ describe("/admin/customers", () => { email: "test2@email.com", }), ]) - ); - }); + ) + }) it("lists customers with expand query", async () => { - const api = useApi(); + const api = useApi() const response = await api .get("/admin/customers?q=test1@email.com&expand=shipping_addresses", { @@ -108,11 +108,11 @@ describe("/admin/customers", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.count).toEqual(1); + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) expect(response.data.customers).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -126,7 +126,7 @@ describe("/admin/customers", () => { ]), }), ]) - ); - }); - }); -}); + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index a731cec416..cbc124ce53 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -24,6 +24,67 @@ describe("/admin/discounts", () => { medusaProcess.kill() }) + describe("GET /admin/discounts", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + await manager.insert(DiscountRule, { + id: "test-discount-rule", + description: "Test discount rule", + type: "percentage", + value: 10, + allocation: "total", + }) + await manager.insert(Discount, { + id: "test-discount", + code: "TESTING", + rule_id: "test-discount-rule", + is_dynamic: false, + is_disabled: false, + }) + await manager.insert(Discount, { + id: "messi-discount", + code: "BARCA100", + rule_id: "test-discount-rule", + is_dynamic: false, + is_disabled: false, + }) + } catch (err) { + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should list discounts that match a specific query in a case insensitive manner", async () => { + const api = useApi() + + const response = await api + .get("/admin/discounts?q=barca", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.discounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "messi-discount", + code: "BARCA100", + }), + ]) + ) + }) + }) + describe("POST /admin/discounts", () => { beforeEach(async () => { try { diff --git a/integration-tests/api/__tests__/admin/gift-cards.js b/integration-tests/api/__tests__/admin/gift-cards.js index 5b71d5bc5c..219e8b54d1 100644 --- a/integration-tests/api/__tests__/admin/gift-cards.js +++ b/integration-tests/api/__tests__/admin/gift-cards.js @@ -1,54 +1,162 @@ -const path = require("path"); -const { Region } = require("@medusajs/medusa"); +const path = require("path") +const { Region, GiftCard } = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); -const adminSeeder = require("../../helpers/admin-seeder"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const adminSeeder = require("../../helpers/admin-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/gift-cards", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); + const db = useDb() + await db.shutdown() - medusaProcess.kill(); - }); + medusaProcess.kill() + }) + + describe("GET /admin/gift-cards", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + await manager.insert(Region, { + id: "test-region", + name: "Test Region", + currency_code: "usd", + tax_rate: 0, + }) + await manager.insert(GiftCard, { + id: "gift_test", + code: "GC_TEST", + value: 20000, + balance: 20000, + region_id: "test-region", + }) + await manager.insert(GiftCard, { + id: "another_gift_test", + code: "CARD_TEST", + value: 200000, + balance: 200000, + region_id: "test-region", + }) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("lists gift cards and query count", async () => { + const api = useApi() + + const response = await api + .get("/admin/gift-cards", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.gift_cards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "gift_test", + code: "GC_TEST", + }), + expect.objectContaining({ + id: "another_gift_test", + code: "CARD_TEST", + }), + ]) + ) + }) + + it("lists gift cards with specific query", async () => { + const api = useApi() + + const response = await api + .get("/admin/gift-cards?q=gc", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.gift_cards.length).toEqual(1) + expect(response.data.gift_cards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "gift_test", + code: "GC_TEST", + }), + ]) + ) + }) + + it("lists no gift cards on query for non-existing gift card code", async () => { + const api = useApi() + + const response = await api + .get("/admin/gift-cards?q=bla", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.gift_cards.length).toEqual(0) + expect(response.data.gift_cards).toEqual([]) + }) + }) describe("POST /admin/gift-cards", () => { beforeEach(async () => { - const manager = dbConnection.manager; + const manager = dbConnection.manager try { - await adminSeeder(dbConnection); + await adminSeeder(dbConnection) await manager.insert(Region, { id: "region", name: "Test Region", currency_code: "usd", tax_rate: 0, - }); + }) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a gift card", async () => { - const api = useApi(); + const api = useApi() const response = await api .post( @@ -64,13 +172,13 @@ describe("/admin/gift-cards", () => { } ) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); - expect(response.data.gift_card.value).toEqual(1000); - expect(response.data.gift_card.balance).toEqual(1000); - expect(response.data.gift_card.region_id).toEqual("region"); - }); - }); -}); + expect(response.status).toEqual(200) + expect(response.data.gift_card.value).toEqual(1000) + expect(response.data.gift_card.balance).toEqual(1000) + expect(response.data.gift_card.region_id).toEqual("region") + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/order.js b/integration-tests/api/__tests__/admin/order.js index 863b151551..251578cca0 100644 --- a/integration-tests/api/__tests__/admin/order.js +++ b/integration-tests/api/__tests__/admin/order.js @@ -1,64 +1,64 @@ -const path = require("path"); +const path = require("path") const { ReturnReason, Order, LineItem, ProductVariant, -} = require("@medusajs/medusa"); +} = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const orderSeeder = require("../../helpers/order-seeder"); -const swapSeeder = require("../../helpers/swap-seeder"); -const adminSeeder = require("../../helpers/admin-seeder"); -const claimSeeder = require("../../helpers/claim-seeder"); +const orderSeeder = require("../../helpers/order-seeder") +const swapSeeder = require("../../helpers/swap-seeder") +const adminSeeder = require("../../helpers/admin-seeder") +const claimSeeder = require("../../helpers/claim-seeder") const { expectPostCallToReturn, expectAllPostCallsToReturn, callGet, partial, -} = require("../../helpers/call-helpers"); +} = require("../../helpers/call-helpers") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/orders", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); + const db = useDb() + await db.shutdown() - medusaProcess.kill(); - }); + medusaProcess.kill() + }) describe("GET /admin/orders", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("gets orders", async () => { - const api = useApi(); + const api = useApi() const response = await api .get("/admin/orders", { @@ -67,25 +67,25 @@ describe("/admin/orders", () => { }, }) .catch((err) => { - console.log(err); - }); - expect(response.status).toEqual(200); - }); - }); + console.log(err) + }) + expect(response.status).toEqual(200) + }) + }) describe("GET /admin/orders", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); - await swapSeeder(dbConnection); - await claimSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) + await swapSeeder(dbConnection) + await claimSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - const manager = dbConnection.manager; + const manager = dbConnection.manager const order2 = manager.create(Order, { id: "test-order-not-payed", @@ -136,9 +136,9 @@ describe("/admin/orders", () => { }, ], items: [], - }); + }) - await manager.save(order2); + await manager.save(order2) const li2 = manager.create(LineItem, { id: "test-item", @@ -151,23 +151,23 @@ describe("/admin/orders", () => { quantity: 1, variant_id: "test-variant", order_id: "test-order-not-payed", - }); + }) - await manager.save(li2); - }); + await manager.save(li2) + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("cancels an order and increments inventory_quantity", async () => { - const api = useApi(); - const manager = dbConnection.manager; + const api = useApi() + const manager = dbConnection.manager - const initialInventoryRes = await api.get("/store/variants/test-variant"); + const initialInventoryRes = await api.get("/store/variants/test-variant") - expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1); + expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1) const response = await api .post( @@ -180,26 +180,26 @@ describe("/admin/orders", () => { } ) .catch((err) => { - console.log(err); - }); - expect(response.status).toEqual(200); + console.log(err) + }) + expect(response.status).toEqual(200) - const secondInventoryRes = await api.get("/store/variants/test-variant"); + const secondInventoryRes = await api.get("/store/variants/test-variant") - expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(2); - }); + expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(2) + }) it("cancels an order but does not increment inventory_quantity of unmanaged variant", async () => { - const api = useApi(); - const manager = dbConnection.manager; + const api = useApi() + const manager = dbConnection.manager await manager.query( `UPDATE "product_variant" SET manage_inventory=false WHERE id = 'test-variant'` - ); + ) - const initialInventoryRes = await api.get("/store/variants/test-variant"); + const initialInventoryRes = await api.get("/store/variants/test-variant") - expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1); + expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1) const response = await api .post( @@ -212,35 +212,35 @@ describe("/admin/orders", () => { } ) .catch((err) => { - console.log(err); - }); - expect(response.status).toEqual(200); + console.log(err) + }) + expect(response.status).toEqual(200) - const secondInventoryRes = await api.get("/store/variants/test-variant"); + const secondInventoryRes = await api.get("/store/variants/test-variant") - expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(1); - }); - }); + expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(1) + }) + }) describe("POST /admin/orders/:id/claims", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); - await claimSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) + await claimSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a claim", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -267,30 +267,30 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) const variant = await api.get("/admin/products", { headers: { authorization: "Bearer test_token", }, - }); + }) // find test variant and verify that its inventory quantity has changed const toTest = variant.data.products[0].variants.find( (v) => v.id === "test-variant" - ); - expect(toTest.inventory_quantity).toEqual(0); + ) + expect(toTest.inventory_quantity).toEqual(0) expect(response.data.order.claims[0].shipping_address_id).toEqual( "test-shipping-address" - ); + ) expect(response.data.order.claims[0].shipping_address).toEqual( expect.objectContaining({ first_name: "lebron", country_code: "us", }) - ); + ) expect(response.data.order.claims[0].claim_items).toEqual( expect.arrayContaining([ @@ -305,7 +305,7 @@ describe("/admin/orders", () => { ]), }), ]) - ); + ) expect(response.data.order.claims[0].additional_items).toEqual( expect.arrayContaining([ @@ -314,11 +314,11 @@ describe("/admin/orders", () => { quantity: 1, }), ]) - ); - }); + ) + }) it("creates a claim with a shipping address", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -353,8 +353,8 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) expect(response.data.order.claims[0].shipping_address).toEqual( expect.objectContaining({ @@ -365,7 +365,7 @@ describe("/admin/orders", () => { postal_code: "12345", country_code: "us", }) - ); + ) expect(response.data.order.claims[0].claim_items).toEqual( expect.arrayContaining([ @@ -380,7 +380,7 @@ describe("/admin/orders", () => { ]), }), ]) - ); + ) expect(response.data.order.claims[0].additional_items).toEqual( expect.arrayContaining([ @@ -389,11 +389,11 @@ describe("/admin/orders", () => { quantity: 1, }), ]) - ); - }); + ) + }) it("creates a claim with return shipping", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -421,9 +421,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.order.claims[0].claim_items).toEqual( expect.arrayContaining([ @@ -438,7 +438,7 @@ describe("/admin/orders", () => { ]), }), ]) - ); + ) expect(response.data.order.claims[0].additional_items).toEqual( expect.arrayContaining([ @@ -447,7 +447,7 @@ describe("/admin/orders", () => { quantity: 1, }), ]) - ); + ) expect( response.data.order.claims[0].return_order.shipping_method @@ -456,11 +456,11 @@ describe("/admin/orders", () => { price: 0, shipping_option_id: "test-return-option", }) - ); - }); + ) + }) it("updates a claim", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -487,10 +487,10 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) - const cid = response.data.order.claims[0].id; + const cid = response.data.order.claims[0].id const { status, data: updateData } = await api.post( `/admin/orders/test-order/claims/${cid}`, { @@ -505,18 +505,18 @@ describe("/admin/orders", () => { authorization: "bearer test_token", }, } - ); + ) - expect(status).toEqual(200); + expect(status).toEqual(200) expect(updateData.order.claims[0].shipping_methods).toEqual([ expect.objectContaining({ id: "test-method", }), - ]); - }); + ]) + }) it("updates claim items", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -543,11 +543,11 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) - let claim = response.data.order.claims[0]; - const cid = claim.id; + let claim = response.data.order.claims[0] + const cid = claim.id const { status, data: updateData } = await api.post( `/admin/orders/test-order/claims/${cid}`, { @@ -570,14 +570,14 @@ describe("/admin/orders", () => { authorization: "bearer test_token", }, } - ); + ) - expect(status).toEqual(200); - expect(updateData.order.claims.length).toEqual(1); + expect(status).toEqual(200) + expect(updateData.order.claims.length).toEqual(1) - claim = updateData.order.claims[0]; + claim = updateData.order.claims[0] - expect(claim.claim_items.length).toEqual(1); + expect(claim.claim_items.length).toEqual(1) expect(claim.claim_items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -599,11 +599,11 @@ describe("/admin/orders", () => { // ]), }), ]) - ); - }); + ) + }) it("updates claim items - removes image", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/claims", @@ -630,11 +630,11 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) - let claim = response.data.order.claims[0]; - const cid = claim.id; + let claim = response.data.order.claims[0] + const cid = claim.id const { status, data: updateData } = await api.post( `/admin/orders/test-order/claims/${cid}`, { @@ -654,14 +654,14 @@ describe("/admin/orders", () => { authorization: "bearer test_token", }, } - ); + ) - expect(status).toEqual(200); - expect(updateData.order.claims.length).toEqual(1); + expect(status).toEqual(200) + expect(updateData.order.claims.length).toEqual(1) - claim = updateData.order.claims[0]; + claim = updateData.order.claims[0] - expect(claim.claim_items.length).toEqual(1); + expect(claim.claim_items.length).toEqual(1) expect(claim.claim_items).toEqual([ expect.objectContaining({ id: claim.claim_items[0].id, @@ -674,11 +674,11 @@ describe("/admin/orders", () => { // expect.objectContaining({ value: "tags" }), // ]), }), - ]); - }); + ]) + }) it("fulfills a claim", async () => { - const api = useApi(); + const api = useApi() const response = await api .post( @@ -713,10 +713,10 @@ describe("/admin/orders", () => { } ) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - const cid = response.data.order.claims[0].id; + const cid = response.data.order.claims[0].id const fulRes = await api.post( `/admin/orders/test-order/claims/${cid}/fulfillments`, {}, @@ -725,18 +725,18 @@ describe("/admin/orders", () => { Authorization: "Bearer test_token", }, } - ); - expect(fulRes.status).toEqual(200); + ) + expect(fulRes.status).toEqual(200) expect(fulRes.data.order.claims).toEqual([ expect.objectContaining({ id: cid, order_id: "test-order", fulfillment_status: "fulfilled", }), - ]); + ]) - const fid = fulRes.data.order.claims[0].fulfillments[0].id; - const iid = fulRes.data.order.claims[0].additional_items[0].id; + const fid = fulRes.data.order.claims[0].fulfillments[0].id + const iid = fulRes.data.order.claims[0].additional_items[0].id expect(fulRes.data.order.claims[0].fulfillments).toEqual([ expect.objectContaining({ items: [ @@ -747,63 +747,63 @@ describe("/admin/orders", () => { }, ], }), - ]); - }); + ]) + }) it("Only allow canceling claim after canceling fulfillments", async () => { - const order_id = "order-with-claim"; + const order_id = "order-with-claim" const order = await callGet({ path: `/admin/orders/${order_id}`, get: "order", - }); + }) - const claim = order.claims.filter((s) => s.id === "claim-w-f")[0]; - const claim_id = claim.id; + const claim = order.claims.filter((s) => s.id === "claim-w-f")[0] + const claim_id = claim.id const expectCancelToReturn = partial(expectPostCallToReturn, { path: `/admin/orders/${order_id}/claims/${claim_id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 400 }); + await expectCancelToReturn({ code: 400 }) await expectAllPostCallsToReturn({ code: 200, col: claim.fulfillments, pathf: (f) => `/admin/orders/${order_id}/claims/${claim_id}/fulfillments/${f.id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 200 }); - }); + await expectCancelToReturn({ code: 200 }) + }) it("Only allow canceling claim after canceling returns", async () => { - const order_id = "order-with-claim"; + const order_id = "order-with-claim" const order = await callGet({ path: `/admin/orders/${order_id}`, get: "order", - }); + }) - const claim = order.claims.filter((c) => c.id === "claim-w-r")[0]; - const claim_id = claim.id; + const claim = order.claims.filter((c) => c.id === "claim-w-r")[0] + const claim_id = claim.id const expectCancelToReturn = partial(expectPostCallToReturn, { path: `/admin/orders/${order_id}/claims/${claim_id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 400 }); + await expectCancelToReturn({ code: 400 }) await expectPostCallToReturn({ code: 200, path: `/admin/returns/${claim.return_order.id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 200 }); - }); + await expectCancelToReturn({ code: 200 }) + }) it("fails to creates a claim due to no stock on additional items", async () => { - const api = useApi(); + const api = useApi() try { await api.post( "/admin/orders/test-order/claims", @@ -830,43 +830,43 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) } catch (e) { - expect(e.response.status).toEqual(400); + expect(e.response.status).toEqual(400) expect(e.response.data.message).toEqual( "Variant with id: test-variant does not have the required inventory" - ); + ) } - }); - }); + }) + }) describe("POST /admin/orders/:id/return", () => { - let rrId; + let rrId beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) const created = dbConnection.manager.create(ReturnReason, { value: "too_big", label: "Too Big", - }); - const result = await dbConnection.manager.save(created); + }) + const result = await dbConnection.manager.save(created) - rrId = result.id; + rrId = result.id } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a return", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/return", @@ -885,10 +885,10 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); + ) + expect(response.status).toEqual(200) - expect(response.data.order.returns[0].refund_amount).toEqual(7200); + expect(response.data.order.returns[0].refund_amount).toEqual(7200) expect(response.data.order.returns[0].items).toEqual([ expect.objectContaining({ item_id: "test-item", @@ -896,11 +896,11 @@ describe("/admin/orders", () => { reason_id: rrId, note: "TOO SMALL", }), - ]); - }); + ]) + }) it("increases inventory_quantity when return is received", async () => { - const api = useApi(); + const api = useApi() const returned = await api.post( "/admin/orders/test-order/return", @@ -918,24 +918,22 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) //Find variant that should have its inventory_quantity updated - const toTest = returned.data.order.items.find( - (i) => i.id === "test-item" - ); + const toTest = returned.data.order.items.find((i) => i.id === "test-item") - expect(returned.status).toEqual(200); - expect(toTest.variant.inventory_quantity).toEqual(2); - }); + expect(returned.status).toEqual(200) + expect(toTest.variant.inventory_quantity).toEqual(2) + }) it("does not increases inventory_quantity when return is received when inventory is not managed", async () => { - const api = useApi(); - const manager = dbConnection.manager; + const api = useApi() + const manager = dbConnection.manager await manager.query( `UPDATE "product_variant" SET manage_inventory=false WHERE id = 'test-variant'` - ); + ) const returned = await api.post( "/admin/orders/test-order/return", @@ -953,48 +951,46 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) //Find variant that should have its inventory_quantity updated - const toTest = returned.data.order.items.find( - (i) => i.id === "test-item" - ); + const toTest = returned.data.order.items.find((i) => i.id === "test-item") - expect(returned.status).toEqual(200); - expect(toTest.variant.inventory_quantity).toEqual(1); - }); - }); + expect(returned.status).toEqual(200) + expect(toTest.variant.inventory_quantity).toEqual(1) + }) + }) describe("GET /admin/orders", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); + await adminSeeder(dbConnection) // Manually insert date for filtering - const createdAt = new Date("26 January 1997 12:00 UTC"); + const createdAt = new Date("26 January 1997 12:00 UTC") await orderSeeder(dbConnection, { created_at: createdAt.toISOString(), - }); + }) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("lists all orders", async () => { - const api = useApi(); + const api = useApi() const response = await api.get("/admin/orders?fields=id", { headers: { authorization: "Bearer test_token", }, - }); + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.orders).toEqual([ expect.objectContaining({ id: "test-order", @@ -1013,11 +1009,52 @@ describe("/admin/orders", () => { expect.objectContaining({ id: "test-order-w-r", }), - ]); - }); + ]) + }) + + it("list all orders with matching order email", async () => { + const api = useApi() + + const response = await api.get( + "/admin/orders?fields=id,email&q=test@email", + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.orders).toEqual([ + expect.objectContaining({ + id: "test-order", + email: "test@email.com", + }), + ]) + }) + + it("list all orders with matching shipping_address first name", async () => { + const api = useApi() + + const response = await api.get("/admin/orders?q=lebron", { + headers: { + authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.orders).toEqual([ + expect.objectContaining({ + id: "test-order", + shipping_address: expect.objectContaining({ first_name: "lebron" }), + }), + ]) + }) it("successfully lists orders with greater than", async () => { - const api = useApi(); + const api = useApi() const response = await api.get( "/admin/orders?fields=id&created_at[gt]=01-26-1990", @@ -1026,9 +1063,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.orders).toEqual([ expect.objectContaining({ id: "test-order", @@ -1046,11 +1083,11 @@ describe("/admin/orders", () => { expect.objectContaining({ id: "test-order-w-r", }), - ]); - }); + ]) + }) it("successfully lists no orders with greater than", async () => { - const api = useApi(); + const api = useApi() const response = await api.get( "/admin/orders?fields=id&created_at[gt]=01-26-2000", @@ -1059,14 +1096,14 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); - expect(response.data.orders).toEqual([]); - }); + expect(response.status).toEqual(200) + expect(response.data.orders).toEqual([]) + }) it("successfully lists orders with less than", async () => { - const api = useApi(); + const api = useApi() const response = await api.get( "/admin/orders?fields=id&created_at[lt]=01-26-2000", @@ -1075,9 +1112,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.orders).toEqual([ expect.objectContaining({ id: "test-order", @@ -1095,11 +1132,11 @@ describe("/admin/orders", () => { expect.objectContaining({ id: "test-order-w-r", }), - ]); - }); + ]) + }) it("successfully lists no orders with less than", async () => { - const api = useApi(); + const api = useApi() const response = await api.get( "/admin/orders?fields=id&created_at[lt]=01-26-1990", @@ -1108,14 +1145,14 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); - expect(response.data.orders).toEqual([]); - }); + expect(response.status).toEqual(200) + expect(response.data.orders).toEqual([]) + }) it("successfully lists orders using unix (greater than)", async () => { - const api = useApi(); + const api = useApi() const response = await api.get( "/admin/orders?fields=id&created_at[gt]=633351600", @@ -1124,9 +1161,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.orders).toEqual([ expect.objectContaining({ id: "test-order", @@ -1144,8 +1181,8 @@ describe("/admin/orders", () => { expect.objectContaining({ id: "test-order-w-r", }), - ]); - }); + ]) + }) it.each([ [ @@ -1175,49 +1212,49 @@ describe("/admin/orders", () => { ])( "Only allows canceling order after canceling %s", async (id, o, of, pf) => { - const order_id = o; + const order_id = o const order = await callGet({ path: `/admin/orders/${order_id}`, get: "order", - }); + }) const expectCanceltoReturn = partial(expectPostCallToReturn, { path: `/admin/orders/${order_id}/cancel`, - }); + }) - await expectCanceltoReturn({ code: 400 }); + await expectCanceltoReturn({ code: 400 }) await expectAllPostCallsToReturn({ code: 200, col: of(order), pathf: pf, - }); + }) - await expectCanceltoReturn({ code: 200 }); + await expectCanceltoReturn({ code: 200 }) } - ); - }); + ) + }) describe("POST /admin/orders/:id/swaps", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); - await orderSeeder(dbConnection); - await swapSeeder(dbConnection); + await adminSeeder(dbConnection) + await orderSeeder(dbConnection) + await swapSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a swap", async () => { - const api = useApi(); + const api = useApi() const response = await api.post( "/admin/orders/test-order/swaps", @@ -1235,12 +1272,12 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); - expect(response.status).toEqual(200); - }); + ) + expect(response.status).toEqual(200) + }) it("creates a swap and a return", async () => { - const api = useApi(); + const api = useApi() const returnedOrderFirst = await api.post( "/admin/orders/order-with-swap/return", @@ -1258,9 +1295,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(returnedOrderFirst.status).toEqual(200); + expect(returnedOrderFirst.status).toEqual(200) const returnedOrderSecond = await api.post( "/admin/orders/order-with-swap/return", @@ -1278,19 +1315,19 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) // find item to test returned quantiy for const toTest = returnedOrderSecond.data.order.items.find( (i) => i.id === "test-item-many" - ); + ) - expect(returnedOrderSecond.status).toEqual(200); - expect(toTest.returned_quantity).toBe(3); - }); + expect(returnedOrderSecond.status).toEqual(200) + expect(toTest.returned_quantity).toBe(3) + }) it("creates a swap and receives the items", async () => { - const api = useApi(); + const api = useApi() const createdSwapOrder = await api.post( "/admin/orders/test-order/swaps", @@ -1308,11 +1345,11 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(createdSwapOrder.status).toEqual(200); + expect(createdSwapOrder.status).toEqual(200) - const swap = createdSwapOrder.data.order.swaps[0]; + const swap = createdSwapOrder.data.order.swaps[0] const receivedSwap = await api.post( `/admin/returns/${swap.return_order.id}/receive`, @@ -1329,14 +1366,14 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(receivedSwap.status).toEqual(200); - expect(receivedSwap.data.return.status).toBe("received"); - }); + expect(receivedSwap.status).toEqual(200) + expect(receivedSwap.data.return.status).toBe("received") + }) it("creates a swap on a swap", async () => { - const api = useApi(); + const api = useApi() const swapOnSwap = await api.post( "/admin/orders/order-with-swap/swaps", @@ -1354,13 +1391,13 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(swapOnSwap.status).toEqual(200); - }); + expect(swapOnSwap.status).toEqual(200) + }) it("receives a swap on swap", async () => { - const api = useApi(); + const api = useApi() const received = await api.post( `/admin/returns/return-on-swap/receive`, @@ -1377,13 +1414,13 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(received.status).toEqual(200); - }); + expect(received.status).toEqual(200) + }) it("creates a return on a swap", async () => { - const api = useApi(); + const api = useApi() const returnOnSwap = await api.post( "/admin/orders/order-with-swap/return", @@ -1400,13 +1437,13 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(returnOnSwap.status).toEqual(200); - }); + expect(returnOnSwap.status).toEqual(200) + }) it("creates a return on an order", async () => { - const api = useApi(); + const api = useApi() const returnOnOrder = await api.post( "/admin/orders/test-order/return", @@ -1423,9 +1460,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(returnOnOrder.status).toEqual(200); + expect(returnOnOrder.status).toEqual(200) const captured = await api.post( "/admin/orders/test-order/capture", @@ -1435,9 +1472,9 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - const returnId = returnOnOrder.data.order.returns[0].id; + const returnId = returnOnOrder.data.order.returns[0].id const received = await api.post( `/admin/returns/${returnId}/receive`, @@ -1454,63 +1491,63 @@ describe("/admin/orders", () => { authorization: "Bearer test_token", }, } - ); + ) - expect(received.status).toEqual(200); - }); + expect(received.status).toEqual(200) + }) it("Only allows canceling swap after canceling fulfillments", async () => { try { - const swap_id = "swap-w-f"; + const swap_id = "swap-w-f" const swap = await callGet({ path: `/admin/swaps/${swap_id}`, get: "swap", - }); + }) - const { order_id } = swap; + const { order_id } = swap const expectCancelToReturn = partial(expectPostCallToReturn, { path: `/admin/orders/${order_id}/swaps/${swap_id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 400 }); + await expectCancelToReturn({ code: 400 }) await expectAllPostCallsToReturn({ code: 200, col: swap.fulfillments, pathf: (f) => `/admin/orders/${order_id}/swaps/${swap_id}/fulfillments/${f.id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 200 }); + await expectCancelToReturn({ code: 200 }) } catch (e) { - console.log(e); + console.log(e) } - }); + }) it("Only allows canceling swap after canceling return", async () => { - const swap_id = "swap-w-r"; + const swap_id = "swap-w-r" const swap = await callGet({ path: `/admin/swaps/${swap_id}`, get: "swap", - }); + }) - const { order_id } = swap; + const { order_id } = swap const expectCancelToReturn = partial(expectPostCallToReturn, { path: `/admin/orders/${order_id}/swaps/${swap_id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 400 }); + await expectCancelToReturn({ code: 400 }) await expectPostCallToReturn({ code: 200, path: `/admin/returns/${swap.return_order.id}/cancel`, - }); + }) - await expectCancelToReturn({ code: 200 }); - }); - }); -}); + await expectCancelToReturn({ code: 200 }) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/variant.js b/integration-tests/api/__tests__/admin/variant.js new file mode 100644 index 0000000000..a0244141c1 --- /dev/null +++ b/integration-tests/api/__tests__/admin/variant.js @@ -0,0 +1,153 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") + +const adminSeeder = require("../../helpers/admin-seeder") +const productSeeder = require("../../helpers/product-seeder") + +jest.setTimeout(30000) + +describe("/admin/products", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/product-variants", () => { + beforeEach(async () => { + try { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("lists all product variants", async () => { + const api = useApi() + + const response = await api + .get("/admin/variants/", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining( + { + id: "test-variant", + }, + { + id: "test-variant_2", + }, + { + id: "test-variant_1", + } + ), + ]) + ) + }) + + it("lists all product variants matching a specific sku", async () => { + const api = useApi() + const response = await api + .get("/admin/variants?q=sku2", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.variants.length).toEqual(1) + expect(response.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "test-sku2", + }), + ]) + ) + }) + + it("lists all product variants matching a specific variant title", async () => { + const api = useApi() + const response = await api + .get("/admin/variants?q=rank (1)", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.variants.length).toEqual(1) + expect(response.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-variant_1", + sku: "test-sku1", + }), + ]) + ) + }) + + it("lists all product variants matching a specific product title", async () => { + const api = useApi() + const response = await api + .get("/admin/variants?q=Test product1", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.variants.length).toEqual(2) + expect(response.data.variants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + product_id: "test-product1", + id: "test-variant_3", + sku: "test-sku3", + }), + expect.objectContaining({ + product_id: "test-product1", + id: "test-variant_4", + sku: "test-sku4", + }), + ]) + ) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js index ff47e6e0d5..8b740fb1a8 100644 --- a/packages/medusa/src/services/__tests__/discount.js +++ b/packages/medusa/src/services/__tests__/discount.js @@ -494,9 +494,6 @@ describe("DiscountService", () => { expect(discountRepository.findAndCount).toHaveBeenCalledTimes(1) expect(discountRepository.findAndCount).toHaveBeenCalledWith({ - join: { - alias: "discount", - }, where: expect.anything(), skip: 0, take: 50, diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index 88ade052b8..850ddb27f7 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -3,7 +3,7 @@ import Scrypt from "scrypt-kdf" import _ from "lodash" import { Validator, MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" -import { Brackets } from "typeorm" +import { Brackets, ILike } from "typeorm" /** * Provides layer to manipulate customers. @@ -149,9 +149,9 @@ class CustomerService extends BaseService { qb.andWhere( new Brackets(qb => { - qb.where(`email ILIKE :q`, { q: `%${q}%` }) - .orWhere(`first_name ILIKE :q`, { q: `%${q}%` }) - .orWhere(`last_name ILIKE :q`, { q: `%${q}%` }) + qb.where({ email: ILike(`%${q}%`) }) + .orWhere({ first_name: ILike(`%${q}%`) }) + .orWhere({ last_name: ILike(`%${q}%`) }) }) ) } @@ -183,18 +183,14 @@ class CustomerService extends BaseService { delete where.first_name delete where.last_name - query.join = { - alias: "customer", - } - query.where = qb => { qb.where(where) qb.andWhere( new Brackets(qb => { - qb.where(`customer.first_name ILIKE :q`, { q: `%${q}%` }) - .orWhere(`customer.last_name ILIKE :q`, { q: `%${q}%` }) - .orWhere(`customer.email ILIKE :q`, { q: `%${q}%` }) + qb.where({ email: ILike(`%${q}%`) }) + .orWhere({ first_name: ILike(`%${q}%`) }) + .orWhere({ last_name: ILike(`%${q}%`) }) }) ) } diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js index 6451eb9c72..a4fabdac65 100644 --- a/packages/medusa/src/services/discount.js +++ b/packages/medusa/src/services/discount.js @@ -2,7 +2,7 @@ import _ from "lodash" import randomize from "randomatic" import { BaseService } from "medusa-interfaces" import { Validator, MedusaError } from "medusa-core-utils" -import { Brackets } from "typeorm" +import { Brackets, ILike } from "typeorm" /** * Provides layer to manipulate discounts. @@ -151,16 +151,12 @@ class DiscountService extends BaseService { delete where.code - query.join = { - alias: "discount", - } - query.where = qb => { qb.where(where) qb.andWhere( new Brackets(qb => { - qb.where(`discount.code ILIKE :q`, { q: `%${q}%` }) + qb.where({ code: ILike(`%${q}%`) }) }) ) } diff --git a/packages/medusa/src/services/draft-order.js b/packages/medusa/src/services/draft-order.js index d16044b43b..fe2ceb51aa 100644 --- a/packages/medusa/src/services/draft-order.js +++ b/packages/medusa/src/services/draft-order.js @@ -1,7 +1,7 @@ import _ from "lodash" import { BaseService } from "medusa-interfaces" import { MedusaError } from "medusa-core-utils" -import { Brackets } from "typeorm" +import { Brackets, ILike } from "typeorm" /** * Handles draft orders diff --git a/packages/medusa/src/services/gift-card.js b/packages/medusa/src/services/gift-card.js index 173a3d1aee..3762ce36f5 100644 --- a/packages/medusa/src/services/gift-card.js +++ b/packages/medusa/src/services/gift-card.js @@ -1,8 +1,7 @@ -import _ from "lodash" -import randomize from "randomatic" -import { BaseService } from "medusa-interfaces" -import { Brackets } from "typeorm" import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import randomize from "randomatic" +import { Brackets } from "typeorm" /** * Provides layer to manipulate gift cards. diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 86d43f4d78..64684ac6d6 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -1,5 +1,4 @@ -import _ from "lodash" -import { Validator, MedusaError } from "medusa-core-utils" +import { MedusaError, Validator } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { Brackets } from "typeorm" @@ -237,7 +236,9 @@ class OrderService extends BaseService { qb.andWhere( new Brackets(qb => { - qb.where(`shipping_address.first_name ILIKE :q`, { q: `%${q}%` }) + qb.where(`shipping_address.first_name ILIKE :qfn`, { + qfn: `%${q}%`, + }) .orWhere(`order.email ILIKE :q`, { q: `%${q}%` }) .orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` }) }) diff --git a/packages/medusa/src/services/product-variant.js b/packages/medusa/src/services/product-variant.js index 388d8fad24..055efbde95 100644 --- a/packages/medusa/src/services/product-variant.js +++ b/packages/medusa/src/services/product-variant.js @@ -1,6 +1,6 @@ import _ from "lodash" import { BaseService } from "medusa-interfaces" -import { Brackets, Raw, IsNull } from "typeorm" +import { Brackets, Raw, IsNull, ILike } from "typeorm" import { Validator, MedusaError } from "medusa-core-utils" /** @@ -558,14 +558,11 @@ class ProductVariantService extends BaseService { } query.where = qb => { - qb.where(where).andWhere( - new Brackets(qb => { - qb.where([ - { sku: Raw(a => `${a} ILIKE :q`, { q: `%${q}%` }) }, - { title: Raw(a => `${a} ILIKE :q`, { q: `%${q}%` }) }, - ]).orWhere(`product.title ILIKE :q`, { q: `%${q}%` }) - }) - ) + qb.where(where).andWhere([ + { sku: ILike(`%${q}%`) }, + { title: ILike(`%${q}%`) }, + { product: { title: ILike(`%${q}%`) } }, + ]) } } From a82332da3e2c8940da814b27607182c2c888b49f Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 21 Sep 2021 11:22:17 +0200 Subject: [PATCH 05/19] feat: add product status (#400) * added statuses to product + unit test for updating status * add update to product model * added integration tests * added integration test to validate that updating status to null results in invalid_data error * removed comment * update GET /store/products integration test * fixed unit test with IdMap * changed dbehaviour on invalid status input on admin list products * updated migration to add status = published on all existing products + added integration test on GET /admin/products when status null is provided * made requested changes to migration and GET /store/products * fixed test * made requested changes to migration --- .../admin/__snapshots__/product.js.snap | 2 + .../api/__tests__/admin/product.js | 91 ++++++++++++++++++- .../store/__snapshots__/products.js.snap | 1 + .../store/__snapshots__/swaps.js.snap | 2 + .../api/__tests__/store/products.js | 35 +++++++ .../products/__tests__/create-product.js | 2 + .../routes/admin/products/create-product.js | 3 + .../routes/admin/products/list-products.js | 15 +++ .../routes/admin/products/update-product.js | 6 ++ .../store/products/__tests__/list-products.js | 4 +- .../routes/store/products/list-products.js | 3 + .../store/variants/__tests__/get-variant.js | 5 +- .../1631864388026-status_on_product.ts | 28 ++++++ packages/medusa/src/models/product.ts | 10 ++ .../src/services/__mocks__/product-variant.js | 2 +- .../medusa/src/services/__tests__/product.js | 18 ++++ packages/medusa/yarn.lock | 64 +++++++------ 17 files changed, 255 insertions(+), 36 deletions(-) create mode 100644 packages/medusa/src/migrations/1631864388026-status_on_product.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap index 4cd2e9683e..8fc7342918 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap @@ -49,6 +49,7 @@ Array [ ], "origin_country": null, "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", "subtitle": null, "tags": Array [ Object { @@ -252,6 +253,7 @@ Array [ "options": Array [], "origin_country": null, "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", "subtitle": null, "tags": Array [ Object { diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 9c17d99cf1..1e398b65c5 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -42,6 +42,73 @@ describe("/admin/products", () => { await db.teardown() }) + it("returns a list of products with all statuses when no status or invalid status is provided", async () => { + const api = useApi() + + const res = await api + .get("/admin/products?status%5B%5D=null", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(res.status).toEqual(200) + expect(res.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-product", + status: "draft", + }), + expect.objectContaining({ + id: "test-product1", + status: "draft", + }), + ]) + ) + }) + + it("returns a list of products where status is proposed", async () => { + const api = useApi() + + const payload = { + status: "proposed", + } + + //update test-product status to proposed + await api + .post("/admin/products/test-product", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const response = await api + .get("/admin/products?status%5B%5D=proposed", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-product", + status: "proposed", + }), + ]) + ) + }) + it("returns a list of products with child entities", async () => { const api = useApi() @@ -297,6 +364,7 @@ describe("/admin/products", () => { discountable: true, is_giftcard: false, handle: "test", + status: "draft", images: expect.arrayContaining([ expect.objectContaining({ url: "test-image.png", @@ -455,7 +523,7 @@ describe("/admin/products", () => { ) }) - it("updates a product (update prices, tags, delete collection, delete type, replaces images)", async () => { + it("updates a product (update prices, tags, update status, delete collection, delete type, replaces images)", async () => { const api = useApi() const payload = { @@ -476,6 +544,7 @@ describe("/admin/products", () => { tags: [{ value: "123" }], images: ["test-image-2.png"], type: { value: "test-type-2" }, + status: "published", } const response = await api @@ -514,6 +583,7 @@ describe("/admin/products", () => { }), ], type: null, + status: "published", collection: null, type: expect.objectContaining({ value: "test-type-2", @@ -522,6 +592,25 @@ describe("/admin/products", () => { ) }) + it("fails to update product with invalid status", async () => { + const api = useApi() + + const payload = { + status: null, + } + + try { + await api.post("/admin/products/test-product", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + } catch (e) { + expect(e.response.status).toEqual(400) + expect(e.response.data.type).toEqual("invalid_data") + } + }) + it("updates a product (variant ordering)", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap index 436c7e60cc..781d540f96 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap @@ -101,6 +101,7 @@ Object { ], "origin_country": null, "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", "subtitle": null, "tags": Array [ Object { diff --git a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap index 5f3522721f..fee312ff37 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap @@ -58,6 +58,7 @@ Object { "mid_code": null, "origin_country": null, "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", "subtitle": null, "thumbnail": null, "title": "test product", @@ -228,6 +229,7 @@ Object { "mid_code": null, "origin_country": null, "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", "subtitle": null, "thumbnail": null, "title": "test product", diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index b3225c289c..22f35079d4 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -5,6 +5,7 @@ const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") const productSeeder = require("../../helpers/product-seeder") +const adminSeeder = require("../../helpers/admin-seeder") jest.setTimeout(30000) describe("/store/products", () => { let medusaProcess @@ -26,6 +27,7 @@ describe("/store/products", () => { beforeEach(async () => { try { await productSeeder(dbConnection) + await adminSeeder(dbConnection) } catch (err) { console.log(err) throw err @@ -261,5 +263,38 @@ describe("/store/products", () => { expect(product.variants.some((variant) => variant.options)).toEqual(false) }) + + it("lists all published products", async () => { + const api = useApi() + + //update test-product status to published + await api + .post( + "/admin/products/test-product", + { + status: "published", + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + const response = await api.get("/store/products") + + expect(response.status).toEqual(200) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "test-product", + status: "published", + }), + ]) + ) + }) }) }) diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js index 25a9012395..f7d9722bd5 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js @@ -110,6 +110,7 @@ describe("POST /admin/products", () => { description: "Test Description", tags: [{ id: "test", value: "test" }], handle: "test-product", + status: "draft", is_giftcard: false, options: [{ title: "Denominations" }], profile_id: IdMap.getId("default_shipping_profile"), @@ -170,6 +171,7 @@ describe("POST /admin/products", () => { options: [{ title: "Denominations" }], handle: "test-gift-card", is_giftcard: true, + status: "draft", profile_id: IdMap.getId("giftCardProfile"), }) }) diff --git a/packages/medusa/src/api/routes/admin/products/create-product.js b/packages/medusa/src/api/routes/admin/products/create-product.js index 95c5f4b3b7..d02ac8fb7d 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/create-product.js @@ -193,6 +193,9 @@ export default async (req, res) => { .optional(), thumbnail: Validator.string().optional(), handle: Validator.string().optional(), + status: Validator.string() + .valid("proposed", "draft", "published", "rejected") + .default("draft"), type: Validator.object() .keys({ id: Validator.string().optional(), diff --git a/packages/medusa/src/api/routes/admin/products/list-products.js b/packages/medusa/src/api/routes/admin/products/list-products.js index 8c0ab5207d..bd4873a82d 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.js +++ b/packages/medusa/src/api/routes/admin/products/list-products.js @@ -1,4 +1,5 @@ import _ from "lodash" +import { MedusaError, Validator } from "medusa-core-utils" import { defaultFields, defaultRelations } from "./" /** @@ -56,6 +57,20 @@ export default async (req, res) => { selector.is_giftcard = req.query.is_giftcard === "true" } + if ("status" in req.query) { + const schema = Validator.array() + .items( + Validator.string().valid("proposed", "draft", "published", "rejected") + ) + .single() + + const { value, error } = schema.validate(req.query.status) + + if (value && !error) { + selector.status = value + } + } + const listConfig = { select: includeFields.length ? includeFields : defaultFields, relations: expandFields.length ? expandFields : defaultRelations, diff --git a/packages/medusa/src/api/routes/admin/products/update-product.js b/packages/medusa/src/api/routes/admin/products/update-product.js index 71a2fe2cc2..85b8f72559 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.js +++ b/packages/medusa/src/api/routes/admin/products/update-product.js @@ -193,6 +193,12 @@ export default async (req, res) => { .allow(null, ""), description: Validator.string().optional(), discountable: Validator.boolean().optional(), + status: Validator.string().valid( + "proposed", + "draft", + "published", + "rejected" + ), type: Validator.object() .keys({ id: Validator.string().optional(), diff --git a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js index 647e29d281..8e044bd193 100644 --- a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js +++ b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js @@ -18,7 +18,7 @@ describe("GET /store/products", () => { it("calls get product from productSerice", () => { expect(ProductServiceMock.list).toHaveBeenCalledTimes(1) expect(ProductServiceMock.list).toHaveBeenCalledWith( - {}, + { status: ["published"] }, { relations: defaultRelations, skip: 0, take: 100 } ) }) @@ -43,7 +43,7 @@ describe("GET /store/products", () => { it("calls list from productSerice", () => { expect(ProductServiceMock.list).toHaveBeenCalledTimes(1) expect(ProductServiceMock.list).toHaveBeenCalledWith( - { is_giftcard: true }, + { is_giftcard: true, status: ["published"] }, { relations: defaultRelations, skip: 0, take: 100 } ) }) diff --git a/packages/medusa/src/api/routes/store/products/list-products.js b/packages/medusa/src/api/routes/store/products/list-products.js index 44b024fe71..fadbce82de 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.js +++ b/packages/medusa/src/api/routes/store/products/list-products.js @@ -1,3 +1,4 @@ +import { MedusaError, Validator } from "medusa-core-utils" import { defaultRelations } from "." /** @@ -41,6 +42,8 @@ export default async (req, res) => { selector.is_giftcard = req.query.is_giftcard === "true" } + selector.status = ["published"] + const listConfig = { relations: defaultRelations, skip: offset, diff --git a/packages/medusa/src/api/routes/store/variants/__tests__/get-variant.js b/packages/medusa/src/api/routes/store/variants/__tests__/get-variant.js index cebdee0f60..890ab5e078 100644 --- a/packages/medusa/src/api/routes/store/variants/__tests__/get-variant.js +++ b/packages/medusa/src/api/routes/store/variants/__tests__/get-variant.js @@ -28,10 +28,7 @@ describe("Get variant by id", () => { describe("get variant with prices", () => { let subject beforeAll(async () => { - subject = await request( - "GET", - `/store/variants/${IdMap.getId("variantWithPrices")}` - ) + subject = await request("GET", `/store/variants/variant_with_prices`) }) it("successfully retrieves variants with prices", async () => { expect(subject.status).toEqual(200) diff --git a/packages/medusa/src/migrations/1631864388026-status_on_product.ts b/packages/medusa/src/migrations/1631864388026-status_on_product.ts new file mode 100644 index 0000000000..1a54b5eb77 --- /dev/null +++ b/packages/medusa/src/migrations/1631864388026-status_on_product.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class statusOnProduct1631864388026 implements MigrationInterface { + name = "statusOnProduct1631864388026" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "product_status_enum" AS ENUM('draft', 'proposed', 'published', 'rejected')` + ) + await queryRunner.query( + `ALTER TABLE "product" ADD "status" "product_status_enum" ` + ) + await queryRunner.query( + `UPDATE "product" SET "status" = 'published' WHERE "status" IS NULL` + ) + await queryRunner.query( + `ALTER TABLE "product" ALTER COLUMN "status" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "product" ALTER COLUMN "status" SET DEFAULT 'draft'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "status"`) + await queryRunner.query(`DROP TYPE "product_status_enum"`) + } +} diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index c1ff8d713f..062912b2a5 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -26,6 +26,13 @@ import { ProductVariant } from "./product-variant" import { ShippingProfile } from "./shipping-profile" import _ from "lodash" +export enum Status { + DRAFT = "draft", + PROPOSED = "proposed", + PUBLISHED = "published", + REJECTED = "rejected", +} + @Entity() export class Product { @PrimaryColumn() @@ -47,6 +54,9 @@ export class Product { @Column({ default: false }) is_giftcard: boolean + @DbAwareColumn({ type: "enum", enum: Status, default: "draft" }) + status: Status + @ManyToMany(() => Image, { cascade: ["insert"] }) @JoinTable({ name: "product_images", diff --git a/packages/medusa/src/services/__mocks__/product-variant.js b/packages/medusa/src/services/__mocks__/product-variant.js index 2b84bdbb34..7576cbae13 100644 --- a/packages/medusa/src/services/__mocks__/product-variant.js +++ b/packages/medusa/src/services/__mocks__/product-variant.js @@ -223,7 +223,7 @@ export const ProductVariantServiceMock = { if (variantId === "4") { return Promise.resolve(variant4) } - if (variantId === IdMap.getId("variantWithPrices")) { + if (variantId === "variant_with_prices") { return Promise.resolve(variantWithPrices) } if (variantId === IdMap.getId("validId")) { diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index a8fe0463d4..af1c0ae7cb 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -199,6 +199,12 @@ describe("ProductService", () => { variants: [{ id: IdMap.getId("green"), title: "Green" }], }) } + if (query.where.id === "prod_status") { + return Promise.resolve({ + id: "prod_status", + status: "draft", + }) + } if (query.where.id === "123") { return undefined } @@ -290,6 +296,18 @@ describe("ProductService", () => { expect(productRepository.save).toHaveBeenCalledTimes(1) }) + it("successfully updates product status", async () => { + await productService.update(IdMap.getId("ironman"), { + status: "published", + }) + + expect(productRepository.save).toHaveBeenCalledTimes(1) + expect(productRepository.save).toHaveBeenCalledWith({ + id: IdMap.getId("ironman"), + status: "published", + }) + }) + it("successfully updates product", async () => { await productService.update(IdMap.getId("ironman"), { title: "Full suit", diff --git a/packages/medusa/yarn.lock b/packages/medusa/yarn.lock index 35c3432b2e..df967251e9 100644 --- a/packages/medusa/yarn.lock +++ b/packages/medusa/yarn.lock @@ -1407,10 +1407,10 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@medusajs/medusa-cli@1.1.16-dev-1631019393655": - version "1.1.16-dev-1631019393655" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.16-dev-1631019393655.tgz#68fc30f7053df428cd7a4e5a443326371818a20d" - integrity sha512-5wUVfTJahuHBbY4U5iuwSKbE/Dn3S/fC8MWVpP5R/JFNK484zR3On1qBL0I9tXtsUd9CmB+jKI59w6D49wJ29A== +"@medusajs/medusa-cli@^1.1.17": + version "1.1.18" + resolved "https://registry.yarnpkg.com/@medusajs/medusa-cli/-/medusa-cli-1.1.18.tgz#a2b34575a81a7df239d6d06cf0d0b192e2b8c8db" + integrity sha512-JEvQVjebaGuOF5BsqjZYnewmU4TPbrnhODKVyadPKPb/cxPcCMODg21d5QyoaVlcXood08LgTFe8CfdWoyubVw== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1428,8 +1428,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.20-dev-1631019393655" - medusa-telemetry "0.0.3-dev-1631019393655" + medusa-core-utils "^0.1.27" + medusa-telemetry "^0.0.5" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -2046,10 +2046,10 @@ babel-preset-jest@^25.5.0: babel-plugin-jest-hoist "^25.5.0" babel-preset-current-node-syntax "^0.1.2" -babel-preset-medusa-package@1.1.13-dev-1631019393655: - version "1.1.13-dev-1631019393655" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.13-dev-1631019393655.tgz#95b20e00ae34b6b1d5a3be2dbd17c4bf45c1895e" - integrity sha512-cpaVSi2+M8LFlZjfcazmp1GHB+lEgDNZNlX8J9RdB5/LWZwWRrqFGVyv//8hXp7AKiFFTtayJmpgBAEFfLhdcA== +babel-preset-medusa-package@^1.1.14: + version "1.1.15" + resolved "https://registry.yarnpkg.com/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.15.tgz#6917cadd8abe9a1f64c71b5c43ab507df193effc" + integrity sha512-toA8mFdvLeKbbRJ7KvQvpL6VJnzkKURZv7Yd97cXMMNpdjrhp+SZppcNOL2tk6ywgBAs4NC2LCVjtZInMMBS6Q== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5454,25 +5454,33 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.20-dev-1631019393655: - version "1.1.20-dev-1631019393655" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.20-dev-1631019393655.tgz#edd5e25518677799647eef32ccc8ddb4f244026e" - integrity sha512-Ko+gjpe4pHwQMI6Gv4MxMlzWbQbCL9bnN8MX5eHwLJoGcPzt57y5/RWujJo99nUuNthUDiJ14EFXQHVQu45bmQ== +medusa-core-utils@^0.1.27: + version "0.1.39" + resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-0.1.39.tgz#d57816c9bd43f9a92883650c1e66add1665291df" + integrity sha512-R8+U1ile7if+nR6Cjh5exunx0ETV0OfkWUUBUpz1KmHSDv0V0CcvQqU9lcZesPFDEbu3Y2iEjsCqidVA4nG2nQ== + dependencies: + "@hapi/joi" "^16.1.8" + joi-objectid "^3.0.1" + +medusa-core-utils@^1.1.21, medusa-core-utils@^1.1.22: + version "1.1.22" + resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.22.tgz#84ce0af0a7c672191d758ea462056e30a39d08b1" + integrity sha512-kMuRkWOuNG4Bw6epg/AYu95UJuE+rjHTeTWRLbEPrYGjWREV82tLWVDI21/QcccmaHmMU98Rkw2z9JwyFZIiyw== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.1.21-dev-1631019393655: - version "1.1.21-dev-1631019393655" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.21-dev-1631019393655.tgz#c2a72b6751a802438ed0f9010ed0cfecff1d1a2d" - integrity sha512-I8BEdvmVKorakTU5myw8IyTLp9OcO40OkNdZmZj29Q4oRU4scwP3i+5bXd6xW0ML5t5hbMUT9TKU9n7oZNIvug== +medusa-interfaces@^1.1.22: + version "1.1.23" + resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.23.tgz#b552a8c1d0eaddeff30472ab238652b9e1a56e73" + integrity sha512-dHCOnsyYQvjrtRd3p0ZqQZ4M/zmo4M/BAgVfRrYSyGrMdQ86TK9Z1DQDCHEzM1216AxEfXz2JYUD7ilTfG2iHQ== dependencies: - medusa-core-utils "1.1.20-dev-1631019393655" + medusa-core-utils "^1.1.22" -medusa-telemetry@0.0.3-dev-1631019393655: - version "0.0.3-dev-1631019393655" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.3-dev-1631019393655.tgz#dc6abef74c631520d4d8c81a4175ac795d72fbda" - integrity sha512-I8a8iTTmL0u5/SYnTw02xn3JiFa2PDclRyDL5pAl8gFgCzVCWD+fm+ik51kMMhbNhK7GWobs4h5xxA4W6eCcAQ== +medusa-telemetry@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/medusa-telemetry/-/medusa-telemetry-0.0.5.tgz#d7d08fca5cbecc0e853b4e0406194a92c5206caa" + integrity sha512-h7hP5Lc33OkFhMcvfrPcwINzMOuPoG8Vn8O6niKGFxF9RmmQnJgaAG1J43/Eq9ZWBrWi0n42XBttibKwCMViHw== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5484,13 +5492,13 @@ medusa-telemetry@0.0.3-dev-1631019393655: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.23-dev-1631019393655: - version "1.1.23-dev-1631019393655" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.23-dev-1631019393655.tgz#1daf3913cfe8a9586863e4b80dabbb7344ebba48" - integrity sha512-5qsKVr2whQi03lGPmy2+/tAD1KuFsqrAcaaEp+u42Jjg6acDV1rrcTgFLj+lplRBOC2qFzMEU0cBFuGKNmYhpg== +medusa-test-utils@^1.1.24: + version "1.1.25" + resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.25.tgz#7c4aa8a70ec8a95875304258ffbe7493a1e5a7fc" + integrity sha512-4xy20KsZBR1XcuzckGRq9A+GJwh+CFHzVw3dajaO4iiNpL/a9K3Yj2N4f/8BgRcQyw5PnkKGJ0pzv+OR8+5GVw== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.20-dev-1631019393655" + medusa-core-utils "^1.1.22" randomatic "^3.1.1" merge-descriptors@1.0.1: From 897ccf475aa417ec61bdd534b0d6d40b4ee2ea33 Mon Sep 17 00:00:00 2001 From: Sebastian Mateos Nicolajsen <80953876+sebastiannicolajsen@users.noreply.github.com> Date: Wed, 22 Sep 2021 15:19:35 +0200 Subject: [PATCH 06/19] Feat/note on order (#399) * added NoteService and related endpoints && tests * removed snapshots * corrected error in service * removed snapshot * added the ability to note down author using a string * updated model for note * refactored to access logged in user * added other user id option * removed snapshot * updated according to feedback * removed snapshots * reintroduced snapshots * updated to snake case * removed try catch from use-db --- docs-util/helpers/test-server.js | 32 +-- docs-util/helpers/use-db.js | 44 +-- integration-tests/api/__tests__/admin/note.js | 268 ++++++++++++++++++ integration-tests/api/helpers/admin-seeder.js | 14 +- packages/medusa/src/api/routes/admin/index.js | 2 + .../src/api/routes/admin/notes/create-note.js | 63 ++++ .../src/api/routes/admin/notes/delete-note.js | 35 +++ .../src/api/routes/admin/notes/get-note.js | 31 ++ .../src/api/routes/admin/notes/index.js | 20 ++ .../src/api/routes/admin/notes/list-notes.js | 42 +++ .../src/api/routes/admin/notes/update-note.js | 51 ++++ packages/medusa/src/index.js | 1 + .../src/migrations/1632220294687-add_notes.ts | 23 ++ packages/medusa/src/models/note.ts | 96 +++++++ packages/medusa/src/repositories/note.ts | 5 + .../medusa/src/services/__tests__/note.js | 202 +++++++++++++ packages/medusa/src/services/note.js | 169 +++++++++++ packages/medusa/src/services/notification.js | 2 +- 18 files changed, 1054 insertions(+), 46 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/note.js create mode 100644 packages/medusa/src/api/routes/admin/notes/create-note.js create mode 100644 packages/medusa/src/api/routes/admin/notes/delete-note.js create mode 100644 packages/medusa/src/api/routes/admin/notes/get-note.js create mode 100644 packages/medusa/src/api/routes/admin/notes/index.js create mode 100644 packages/medusa/src/api/routes/admin/notes/list-notes.js create mode 100644 packages/medusa/src/api/routes/admin/notes/update-note.js create mode 100644 packages/medusa/src/migrations/1632220294687-add_notes.ts create mode 100644 packages/medusa/src/models/note.ts create mode 100644 packages/medusa/src/repositories/note.ts create mode 100644 packages/medusa/src/services/__tests__/note.js create mode 100644 packages/medusa/src/services/note.js diff --git a/docs-util/helpers/test-server.js b/docs-util/helpers/test-server.js index 785c39fd7d..fcda9df353 100644 --- a/docs-util/helpers/test-server.js +++ b/docs-util/helpers/test-server.js @@ -1,34 +1,34 @@ -const path = require("path"); -const express = require("express"); -const getPort = require("get-port"); -const importFrom = require("import-from"); +const path = require("path") +const express = require("express") +const getPort = require("get-port") +const importFrom = require("import-from") const initialize = async () => { - const app = express(); + const app = express() - const cwd = process.cwd(); - const loaders = importFrom(cwd, "@medusajs/medusa/dist/loaders").default; + const cwd = process.cwd() + const loaders = importFrom(cwd, "@medusajs/medusa/dist/loaders").default const { dbConnection } = await loaders({ directory: path.resolve(process.cwd()), expressApp: app, - }); + }) - const PORT = await getPort(); + const PORT = await getPort() return { db: dbConnection, app, port: PORT, - }; -}; + } +} const setup = async () => { - const { app, port } = await initialize(); + const { app, port } = await initialize() app.listen(port, (err) => { - process.send(port); - }); -}; + process.send(port) + }) +} -setup(); +setup() diff --git a/docs-util/helpers/use-db.js b/docs-util/helpers/use-db.js index d533ae5ba2..c451fe0cb0 100644 --- a/docs-util/helpers/use-db.js +++ b/docs-util/helpers/use-db.js @@ -1,26 +1,26 @@ -const { dropDatabase, createDatabase } = require("pg-god"); -const { createConnection } = require("typeorm"); +const { dropDatabase, createDatabase } = require("pg-god") +const { createConnection } = require("typeorm") -const path = require("path"); +const path = require("path") const DbTestUtil = { db_: null, setDb: function (connection) { - this.db_ = connection; + this.db_ = connection }, clear: function () { - return this.db_.synchronize(true); + return this.db_.synchronize(true) }, shutdown: async function () { - await this.db_.close(); - return dropDatabase({ databaseName }); + await this.db_.close() + return dropDatabase({ databaseName }) }, -}; +} -const instance = DbTestUtil; +const instance = DbTestUtil module.exports = { initDb: async function ({ cwd }) { @@ -33,19 +33,19 @@ module.exports = { `dist`, `migrations` ) - ); + ) - const databaseName = "medusa-fixtures"; - await createDatabase({ databaseName }); + const databaseName = "medusa-fixtures" + await createDatabase({ databaseName }) const connection = await createConnection({ type: "postgres", url: "postgres://localhost/medusa-fixtures", migrations: [`${migrationDir}/*.js`], - }); + }) - await connection.runMigrations(); - await connection.close(); + await connection.runMigrations() + await connection.close() const modelsLoader = require(path.join( cwd, @@ -55,19 +55,19 @@ module.exports = { `dist`, `loaders`, `models` - )).default; + )).default - const entities = modelsLoader({}, { register: false }); + const entities = modelsLoader({}, { register: false }) const dbConnection = await createConnection({ type: "postgres", url: "postgres://localhost/medusa-fixtures", entities, - }); + }) - instance.setDb(dbConnection); - return dbConnection; + instance.setDb(dbConnection) + return dbConnection }, useDb: function () { - return instance; + return instance }, -}; +} diff --git a/integration-tests/api/__tests__/admin/note.js b/integration-tests/api/__tests__/admin/note.js new file mode 100644 index 0000000000..6e3bd05df0 --- /dev/null +++ b/integration-tests/api/__tests__/admin/note.js @@ -0,0 +1,268 @@ +const path = require("path") +const { Note } = require("@medusajs/medusa") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") + +const adminSeeder = require("../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const note = { + id: "note1", + value: "note text", + resource_id: "resource1", + resource_type: "type", + author: { id: "admin_user" }, +} + +describe("/admin/notes", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + describe("GET /admin/notes/:id", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + + await manager.insert(Note, note) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("properly retrieves note", async () => { + const api = useApi() + + const response = await api.get("/admin/notes/note1", { + headers: { + authorization: "Bearer test_token", + }, + }) + + expect(response.data).toMatchObject({ + note: { + id: "note1", + resource_id: "resource1", + resource_type: "type", + value: "note text", + author: { id: "admin_user" }, + }, + }) + }) + }) + + describe("POST /admin/notes", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("creates a note", async () => { + const api = useApi() + + const response = await api + .post( + "/admin/notes", + { + resource_id: "resource-id", + resource_type: "resource-type", + value: "my note", + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.data).toMatchObject({ + note: { + id: expect.stringMatching(/^note_*/), + resource_id: "resource-id", + resource_type: "resource-type", + value: "my note", + author_id: "admin_user", + }, + }) + }) + }) + + describe("GET /admin/notes", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + + await manager.insert(Note, { ...note, id: "note1" }) + await manager.insert(Note, { ...note, id: "note2" }) + await manager.insert(Note, { + ...note, + id: "note3", + resource_id: "resource2", + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("lists notes only related to wanted resource", async () => { + const api = useApi() + const response = await api + .get("/admin/notes?resource_id=resource1", { + headers: { + authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.data.notes.length).toEqual(2) + expect(response.data).toMatchObject({ + notes: [ + { + id: "note1", + resource_id: "resource1", + resource_type: "type", + value: "note text", + author: { id: "admin_user" }, + }, + { + id: "note2", + resource_id: "resource1", + resource_type: "type", + value: "note text", + author: { id: "admin_user" }, + }, + ], + }) + }) + }) + + describe("POST /admin/notes/:id", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + + await manager.insert(Note, note) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("updates the content of the note", async () => { + const api = useApi() + + await api + .post( + "/admin/notes/note1", + { value: "new text" }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + const response = await api + .get("/admin/notes/note1", { + headers: { + authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.data.note.value).toEqual("new text") + }) + }) + + describe("DELETE /admin/notes/:id", () => { + beforeEach(async () => { + const manager = dbConnection.manager + try { + await adminSeeder(dbConnection) + + await manager.insert(Note, note) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("deletes the wanted note", async () => { + const api = useApi() + + await api + .delete("/admin/notes/note1", { + headers: { + authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + let error + await api + .get("/admin/notes/note1", { + headers: { + authorization: "Bearer test_token", + }, + }) + .catch((err) => (error = err)) + + expect(error.response.status).toEqual(404) + }) + }) +}) diff --git a/integration-tests/api/helpers/admin-seeder.js b/integration-tests/api/helpers/admin-seeder.js index 9a21256360..4a6fcfce82 100644 --- a/integration-tests/api/helpers/admin-seeder.js +++ b/integration-tests/api/helpers/admin-seeder.js @@ -1,11 +1,11 @@ -const Scrypt = require("scrypt-kdf"); -const { User } = require("@medusajs/medusa"); +const Scrypt = require("scrypt-kdf") +const { User } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { - const manager = connection.manager; + const manager = connection.manager - const buf = await Scrypt.kdf("secret_password", { logN: 1, r: 1, p: 1 }); - const password_hash = buf.toString("base64"); + const buf = await Scrypt.kdf("secret_password", { logN: 1, r: 1, p: 1 }) + const password_hash = buf.toString("base64") await manager.insert(User, { id: "admin_user", @@ -13,5 +13,5 @@ module.exports = async (connection, data = {}) => { api_token: "test_token", password_hash, ...data, - }); -}; + }) +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index c5b5d4b557..1ce749b289 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -22,6 +22,7 @@ import variantRoutes from "./variants" import draftOrderRoutes from "./draft-orders" import collectionRoutes from "./collections" import notificationRoutes from "./notifications" +import noteRoutes from "./notes" const route = Router() @@ -68,6 +69,7 @@ export default (app, container, config) => { collectionRoutes(route) notificationRoutes(route) returnReasonRoutes(route) + noteRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/notes/create-note.js b/packages/medusa/src/api/routes/admin/notes/create-note.js new file mode 100644 index 0000000000..518375876f --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/create-note.js @@ -0,0 +1,63 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +/** + * @oas [post] /notes + * operationId: "PostNotes" + * summary: "Creates a Note" + * description: "Creates a Note which can be associated with any resource as required." + * requestBody: + * content: + * application/json: + * schema: + * properties: + * resource_id: + * type: string + * description: The id of the resource which the Note relates to. + * resource_type: + * type: string + * description: The type of resource which the Note relates to. + * value: + * type: string + * description: The content of the Note to create. + * tags: + * - Note + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * note: + * $ref: "#/components/schemas/note" + * + */ +export default async (req, res) => { + const schema = Validator.object().keys({ + resource_id: Validator.string(), + resource_type: Validator.string(), + value: Validator.string(), + }) + + const userId = req.user.id || req.user.userId + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const noteService = req.scope.resolve("noteService") + + const result = await noteService.create({ + resource_id: value.resource_id, + resource_type: value.resource_type, + value: value.value, + author_id: userId, + }) + + res.status(200).json({ note: result }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/notes/delete-note.js b/packages/medusa/src/api/routes/admin/notes/delete-note.js new file mode 100644 index 0000000000..a9d079d5e6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/delete-note.js @@ -0,0 +1,35 @@ +/** + * @oas [delete] /notes/{id} + * operationId: "DeleteNotesNote" + * summary: "Deletes a Note" + * description: "Deletes a Note." + * parameters: + * - (path) id=* {string} The id of the Note to delete. + * tags: + * - Note + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The id of the deleted Note. + * deleted: + * type: boolean + * description: Whether or not the Note was deleted. + */ +export default async (req, res) => { + const { id } = req.params + + try { + const noteService = req.scope.resolve("noteService") + await noteService.delete(id) + + res.status(200).json({ id, deleted: true }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/notes/get-note.js b/packages/medusa/src/api/routes/admin/notes/get-note.js new file mode 100644 index 0000000000..831b4f6b9e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/get-note.js @@ -0,0 +1,31 @@ +/** + * @oas [get] /notes/{id} + * operationId: "GetNoteNote" + * summary: "Get Note" + * description: "Retrieves a single note using its id" + * parameters: + * - (path) id=* {string} The id of the note to retrieve. + * tags: + * - Note + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * note: + * $ref: "#/components/schemas/note" + */ +export default async (req, res) => { + const { id } = req.params + + try { + const noteService = req.scope.resolve("noteService") + const note = await noteService.retrieve(id, { relations: ["author"] }) + + res.status(200).json({ note }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/notes/index.js b/packages/medusa/src/api/routes/admin/notes/index.js new file mode 100644 index 0000000000..3bbf13f1e6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/index.js @@ -0,0 +1,20 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/notes", route) + + route.get("/:id", middlewares.wrap(require("./get-note").default)) + + route.get("/", middlewares.wrap(require("./list-notes").default)) + + route.post("/", middlewares.wrap(require("./create-note").default)) + + route.post("/:id", middlewares.wrap(require("./update-note").default)) + + route.delete("/:id", middlewares.wrap(require("./delete-note").default)) + + return app +} diff --git a/packages/medusa/src/api/routes/admin/notes/list-notes.js b/packages/medusa/src/api/routes/admin/notes/list-notes.js new file mode 100644 index 0000000000..ba2369866e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/list-notes.js @@ -0,0 +1,42 @@ +/** + * @oas [get] /notes + * operationId: "GetNotes" + * summary: "List Notes" + * description: "Retrieves a list of notes" + * tags: + * - Note + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * notes: + * type: array + * items: + * $ref: "#/components/schemas/note" + */ +export default async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 50 + const offset = parseInt(req.query.offset) || 0 + + const selector = {} + + if ("resource_id" in req.query) { + selector.resource_id = req.query.resource_id + } + + const noteService = req.scope.resolve("noteService") + const notes = await noteService.list(selector, { + take: limit, + skip: offset, + relations: ["author"], + }) + + res.status(200).json({ notes }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/notes/update-note.js b/packages/medusa/src/api/routes/admin/notes/update-note.js new file mode 100644 index 0000000000..eca3822e05 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notes/update-note.js @@ -0,0 +1,51 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +/** + * @oas [post] /notes/{id} + * operationId: "PostNotesNote" + * summary: "Updates a Note" + * description: "Updates a Note associated with some resource" + * parameters: + * - (path) id=* {string} The id of the Note to update + * requestBody: + * content: + * application/json: + * schema: + * properties: + * value: + * type: string + * description: The updated description of the Note. + * tags: + * - Note + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * note: + * $ref: "#/components/schemas/note" + * + */ +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + value: Validator.string(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const noteService = req.scope.resolve("noteService") + const result = await noteService.update(id, value.value) + + res.status(200).json({ note: result }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index effb629ac7..fca74c93bd 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -45,3 +45,4 @@ export { Swap } from "./models/swap" export { User } from "./models/user" export { DraftOrder } from "./models/draft-order" export { ReturnReason } from "./models/return-reason" +export { Note } from "./models/note" diff --git a/packages/medusa/src/migrations/1632220294687-add_notes.ts b/packages/medusa/src/migrations/1632220294687-add_notes.ts new file mode 100644 index 0000000000..9550e11507 --- /dev/null +++ b/packages/medusa/src/migrations/1632220294687-add_notes.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addNotes1632220294687 implements MigrationInterface { + name = "addNotes1632220294687" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "note" ("id" character varying NOT NULL, "value" character varying NOT NULL, "resource_type" character varying NOT NULL, "resource_id" character varying NOT NULL, "author_id" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "metadata" jsonb, CONSTRAINT "PK_96d0c172a4fba276b1bbed43058" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_f74980b411cf94af523a72af7d" ON "note" ("resource_type") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_3287f98befad26c3a7dab088cf" ON "note" ("resource_id") ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_3287f98befad26c3a7dab088cf"`) + await queryRunner.query(`DROP INDEX "IDX_f74980b411cf94af523a72af7d"`) + await queryRunner.query(`DROP TABLE "note"`) + } +} diff --git a/packages/medusa/src/models/note.ts b/packages/medusa/src/models/note.ts new file mode 100644 index 0000000000..77c29f89df --- /dev/null +++ b/packages/medusa/src/models/note.ts @@ -0,0 +1,96 @@ +import { + Entity, + BeforeInsert, + Column, + DeleteDateColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + JoinColumn, + PrimaryColumn, + ManyToOne, +} from "typeorm" +import { ulid } from "ulid" +import { User } from "./user" +import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column" + +@Entity() +export class Note { + @PrimaryColumn() + id: string + + @Column() + value: string + + @Index() + @Column() + resource_type: string + + @Index() + @Column() + resource_id: string + + @Column({ nullable: true }) + author_id: string + + @ManyToOne(() => User) + @JoinColumn({ name: "author_id" }) + author: User + + @CreateDateColumn({ type: resolveDbType("timestamptz") }) + created_at: Date + + @UpdateDateColumn({ type: resolveDbType("timestamptz") }) + updated_at: Date + + @DeleteDateColumn({ type: resolveDbType("timestamptz") }) + deleted_at: Date + + @DbAwareColumn({ type: "jsonb", nullable: true }) + metadata: any + + @BeforeInsert() + private beforeInsert() { + if (this.id) return + const id = ulid() + this.id = `note_${id}` + } +} + +/** + * @schema note + * title: "Note" + * description: "Notes are elements which we can use in association with different resources to allow users to describe additional information in relation to these." + * x-resourceId: note + * properties: + * id: + * description: "The id of the Note. This value will be prefixed by `note_`." + * type: string + * resource_type: + * description: "The type of resource that the Note refers to." + * type: string + * resource_id: + * description: "The id of the resource that the Note refers to." + * type: string + * value: + * description: "The contents of the note." + * type: string + * author: + * description: "The author of the note." + * type: User + * created_at: + * description: "The date with timezone at which the resource was created." + * type: string + * format: date-time + * updated_at: + * description: "The date with timezone at which the resource was last updated." + * type: string + * format: date-time + * deleted_at: + * description: "The date with timezone at which the resource was deleted." + * type: string + * format: date-time + * metadata: + * description: "An optional key-value map with additional information." + * type: object + */ diff --git a/packages/medusa/src/repositories/note.ts b/packages/medusa/src/repositories/note.ts new file mode 100644 index 0000000000..1e3065c417 --- /dev/null +++ b/packages/medusa/src/repositories/note.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { Note } from "../models/note" + +@EntityRepository(Note) +export class NoteRepository extends Repository {} diff --git a/packages/medusa/src/services/__tests__/note.js b/packages/medusa/src/services/__tests__/note.js new file mode 100644 index 0000000000..e2b9af4697 --- /dev/null +++ b/packages/medusa/src/services/__tests__/note.js @@ -0,0 +1,202 @@ +import NoteService from "../note" +import { MockManager, MockRepository, IdMap } from "medusa-test-utils" +import { EventBusServiceMock } from "../__mocks__/event-bus" + +describe("NoteService", () => { + describe("list", () => { + const noteRepo = MockRepository({ + find: q => { + return Promise.resolve([ + { id: IdMap.getId("note"), value: "some note" }, + ]) + }, + }) + + const noteService = new NoteService({ + manager: MockManager, + noteRepository: noteRepo, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls note model functions", async () => { + await noteService.list( + { resource_id: IdMap.getId("note") }, + { + relations: ["author"], + } + ) + expect(noteRepo.find).toHaveBeenCalledTimes(1) + expect(noteRepo.find).toHaveBeenCalledWith({ + where: { + resource_id: IdMap.getId("note"), + }, + relations: ["author"], + }) + }) + }) + + describe("retrieve", () => { + const noteRepo = MockRepository({ + findOne: q => { + switch (q.where.id) { + case IdMap.getId("note"): + return Promise.resolve({ + id: IdMap.getId("note"), + value: "some note", + }) + default: + return Promise.resolve() + } + }, + }) + + const noteService = new NoteService({ + manager: MockManager, + noteRepository: noteRepo, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls note model functions", async () => { + await noteService.retrieve(IdMap.getId("note"), { relations: ["author"] }) + + expect(noteRepo.findOne).toHaveBeenCalledTimes(1) + expect(noteRepo.findOne).toHaveBeenCalledWith({ + where: { id: IdMap.getId("note") }, + relations: ["author"], + }) + }) + + it("fails when note is not found", async () => { + await expect( + noteService.retrieve(IdMap.getId("not-existing")) + ).rejects.toThrow( + `Note with id: ${IdMap.getId("not-existing")} was not found.` + ) + }) + }) + + describe("create", () => { + const note = { + id: IdMap.getId("note"), + author_id: IdMap.getId("user"), + } + + const noteRepo = MockRepository({ + create: f => Promise.resolve(note), + save: f => Promise.resolve(note), + }) + + const noteService = new NoteService({ + manager: MockManager, + noteRepository: noteRepo, + eventBusService: EventBusServiceMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls note model functions", async () => { + await noteService.create({ + resource_id: IdMap.getId("resource-id"), + resource_type: "type", + value: "my note", + author_id: IdMap.getId("user"), + }) + + expect(noteRepo.create).toHaveBeenCalledTimes(1) + expect(noteRepo.create).toHaveBeenCalledWith({ + resource_id: IdMap.getId("resource-id"), + resource_type: "type", + value: "my note", + author_id: IdMap.getId("user"), + metadata: {}, + }) + + expect(noteRepo.save).toHaveBeenCalledTimes(1) + expect(noteRepo.save).toHaveBeenCalledWith({ + id: IdMap.getId("note"), + author_id: IdMap.getId("user"), + }) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + NoteService.Events.CREATED, + { id: IdMap.getId("note") } + ) + }) + }) + + describe("update", () => { + const note = { id: IdMap.getId("note") } + + const noteRepo = MockRepository({ + findOne: f => Promise.resolve(note), + save: f => Promise.resolve(note), + }) + + const noteService = new NoteService({ + manager: MockManager, + noteRepository: noteRepo, + eventBusService: EventBusServiceMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls note model functions", async () => { + await noteService.update(IdMap.getId("note"), "new note") + + expect(noteRepo.save).toHaveBeenCalledTimes(1) + expect(noteRepo.save).toHaveBeenCalledWith({ + ...note, + value: "new note", + }) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + NoteService.Events.UPDATED, + { id: IdMap.getId("note") } + ) + }) + }) + + describe("delete", () => { + const note = { id: IdMap.getId("note") } + + const noteRepo = MockRepository({ + softRemove: f => Promise.resolve(), + findOne: f => Promise.resolve(note), + }) + + const noteService = new NoteService({ + manager: MockManager, + noteRepository: noteRepo, + eventBusService: EventBusServiceMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls note model functions", async () => { + await noteService.delete(IdMap.getId("note")) + + expect(noteRepo.softRemove).toHaveBeenCalledTimes(1) + expect(noteRepo.softRemove).toHaveBeenCalledWith(note) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + NoteService.Events.DELETED, + { id: IdMap.getId("note") } + ) + }) + }) +}) diff --git a/packages/medusa/src/services/note.js b/packages/medusa/src/services/note.js new file mode 100644 index 0000000000..25d032803f --- /dev/null +++ b/packages/medusa/src/services/note.js @@ -0,0 +1,169 @@ +import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import _ from "lodash" +import { TransactionManager } from "typeorm" + +class NoteService extends BaseService { + static Events = { + CREATED: "note.created", + UPDATED: "note.updated", + DELETED: "note.deleted", + } + + constructor({ manager, noteRepository, eventBusService, userService }) { + super() + + /** @private @const {EntityManager} */ + this.manager_ = manager + + /** @private @const {NoteRepository} */ + this.noteRepository_ = noteRepository + + /** @private @const {EventBus} */ + this.eventBus_ = eventBusService + } + + /** + * Sets the service's manager to a given transaction manager + * @param {EntityManager} transactionManager - the manager to use + * @return {NoteService} a cloned note service + */ + withTransaction(transactionManager) { + if (!TransactionManager) { + return this + } + + const cloned = new NoteService({ + manager: transactionManager, + noteRepository: this.noteRepository_, + eventBus: this.eventBus_, + }) + + cloned.transactionManager_ = transactionManager + return cloned + } + + /** + * Retrieves a specific note. + * @param {*} id - the id of the note to retrieve. + * @param {*} config - any options needed to query for the result. + * @returns {Promise} which resolves to the requested note. + */ + async retrieve(id, config = {}) { + const noteRepo = this.manager_.getCustomRepository(this.noteRepository_) + + const validatedId = this.validateId_(id) + const query = this.buildQuery_({ id: validatedId }, config) + + const note = await noteRepo.findOne(query) + + if (!note) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Note with id: ${id} was not found.` + ) + } + + return note + } + + /** Fetches all notes related to the given selector + * @param {Object} selector - the query object for find + * @param {Object} config - the configuration used to find the objects. contains relations, skip, and take. + * @return {Promise} notes related to the given search. + */ + async list( + selector, + config = { + skip: 0, + take: 50, + relations: [], + } + ) { + const noteRepo = this.manager_.getCustomRepository(this.noteRepository_) + + const query = this.buildQuery_(selector, config) + + return noteRepo.find(query) + } + + /** + * Creates a note associated with a given author + * @param {object} data - the note to create + * @param {*} config - any configurations if needed, including meta data + * @returns {Promise} resolves to the creation result + */ + async create(data, config = { metadata: {} }) { + const { metadata } = config + + const { resource_id, resource_type, value, author_id } = data + + return this.atomicPhase_(async manager => { + const noteRepo = manager.getCustomRepository(this.noteRepository_) + + const toCreate = { + resource_id, + resource_type, + value, + author_id, + metadata, + } + + const note = await noteRepo.create(toCreate) + const result = await noteRepo.save(note) + + await this.eventBus_ + .withTransaction(manager) + .emit(NoteService.Events.CREATED, { id: result.id }) + + return result + }) + } + + /** + * Updates a given note with a new value + * @param {*} noteId - the id of the note to update + * @param {*} value - the new value + * @returns {Promise} resolves to the updated element + */ + async update(noteId, value) { + return this.atomicPhase_(async manager => { + const noteRepo = manager.getCustomRepository(this.noteRepository_) + + const note = await this.retrieve(noteId, { relations: ["author"] }) + + note.value = value + + const result = await noteRepo.save(note) + + await this.eventBus_ + .withTransaction(manager) + .emit(NoteService.Events.UPDATED, { id: result.id }) + + return result + }) + } + + /** + * Deletes a given note + * @param {*} noteId - id of the note to delete + * @returns {Promise} + */ + async delete(noteId) { + return this.atomicPhase_(async manager => { + const noteRepo = manager.getCustomRepository(this.noteRepository_) + + const note = await this.retrieve(noteId) + + await noteRepo.softRemove(note) + + await this.eventBus_ + .withTransaction(manager) + .emit(NoteService.Events.DELETED, { id: noteId }) + + return Promise.resolve() + }) + } +} + +export default NoteService diff --git a/packages/medusa/src/services/notification.js b/packages/medusa/src/services/notification.js index 8dcf7962a0..615efad575 100644 --- a/packages/medusa/src/services/notification.js +++ b/packages/medusa/src/services/notification.js @@ -41,7 +41,7 @@ class NotificationService extends BaseService { /** * Sets the service's manager to a given transaction manager. - * @parma {EntityManager} transactionManager - the manager to use + * @param {EntityManager} transactionManager - the manager to use * return {NotificationService} a cloned notification service */ withTransaction(transactionManager) { From a70e3ed0aee0e9c19f7aaf8bb8d22eb68a9695b7 Mon Sep 17 00:00:00 2001 From: Sebastian Mateos Nicolajsen <80953876+sebastiannicolajsen@users.noreply.github.com> Date: Thu, 23 Sep 2021 10:22:18 +0200 Subject: [PATCH 07/19] feat: customer-information (#413) * added the ability to update email as long as user has_account=false * revamped and added fix for MC-132 Co-authored-by: olivermrbl --- .../api/__tests__/admin/customer.js | 52 ++++++++++++++++++- .../api/helpers/customer-seeder.js | 20 ++++--- .../routes/admin/customers/update-customer.js | 16 +++++- .../customers/__tests__/update-customer.js | 2 + .../routes/store/customers/update-customer.js | 5 ++ .../medusa/src/services/__tests__/customer.js | 1 - packages/medusa/yarn.lock | 10 ++-- 7 files changed, 91 insertions(+), 15 deletions(-) diff --git a/integration-tests/api/__tests__/admin/customer.js b/integration-tests/api/__tests__/admin/customer.js index 1458311eff..510ad13192 100644 --- a/integration-tests/api/__tests__/admin/customer.js +++ b/integration-tests/api/__tests__/admin/customer.js @@ -57,7 +57,7 @@ describe("/admin/customers", () => { }) expect(response.status).toEqual(200) - expect(response.data.count).toEqual(3) + expect(response.data.count).toEqual(4) expect(response.data.customers).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -69,6 +69,9 @@ describe("/admin/customers", () => { expect.objectContaining({ id: "test-customer-3", }), + expect.objectContaining({ + id: "test-customer-has_account", + }), ]) ) }) @@ -129,4 +132,51 @@ describe("/admin/customers", () => { ) }) }) + + describe("POST /admin/customers/:id", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await customerSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Correctly updates customer", async () => { + const api = useApi() + const response = await api + .post( + "/admin/customers/test-customer-3", + { + first_name: "newf", + last_name: "newl", + email: "new@email.com", + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + first_name: "newf", + last_name: "newl", + email: "new@email.com", + }) + ) + }) + }) }) diff --git a/integration-tests/api/helpers/customer-seeder.js b/integration-tests/api/helpers/customer-seeder.js index 78b3e22ec5..acef948bbd 100644 --- a/integration-tests/api/helpers/customer-seeder.js +++ b/integration-tests/api/helpers/customer-seeder.js @@ -1,27 +1,33 @@ -const { Customer, Address } = require("@medusajs/medusa"); +const { Customer, Address } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { - const manager = connection.manager; + const manager = connection.manager await manager.insert(Customer, { id: "test-customer-1", email: "test1@email.com", - }); + }) await manager.insert(Customer, { id: "test-customer-2", email: "test2@email.com", - }); + }) await manager.insert(Customer, { id: "test-customer-3", email: "test3@email.com", - }); + }) + + await manager.insert(Customer, { + id: "test-customer-has_account", + email: "test4@email.com", + has_account: true, + }) await manager.insert(Address, { id: "test-address", first_name: "Lebron", last_name: "James", customer_id: "test-customer-1", - }); -}; + }) +} diff --git a/packages/medusa/src/api/routes/admin/customers/update-customer.js b/packages/medusa/src/api/routes/admin/customers/update-customer.js index c85475e31e..bc3dd3ff67 100644 --- a/packages/medusa/src/api/routes/admin/customers/update-customer.js +++ b/packages/medusa/src/api/routes/admin/customers/update-customer.js @@ -12,6 +12,9 @@ import { Validator, MedusaError } from "medusa-core-utils" * application/json: * schema: * properties: + * email: + * type: string + * description: The Customer's email. Only providable if user not registered. * first_name: * type: string * description: The Customer's first name. @@ -37,6 +40,7 @@ export default async (req, res) => { const { id } = req.params const schema = Validator.object().keys({ + email: Validator.string().optional(), first_name: Validator.string().optional(), last_name: Validator.string().optional(), password: Validator.string().optional(), @@ -50,9 +54,19 @@ export default async (req, res) => { try { const customerService = req.scope.resolve("customerService") + + let customer = await customerService.retrieve(id) + + if (value.email && customer.has_account) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Email cannot be changed when the user has registered their account" + ) + } + await customerService.update(id, value) - const customer = await customerService.retrieve(id, { + customer = await customerService.retrieve(id, { relations: ["orders"], }) res.status(200).json({ customer }) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js index a1a9c7925e..fb84059304 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js @@ -11,6 +11,7 @@ describe("POST /store/customers/:id", () => { payload: { first_name: "LeBron", last_name: "James", + email: "test@email.com", }, clientSession: { jwt: { @@ -31,6 +32,7 @@ describe("POST /store/customers/:id", () => { { first_name: "LeBron", last_name: "James", + email: "test@email.com", } ) }) diff --git a/packages/medusa/src/api/routes/store/customers/update-customer.js b/packages/medusa/src/api/routes/store/customers/update-customer.js index a3635f9ec5..8f862b3551 100644 --- a/packages/medusa/src/api/routes/store/customers/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/update-customer.js @@ -1,3 +1,4 @@ +import { optional } from "joi" import { Validator, MedusaError } from "medusa-core-utils" import { defaultRelations, defaultFields } from "./" @@ -27,6 +28,9 @@ import { defaultRelations, defaultFields } from "./" * phone: * description: "The Customer's phone number." * type: string + * email: + * description: "The email of the customer." + * type: string * metadata: * description: "Metadata about the customer." * type: object @@ -51,6 +55,7 @@ export default async (req, res) => { last_name: Validator.string().optional(), password: Validator.string().optional(), phone: Validator.string().optional(), + email: Validator.string().optional(), metadata: Validator.object().optional(), }) diff --git a/packages/medusa/src/services/__tests__/customer.js b/packages/medusa/src/services/__tests__/customer.js index e980cda5a3..0c3ed6dd50 100644 --- a/packages/medusa/src/services/__tests__/customer.js +++ b/packages/medusa/src/services/__tests__/customer.js @@ -1,5 +1,4 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" -import { add } from "winston" import CustomerService from "../customer" const eventBusService = { diff --git a/packages/medusa/yarn.lock b/packages/medusa/yarn.lock index df967251e9..e863d0abe7 100644 --- a/packages/medusa/yarn.lock +++ b/packages/medusa/yarn.lock @@ -1407,7 +1407,7 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@medusajs/medusa-cli@^1.1.17": +"@medusajs/medusa-cli@^1.1.18": version "1.1.18" resolved "https://registry.yarnpkg.com/@medusajs/medusa-cli/-/medusa-cli-1.1.18.tgz#a2b34575a81a7df239d6d06cf0d0b192e2b8c8db" integrity sha512-JEvQVjebaGuOF5BsqjZYnewmU4TPbrnhODKVyadPKPb/cxPcCMODg21d5QyoaVlcXood08LgTFe8CfdWoyubVw== @@ -2046,7 +2046,7 @@ babel-preset-jest@^25.5.0: babel-plugin-jest-hoist "^25.5.0" babel-preset-current-node-syntax "^0.1.2" -babel-preset-medusa-package@^1.1.14: +babel-preset-medusa-package@^1.1.15: version "1.1.15" resolved "https://registry.yarnpkg.com/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.15.tgz#6917cadd8abe9a1f64c71b5c43ab507df193effc" integrity sha512-toA8mFdvLeKbbRJ7KvQvpL6VJnzkKURZv7Yd97cXMMNpdjrhp+SZppcNOL2tk6ywgBAs4NC2LCVjtZInMMBS6Q== @@ -5462,7 +5462,7 @@ medusa-core-utils@^0.1.27: "@hapi/joi" "^16.1.8" joi-objectid "^3.0.1" -medusa-core-utils@^1.1.21, medusa-core-utils@^1.1.22: +medusa-core-utils@^1.1.22: version "1.1.22" resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.22.tgz#84ce0af0a7c672191d758ea462056e30a39d08b1" integrity sha512-kMuRkWOuNG4Bw6epg/AYu95UJuE+rjHTeTWRLbEPrYGjWREV82tLWVDI21/QcccmaHmMU98Rkw2z9JwyFZIiyw== @@ -5470,7 +5470,7 @@ medusa-core-utils@^1.1.21, medusa-core-utils@^1.1.22: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@^1.1.22: +medusa-interfaces@^1.1.23: version "1.1.23" resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.23.tgz#b552a8c1d0eaddeff30472ab238652b9e1a56e73" integrity sha512-dHCOnsyYQvjrtRd3p0ZqQZ4M/zmo4M/BAgVfRrYSyGrMdQ86TK9Z1DQDCHEzM1216AxEfXz2JYUD7ilTfG2iHQ== @@ -5492,7 +5492,7 @@ medusa-telemetry@^0.0.5: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@^1.1.24: +medusa-test-utils@^1.1.25: version "1.1.25" resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.25.tgz#7c4aa8a70ec8a95875304258ffbe7493a1e5a7fc" integrity sha512-4xy20KsZBR1XcuzckGRq9A+GJwh+CFHzVw3dajaO4iiNpL/a9K3Yj2N4f/8BgRcQyw5PnkKGJ0pzv+OR8+5GVw== From 85de2039cc0b47d2f026d7225ccc8c9dc9bcb676 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Thu, 23 Sep 2021 10:52:46 +0200 Subject: [PATCH 08/19] hotfix: bug related to region update with no shipping address (#415) --- integration-tests/api/__tests__/store/cart.js | 18 ++++++++++++++++++ integration-tests/api/helpers/cart-seeder.js | 16 ++++++++++++++++ packages/medusa/src/services/__tests__/cart.js | 3 +++ packages/medusa/src/services/cart.js | 2 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/integration-tests/api/__tests__/store/cart.js b/integration-tests/api/__tests__/store/cart.js index afd35f4e69..215f77d194 100644 --- a/integration-tests/api/__tests__/store/cart.js +++ b/integration-tests/api/__tests__/store/cart.js @@ -112,6 +112,24 @@ describe("/store/carts", () => { await doAfterEach() }) + // We were experiencing some issues when having created a cart in a region + // containing multiple countries. At this point, the cart does not have a shipping + // address. Therefore, on subsequent requests to update the cart, the server + // would throw a 500 due to missing shipping address id on insertion. + it("updates a cart, that does not have a shipping address", async () => { + const api = useApi() + + const response = await api.post("/store/carts", { + region_id: "test-region-multiple", + }) + + const getRes = await api.post(`/store/carts/${response.data.cart.id}`, { + region_id: "test-region", + }) + + expect(getRes.status).toEqual(200) + }) + it("fails on apply discount if limit has been reached", async () => { const api = useApi() diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index c59ea96e3f..7a75d418b9 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -40,6 +40,22 @@ module.exports = async (connection, data = {}) => { tax_rate: 0, }) + // Region with multiple countries + const regionWithMultipleCoutries = manager.create(Region, { + id: "test-region-multiple", + name: "Test Region", + currency_code: "eur", + tax_rate: 0, + }) + + await manager.save(regionWithMultipleCoutries) + await manager.query( + `UPDATE "country" SET region_id='test-region-multiple' WHERE iso_2 = 'no'` + ) + await manager.query( + `UPDATE "country" SET region_id='test-region-multiple' WHERE iso_2 = 'dk'` + ) + const freeRule = manager.create(DiscountRule, { id: "free-shipping-rule", description: "Free shipping rule", diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 18919d6b29..f887a6a9d5 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1083,6 +1083,9 @@ describe("CartService", () => { id: "region", countries: [{ iso_2: "us" }], }, + shipping_address: { + country_code: "us", + }, items: [IdMap.getId("testitem"), null], payment_session: null, payment_sessions: [], diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 1dfba8d51c..de1f3ab164 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -1446,7 +1446,7 @@ class CartService extends BaseService { } } - await addrRepo.save(updated) + await this.updateShippingAddress_(cart, updated, addrRepo) } // Shipping methods are determined by region so the user needs to find a From a44cf14fc767b387da789d69773f24643102a56e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Sep 2021 18:47:54 +0200 Subject: [PATCH 09/19] chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa (#411) * docs: create-medusa-app article (#401) * fix: temporarily comment out cloud related docs (#387) * Fix typo in registerOptin section (#407) * docs: Carts in Medusa (#406) * chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5. - [Release notes](https://github.com/daaku/nodejs-tmpl/releases) - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) --- updated-dependencies: - dependency-name: tmpl dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Vadim Smirnov <62517920+FuzzyReason@users.noreply.github.com> Co-authored-by: ps-89 <91064940+ps-89@users.noreply.github.com> Co-authored-by: Sebastian Rindom Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/content/guides/carts-in-medusa.md | 163 ++++++++++++++++++ docs/content/how-to/create-medusa-app.md | 117 +++++++++++++ docs/content/quickstart/quick-start.md | 4 +- .../0-set-up-your-development-environment.md | 6 +- .../tutorial/2-adding-custom-functionality.md | 4 +- packages/medusa/yarn.lock | 6 +- www/docs/sidebars.js | 20 ++- 7 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 docs/content/guides/carts-in-medusa.md create mode 100644 docs/content/how-to/create-medusa-app.md diff --git a/docs/content/guides/carts-in-medusa.md b/docs/content/guides/carts-in-medusa.md new file mode 100644 index 0000000000..7d7e673954 --- /dev/null +++ b/docs/content/guides/carts-in-medusa.md @@ -0,0 +1,163 @@ +--- +title: Carts in Medusa +--- + +# Carts in Medusa + +In Medusa a Cart serves the purpose of collecting the information needed to create an Order, including what products to purchase, what address to send the products to and which payment method the purchase will be processed by. + +To create a cart using the `@medusajs/medusa-js` SDK you can use: + +```javascript +const client = new Medusa({ baseUrl: "http://localhost:9000" }) +const { cart } = await client.carts.create() +``` + +A Cart will always belong to a Region and you may provide a `region_id` upon Cart creation. If no `region_id` is specified Medusa will assign the Cart to a random Region. Regions specify information about how the Cart should be taxed, what currency the Cart should be paid with and what payment and fulfillment options will be available at checkout. Below are some of the properties that can be found on the Cart response. For a full example of a Cart response [check our fixtures](https://github.com/medusajs/medusa/blob/docs/api/docs/api/fixtures/store/GetCartsCart.json). + +```json + "cart": { + "id": "cart_01FEWZSRFWT8QWMHJ7ZCPRP3BZ", + "email": null, + "billing_address": null, + "shipping_address": null, + "items": [ ... ], + "region": { + "id": "reg_01FEWZSRD7HVHBSQRC4KYMG5XM", + "name": "United States", + "currency_code": "usd", + "tax_rate": "0", + ... + }, + "discounts": [], + "gift_cards": [], + "customer_id": null, + "payment_sessions": [], + "payment": null, + "shipping_methods": [], + "type": "default", + "metadata": null, + "shipping_total": 0, + "discount_total": 0, + "tax_total": 0, + "gift_card_total": 0, + "subtotal": 1000, + "total": 1000, + ... + } +``` + +## Adding products to the Cart + +Customers can add products to the Cart in order to start gathering the items that will eventually be purchased. In Medusa adding a product to a Cart will result in a _Line Item_ being generated. To add a product using the SDK use: + +```javascript +const { cart } = await client.carts.lineItems.create(cartId, { + variant_id: "[id-of-variant-to-add]", + quantity: 1, +}) +``` + +The resulting response will look something like this: + +```json +{ + "cart": { + "id": "cart_01FEWZSRFWT8QWMHJ7ZCPRP3BZ", + "items": [ + { + "id": "item_01FEWZSRMBAN85SKPCRMM30N6W", + "cart_id": "cart_01FEWZSRFWT8QWMHJ7ZCPRP3BZ", + "title": "Basic Tee", + "description": "Small", + "thumbnail": null, + "is_giftcard": false, + "should_merge": true, + "allow_discounts": true, + "has_shipping": false, + "unit_price": 1000, + "variant": { + "id": "variant_01FEWZSRDNWABVFZTZ21JWKHRG", + "title": "Small", + "product_id": "prod_01FEWZSRDHDDSHQV6ATG6MS2MF", + "sku": null, + "barcode": null, + "ean": null, + "upc": null, + "allow_backorder": false, + "hs_code": null, + "origin_country": null, + "mid_code": null, + "material": null, + "weight": null, + "length": null, + "height": null, + "width": null, + "metadata": null, + ... + }, + "quantity": 1, + "metadata": {}, + ... + } + ], + ... + } +} +``` + +The properties stored on a Line Item are useful for explaining and displaying the contents of the Cart. For example, Line Items can have a thumbnail assigned which can be used to display a pack shot of the product that is being purchased, a title to show name the products in the cart and a description to give further details about the product. By default the Line Item will be generated with properties inherited from the Product that is being added to the Cart, but the behaviour can be customized for other purposes as well. + +## Adding Customer information to a Cart + +After adding products to the Cart, you should gather information about where to send the products, this is done using the `update` method in the SDK. + +```javascript +const { cart } = await client.carts.update(cartId, { + email: "jane.doe@mail.com", + shipping_address: { + first_name: "Jane", + last_name: "Doe", + address_1: "4242 Hollywood Dr", + postal_code: "12345", + country_code: "us", + city: "Los Angeles", + region: "CA", + }, +}) +``` + +Note that the country code in the shipping address must be the country code for one of the countries in a Region - otherwise this method will fail. + +## Initializing Payment Sessions + +In Medusa payments are handled through the long lived entities called _Payment Sessions_. Payment Sessions cary provider specific data that can later be used to authorize the payments, which is the step required before an order can be created. The SDK provides a `createPaymentSessions` method that can be used to initialize the payment sessions with the Payment Providers available in the Region. + +```javascript +const { cart } = await client.carts.createPaymentSessions(cartId) +``` + +You can read more about Payment Sessions in our [guide to checkouts](https://docs.medusa-commerce.com/guides/checkouts). + +## Changing the Cart region + +To update the Region that the cart belongs to you should also use the `update` method from the SDK. + +```javascript +const { cart } = await client.carts.update(cartId, { + region_id: "[id-of-region-to-switch-to]", +}) +``` + +When changing the Cart region you should be aware of a couple of things: + +- If switching to a Region with a different currency the line item prices and cart totals will change +- If switching to a Region with a different tax rate prices and totals will change +- If switching to a Region serving only one country the `shipping_address.country_code` will automatically be set to that country +- If the Cart already has initialized payment sessions all of these will be canceled and a new call to `createPaymentSessions` will have to be made + +## What's next? + +Carts are at the core of the shopping process in Medusa and provide all the necessary functionality to gather products for purchase. If you want to read a more detailed guide about how to complete checkouts please go to our [Checkout Guide](https://docs.medusa-commerce.com/guides/checkout). + +If you have questions or issues feel free to reach out via our [Discord server](https://discord.gg/xpCwq3Kfn8) for direct access to the Medusa engineering team. diff --git a/docs/content/how-to/create-medusa-app.md b/docs/content/how-to/create-medusa-app.md new file mode 100644 index 0000000000..a3394df3c0 --- /dev/null +++ b/docs/content/how-to/create-medusa-app.md @@ -0,0 +1,117 @@ +--- +title: Using create-medusa-app +--- +# Using create-medusa-app +With the new `create-medusa-app` tool you will get your [Medusa](https://github.com/medusajs/medusa) development environment ready within a couple of minutes. After completion, you will have a Medusa backend, a Gatsby or Next.js storefront, and an admin dashboard up and running on your local machine. + +Starting a new e-commerce project just got easier, now with one command. + +## Getting started with `create-medusa-app` + +Use `create-medusa-app` with your preferred package manager: + +```bash +yarn create medusa-app + +npx create-medusa-app +``` + +Behind the scenes, `create-medusa-app` is populating your database with some initial set of mock data, which helps to interact with Medusa setup intuitively straight away. + +Right after hitting one of those commands, the multistep installation process will be initiated, so the starter can be shaped right for the specific needs. + +### Destination folder + +Enter the path to the directory that will become the root of your Medusa project: + +```bash +? Where should your project be installed? › my-medusa-store +``` + +### Pick the starter you prefer + +```bash +? Which Medusa starter would you like to install? … +❯ medusa-starter-default + medusa-starter-contentful + Other +``` + +You will be presented with three options: + +- `medusa-starter-default` is the most lightweight version of a Medusa project +- `medusa-starter-contentful` almost like the default starter, but with `medusa-plugin-contentful` preinstalled +- `Other` if you have a different starter that you would wish to install from `Other` will give you the option of providing a URL to that starter. An additional question will be asked if you choose this option: + + ```bash + Where is the starter located? (URL or path) › https://github.com/somecoolusername/my-custom-medusa-starter + ``` + +For the walkthrough purposes, we assume that the selected starter is `medusa-starter-default` and proceed to the next step. + +### Selecting a Storefront + +After selecting your Medusa starter you will be given the option to install one of our storefront starters. At the moment we have starters for Gatsby and Next.js: + +```bash +Which storefront starter would you like to install? … +❯ Gatsby Starter + Next.js Starter + None +``` + +You may also select `None` if the choice is to craft a custom storefront for your product. + +`create-medusa-app` now has all of the info necessary for the installation to begin. + +```bash +Creating new project from git: https://github.com/medusajs/medusa-starter-default.git +✔ Created starter directory layout +Installing packages... +``` + +Once the installation has been completed you will have a Medusa backend, a demo storefront, and an admin dashboard. + +## What's inside + +Inside the root folder which was specified at the beginning of the installation process the following structure could be found: + +```bash +/my-medusa-store + /storefront // Medusa storefront starter + /backend // Medusa starter as a backend option + /admin // Medusa admin panel +``` + +`create-medusa-app` prints out the commands that are available to you after installation. When each project is started you can visit your storefront, complete the order, and view the order in Medusa admin. + +```bash +⠴ Installing packages... +✔ Packages installed +Initialising git in my-medusa-store/admin +Create initial git commit in my-medusa-store/admin + + Your project is ready 🚀. The available commands are: + + Medusa API + cd my-medusa-store/backend + yarn start + + Admin + cd my-medusa-store/admin + yarn start + + Storefront + cd my-medusa-store/storefront + yarn start +``` + +## **What's next?** + +To learn more about Medusa to go through our docs to get some inspiration and guidance for the next steps and further development: + +- [Find out how to set up a Medusa project with Gatsby and Contentful](https://docs.medusa-commerce.com/how-to/headless-ecommerce-store-with-gatsby-contentful-medusa) +- [Move your Medusa setup to the next level with some custom functionality](https://docs.medusa-commerce.com/tutorial/adding-custom-functionality) +- [Create your own Medusa plugin](https://docs.medusa-commerce.com/how-to/plugins) + +If you have any follow-up questions or want to chat directly with our engineering team we are always happy to meet you at our [Discord](https://discord.gg/DSHySyMu). diff --git a/docs/content/quickstart/quick-start.md b/docs/content/quickstart/quick-start.md index e39c21faf8..fcc16e9475 100644 --- a/docs/content/quickstart/quick-start.md +++ b/docs/content/quickstart/quick-start.md @@ -32,6 +32,6 @@ We have created two starters for you that can help you lay a foundation for your - [Nextjs Starter](https://github.com/medusajs/nextjs-starter-medusa) - [Gatsby Starter](https://github.com/medusajs/gatsby-starter-medusa) -### Link you local development to Medusa Cloud (Coming soon!) + diff --git a/docs/content/tutorial/0-set-up-your-development-environment.md b/docs/content/tutorial/0-set-up-your-development-environment.md index a93bccea3c..48583f1b00 100644 --- a/docs/content/tutorial/0-set-up-your-development-environment.md +++ b/docs/content/tutorial/0-set-up-your-development-environment.md @@ -10,7 +10,7 @@ Welcome to Medusa - we are so excited to get you on board! This tutorial will walk you through the steps to take to set up your local development environment. You will familiarize yourself with some of the core parts that make Medusa work and learn how to configure your development environment. Furthermore you will be introduced to how the plugin architecture works and how to customize your commerce functionalities with custom logic and endpoints. -As a final part of the tutorial you will be linking your local project to Medusa Cloud where you can leverage advanced tools that make it easy to develop, test and deploy your Medusa project. + ## Background Knowledge and Prerequisites @@ -107,11 +107,11 @@ If you don't already have a text editor of choice you should find one you like - It is not important which editor you use as long as you feel comfortable working with it. -## Medusa Cloud account + ## Summary diff --git a/docs/content/tutorial/2-adding-custom-functionality.md b/docs/content/tutorial/2-adding-custom-functionality.md index 0aa78ae9d7..e04d84303a 100644 --- a/docs/content/tutorial/2-adding-custom-functionality.md +++ b/docs/content/tutorial/2-adding-custom-functionality.md @@ -56,7 +56,7 @@ In the constructor we specify that our `WelcomeService` will depend upon the `ca ### `registerOptin` -The `registerOption` function will take to arguments: `cartId` and `optin`, where `cartId` holds the id of the cart that we wish to register optin for and `optin` is a boolean to indicate if the customer has accepted or optin or not. We will save the `optin` preferences in the cart's `metadata` field, so that it can be persisted for the future when we need to evaluate if we should send the welcome or not. +The `registerOption` function will take two arguments: `cartId` and `optin`, where `cartId` holds the id of the cart that we wish to register optin for and `optin` is a boolean to indicate if the customer has accepted or optin or not. We will save the `optin` preferences in the cart's `metadata` field, so that it can be persisted for the future when we need to evaluate if we should send the welcome or not. ```javascript async registerOptin(cartId, optin) { @@ -252,4 +252,4 @@ You have now learned how to add custom functionality to your Medusa server, whic You have now been introduced to many of the key parts of Medusa and with your knowledge of customization you can now begin creating some really powerful commerce experiences. If you have an idea for a cool customization go ahead and make it right now! If you are not completely ready yet you can browse the reference docs further. -In the next part of this tutorial we will look into linking your local project with Medusa Cloud to make develpment smoother while leveraging the powerful management tools that merchants use to manage their Medusa store. + diff --git a/packages/medusa/yarn.lock b/packages/medusa/yarn.lock index e863d0abe7..82e2b85eb7 100644 --- a/packages/medusa/yarn.lock +++ b/packages/medusa/yarn.lock @@ -7647,9 +7647,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-fast-properties@^2.0.0: version "2.0.0" diff --git a/www/docs/sidebars.js b/www/docs/sidebars.js index 20ed1bbe4c..b8fadb9968 100644 --- a/www/docs/sidebars.js +++ b/www/docs/sidebars.js @@ -43,10 +43,10 @@ module.exports = { type: "doc", id: "tutorial/adding-custom-functionality", }, - { - type: "doc", - id: "tutorial/linking-your-local-project-with-medusa-cloud", - }, + // { + // type: "doc", + // id: "tutorial/linking-your-local-project-with-medusa-cloud", + // }, ], }, { @@ -75,6 +75,10 @@ module.exports = { type: "doc", id: "how-to/setting-up-a-nextjs-storefront-for-your-medusa-project", }, + { + type: "doc", + id: "how-to/create-medusa-app", + }, ], }, { @@ -85,6 +89,14 @@ module.exports = { type: "doc", id: "guides/fulfillment-api", }, + { + type: "doc", + id: "guides/checkouts", + }, + { + type: "doc", + id: "guides/carts-in-medusa", + }, ], }, ], From 58031b10ee762926d2855d0ba187f3955c9798d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Sep 2021 18:48:06 +0200 Subject: [PATCH 10/19] chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa-cli (#410) * docs: create-medusa-app article (#401) * fix: temporarily comment out cloud related docs (#387) * Fix typo in registerOptin section (#407) * docs: Carts in Medusa (#406) * chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa-cli Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5. - [Release notes](https://github.com/daaku/nodejs-tmpl/releases) - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) --- updated-dependencies: - dependency-name: tmpl dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Vadim Smirnov <62517920+FuzzyReason@users.noreply.github.com> Co-authored-by: ps-89 <91064940+ps-89@users.noreply.github.com> Co-authored-by: Sebastian Rindom Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/medusa-cli/yarn.lock | 40 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/medusa-cli/yarn.lock b/packages/medusa-cli/yarn.lock index 7719cbaeba..9354965bd6 100644 --- a/packages/medusa-cli/yarn.lock +++ b/packages/medusa-cli/yarn.lock @@ -2246,6 +2246,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + domexception@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" @@ -2870,6 +2875,14 @@ glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +global@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -4091,16 +4104,17 @@ medusa-core-utils@^0.1.27: "@hapi/joi" "^16.1.8" joi-objectid "^3.0.1" -medusa-telemetry@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/medusa-telemetry/-/medusa-telemetry-0.0.3.tgz#c11e5e0f3cc969f3eaee41d1c24f78a5c0715362" - integrity sha512-Qb/sgOwO8t2Sjjo4nKyBa6hKZ/SjniT4eEWenygEaJDqXZhfogVYGhWc5gn4tLlFFNEHXzDTlrqX2LvzfEJWIw== +medusa-telemetry@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/medusa-telemetry/-/medusa-telemetry-0.0.5.tgz#d7d08fca5cbecc0e853b4e0406194a92c5206caa" + integrity sha512-h7hP5Lc33OkFhMcvfrPcwINzMOuPoG8Vn8O6niKGFxF9RmmQnJgaAG1J43/Eq9ZWBrWi0n42XBttibKwCMViHw== dependencies: axios "^0.21.1" axios-retry "^3.1.9" boxen "^5.0.1" ci-info "^3.2.0" configstore "5.0.1" + global "^4.4.0" is-docker "^2.2.1" remove-trailing-slash "^0.1.1" uuid "^8.3.2" @@ -4167,6 +4181,13 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= + dependencies: + dom-walk "^0.1.0" + minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -4706,6 +4727,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -5561,9 +5587,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-fast-properties@^2.0.0: version "2.0.0" From 084adb72d85df3b90eff004a83569d9e2bafb19b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Sep 2021 18:48:19 +0200 Subject: [PATCH 11/19] chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa-core-utils (#409) * docs: create-medusa-app article (#401) * fix: temporarily comment out cloud related docs (#387) * Fix typo in registerOptin section (#407) * docs: Carts in Medusa (#406) * chore(deps): bump tmpl in /packages/medusa-core-utils Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5. - [Release notes](https://github.com/daaku/nodejs-tmpl/releases) - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) --- updated-dependencies: - dependency-name: tmpl dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Vadim Smirnov <62517920+FuzzyReason@users.noreply.github.com> Co-authored-by: ps-89 <91064940+ps-89@users.noreply.github.com> Co-authored-by: Sebastian Rindom Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/medusa-core-utils/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/medusa-core-utils/yarn.lock b/packages/medusa-core-utils/yarn.lock index 61a5d8896f..69bf2b4827 100644 --- a/packages/medusa-core-utils/yarn.lock +++ b/packages/medusa-core-utils/yarn.lock @@ -4938,9 +4938,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-fast-properties@^2.0.0: version "2.0.0" From 4db860f9ab6c9d3d68b578486901920e42a33ac6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Sep 2021 18:50:13 +0200 Subject: [PATCH 12/19] chore(deps): bump tmpl from 1.0.4 to 1.0.5 in /packages/medusa-test-utils (#408) * docs: create-medusa-app article (#401) * fix: temporarily comment out cloud related docs (#387) * Fix typo in registerOptin section (#407) * docs: Carts in Medusa (#406) * hotfix: Stripe cancelPayment catch block (#416) * chore(release): Publish - medusa-payment-stripe@1.1.26 * Fix typo in Plugins in Medusa doc (#414) * chore(deps): bump tmpl in /packages/medusa-test-utils Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5. - [Release notes](https://github.com/daaku/nodejs-tmpl/releases) - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) --- updated-dependencies: - dependency-name: tmpl dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Vadim Smirnov <62517920+FuzzyReason@users.noreply.github.com> Co-authored-by: ps-89 <91064940+ps-89@users.noreply.github.com> Co-authored-by: Sebastian Rindom Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: olivermrbl Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/content/how-to/plugins.md | 2 +- packages/medusa-payment-stripe/CHANGELOG.md | 8 +++ packages/medusa-payment-stripe/package.json | 2 +- .../src/services/stripe-provider.js | 2 +- packages/medusa-test-utils/yarn.lock | 59 ++++++++++++++++++- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/content/how-to/plugins.md b/docs/content/how-to/plugins.md index 83dd41d30c..8d8c837123 100644 --- a/docs/content/how-to/plugins.md +++ b/docs/content/how-to/plugins.md @@ -10,7 +10,7 @@ The purpose of this guide is to give an introduction to the structure of a plugi Plugins offer a way to extend and integrate the core functionality of Medusa. -In most commerce solutions, you can extend the basic features but it often comes with the expense of having to build standalone web applications. Our architecture is built such that plugins run within the same process as the core eliminating the need for extra server capacaity, infrastructure and maintenance. As a result, the plugins can use all other services as dependencies and access the database. +In most commerce solutions, you can extend the basic features but it often comes with the expense of having to build standalone web applications. Our architecture is built such that plugins run within the same process as the core eliminating the need for extra server capacity, infrastructure and maintenance. As a result, the plugins can use all other services as dependencies and access the database. > You will notice that plugins vary in naming. The name should signal what functionality they provide. diff --git a/packages/medusa-payment-stripe/CHANGELOG.md b/packages/medusa-payment-stripe/CHANGELOG.md index 688e73c8d9..a38c7a8a2e 100644 --- a/packages/medusa-payment-stripe/CHANGELOG.md +++ b/packages/medusa-payment-stripe/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.1.26](https://github.com/medusajs/medusa/compare/medusa-payment-stripe@1.1.25...medusa-payment-stripe@1.1.26) (2021-09-22) + +**Note:** Version bump only for package medusa-payment-stripe + + + + + ## [1.1.25](https://github.com/medusajs/medusa/compare/medusa-payment-stripe@1.1.24...medusa-payment-stripe@1.1.25) (2021-09-15) **Note:** Version bump only for package medusa-payment-stripe diff --git a/packages/medusa-payment-stripe/package.json b/packages/medusa-payment-stripe/package.json index afd846d38d..74ea0729eb 100644 --- a/packages/medusa-payment-stripe/package.json +++ b/packages/medusa-payment-stripe/package.json @@ -1,6 +1,6 @@ { "name": "medusa-payment-stripe", - "version": "1.1.25", + "version": "1.1.26", "description": "Stripe Payment provider for Meduas Commerce", "main": "index.js", "repository": { diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index da97bc9c01..781dabbcf4 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -333,7 +333,7 @@ class StripeProviderService extends PaymentService { async cancelPayment(payment) { const { id } = payment.data try { - return this.stripe_.paymentIntents.cancel(id) + return await this.stripe_.paymentIntents.cancel(id) } catch (error) { if (error.payment_intent.status === "canceled") { return error.payment_intent diff --git a/packages/medusa-test-utils/yarn.lock b/packages/medusa-test-utils/yarn.lock index b1d23c2262..80523f3644 100644 --- a/packages/medusa-test-utils/yarn.lock +++ b/packages/medusa-test-utils/yarn.lock @@ -857,6 +857,18 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@hapi/hoek@^9.0.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131" + integrity sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug== + +"@hapi/topo@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1058,6 +1070,23 @@ readdirp "^2.2.1" upath "^1.1.1" +"@sideway/address@^4.1.0": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.2.tgz#811b84333a335739d3969cfc434736268170cad1" + integrity sha512-idTz8ibqWFrPU8kMirL0CoPH/A29XOzzAzpyN3zQ4kAWnzmNfFmRaoMNN6VI8ske5M73HZyhIaW4OuSFIdM4oA== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinonjs/commons@^1.7.0": version "1.8.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b" @@ -3167,6 +3196,22 @@ jest@^25.5.2: import-local "^3.0.2" jest-cli "^25.5.4" +joi-objectid@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/joi-objectid/-/joi-objectid-3.0.1.tgz#63ace7860f8e1a993a28d40c40ffd8eff01a3668" + integrity sha512-V/3hbTlGpvJ03Me6DJbdBI08hBTasFOmipsauOsxOSnsF1blxV537WTl1zPwbfcKle4AK0Ma4OPnzMH4LlvTpQ== + +joi@^17.3.0: + version "17.4.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.2.tgz#02f4eb5cf88e515e614830239379dcbbe28ce7f7" + integrity sha512-Lm56PP+n0+Z2A2rfRvsfWVDXGEWjXxatPopkQ8qQ5mxCEhwHG+Ettgg5o98FFaxilOxozoa14cFhrE/hOzh/Nw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3379,6 +3424,14 @@ math-random@^1.0.1: resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== +medusa-core-utils@^1.1.22: + version "1.1.22" + resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.22.tgz#84ce0af0a7c672191d758ea462056e30a39d08b1" + integrity sha512-kMuRkWOuNG4Bw6epg/AYu95UJuE+rjHTeTWRLbEPrYGjWREV82tLWVDI21/QcccmaHmMU98Rkw2z9JwyFZIiyw== + dependencies: + joi "^17.3.0" + joi-objectid "^3.0.1" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -4516,9 +4569,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-fast-properties@^2.0.0: version "2.0.0" From 42cdfde6d936ce865a8a5c5818f69b6a7d04d62d Mon Sep 17 00:00:00 2001 From: pKorsholm <88927411+pKorsholm@users.noreply.github.com> Date: Wed, 29 Sep 2021 09:22:09 +0200 Subject: [PATCH 13/19] Feat/nested return reasons (#418) * api endpoints for nested return_reasons * add nested return reasons to database * add parent reason to update * integration tests * add children relation * integration tests for nested returns and failing doubly nesting returns * add delete-route and nested relations * delete return reason route * doubly nested return reason creation check and deletion * nested return reasons migration * list only parent reasons * removed null filter * remove empty migration * add return reason filter to get list of categories with children * removed console log * corrected delete route * return reason testing * return reasons query * listREasonsFromIDs * create return testing * listReasonsFromIds * return reason tests * failing if returnreason has child on return * integration tests * cascading deletes on return reasons * more elegant checking for children of return reasons when creating a return * remove console.log * pr adjust Co-authored-by: Philip Korsholm --- .../admin/__snapshots__/return-reason.js.snap | 16 + .../api/__tests__/admin/return-reason.js | 526 ++++++++++++++++-- .../api/__tests__/store/return-reason.js | 33 +- .../api/__tests__/store/returns.js | 108 ++-- .../admin/return-reasons/create-reason.js | 1 + .../admin/return-reasons/delete-reason.js | 41 ++ .../api/routes/admin/return-reasons/index.js | 11 +- .../admin/return-reasons/list-reasons.js | 2 +- .../admin/return-reasons/update-reason.js | 1 + .../api/routes/store/return-reasons/index.js | 6 +- .../store/return-reasons/list-reasons.js | 2 +- .../1631800727788-nested_return_reasons.ts | 20 + packages/medusa/src/models/return-reason.ts | 18 + packages/medusa/src/services/return-reason.js | 42 +- packages/medusa/src/services/return.js | 12 + 15 files changed, 744 insertions(+), 95 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/return-reason.js.snap create mode 100644 packages/medusa/src/api/routes/admin/return-reasons/delete-reason.js create mode 100644 packages/medusa/src/migrations/1631800727788-nested_return_reasons.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/return-reason.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/return-reason.js.snap new file mode 100644 index 0000000000..79d0282927 --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/return-reason.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/return-reasons POST /admin/return-reasons creates a return_reason 1`] = ` +Object { + "created_at": Any, + "deleted_at": null, + "description": "Use this if the size was too big", + "id": Any, + "label": "Too Big", + "parent_return_reason": null, + "parent_return_reason_id": null, + "return_reason_children": Array [], + "updated_at": Any, + "value": "too_big", +} +`; diff --git a/integration-tests/api/__tests__/admin/return-reason.js b/integration-tests/api/__tests__/admin/return-reason.js index 6255c3bfac..94e77e0745 100644 --- a/integration-tests/api/__tests__/admin/return-reason.js +++ b/integration-tests/api/__tests__/admin/return-reason.js @@ -1,53 +1,55 @@ -const path = require("path"); +const { match } = require("assert") +const path = require("path") +const { RepositoryNotTreeError } = require("typeorm") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") -const adminSeeder = require("../../helpers/admin-seeder"); +const adminSeeder = require("../../helpers/admin-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/return-reasons", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); + const db = useDb() + await db.shutdown() - medusaProcess.kill(); - }); + medusaProcess.kill() + }) describe("POST /admin/return-reasons", () => { beforeEach(async () => { try { - await adminSeeder(dbConnection); + await adminSeeder(dbConnection) } catch (err) { - console.log(err); - throw err; + console.log(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("creates a return_reason", async () => { - const api = useApi(); + const api = useApi() const payload = { label: "Too Big", description: "Use this if the size was too big", value: "too_big", - }; + } const response = await api .post("/admin/return-reasons", payload, { @@ -56,10 +58,172 @@ describe("/admin/return-reasons", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) + + expect(response.data.return_reason).toMatchSnapshot({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + parent_return_reason: null, + parent_return_reason_id: null, + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }) + }) + + it("creates a nested return reason", async () => { + const api = useApi() + + const payload = { + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + }) + ) + + const nested_payload = { + parent_return_reason_id: response.data.return_reason.id, + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + } + + const nested_response = await api + .post("/admin/return-reasons", nested_payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(nested_response.status).toEqual(200) + + expect(nested_response.data.return_reason).toEqual( + expect.objectContaining({ + parent_return_reason_id: response.data.return_reason.id, + + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }) + ) + }) + + it("fails to create a doubly nested return reason", async () => { + expect.assertions(5) + + const api = useApi() + + const payload = { + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + }) + ) + + const nested_payload = { + parent_return_reason_id: response.data.return_reason.id, + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + } + + const nested_response = await api + .post("/admin/return-reasons", nested_payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const dbl_nested_payload = { + parent_return_reason_id: nested_response.data.return_reason.id, + label: "Too large size", + description: "Use this if the size was too big", + value: "large_size", + } + + const dbl_nested_response = await api + .post("/admin/return-reasons", dbl_nested_payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.type).toEqual("invalid_data") + expect(err.response.data.message).toEqual( + "Doubly nested return reasons is not supported" + ) + }) + }) + + it("deletes a return_reason", async () => { + const api = useApi() + + const payload = { + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) expect(response.data.return_reason).toEqual( expect.objectContaining({ @@ -67,17 +231,37 @@ describe("/admin/return-reasons", () => { description: "Use this if the size was too big", value: "too_big", }) - ); - }); + ) + + const deleteResponse = await api + .delete(`/admin/return-reasons/${response.data.return_reason.id}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(deleteResponse.data).toEqual( + expect.objectContaining({ + id: response.data.return_reason.id, + object: "return_reason", + deleted: true, + }) + ) + }) it("update a return reason", async () => { - const api = useApi(); + const api = useApi() const payload = { label: "Too Big Typo", description: "Use this if the size was too big", value: "too_big", - }; + } const response = await api .post("/admin/return-reasons", payload, { @@ -86,10 +270,10 @@ describe("/admin/return-reasons", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.return_reason).toEqual( expect.objectContaining({ @@ -97,7 +281,7 @@ describe("/admin/return-reasons", () => { description: "Use this if the size was too big", value: "too_big", }) - ); + ) const newResponse = await api .post( @@ -113,8 +297,8 @@ describe("/admin/return-reasons", () => { } ) .catch((err) => { - console.log(err); - }); + console.log(err) + }) expect(newResponse.data.return_reason).toEqual( expect.objectContaining({ @@ -122,17 +306,81 @@ describe("/admin/return-reasons", () => { description: "new desc", value: "too_big", }) - ); - }); + ) + }) + + it("lists nested return reasons", async () => { + const api = useApi() + + const payload = { + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const nested_payload = { + parent_return_reason_id: response.data.return_reason.id, + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + } + + const resp = await api + .post("/admin/return-reasons", nested_payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const nested_response = await api + .get("/admin/return-reasons", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(nested_response.status).toEqual(200) + + expect(nested_response.data.return_reasons).toEqual([ + expect.objectContaining({ + label: "Wrong size", + description: "Use this if the size was too big", + value: "wrong_size", + return_reason_children: expect.arrayContaining([ + expect.objectContaining({ + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }), + ]), + }), + ]) + }) it("list return reasons", async () => { - const api = useApi(); + const api = useApi() const payload = { label: "Too Big Typo", description: "Use this if the size was too big", value: "too_big", - }; + } await api .post("/admin/return-reasons", payload, { @@ -141,8 +389,8 @@ describe("/admin/return-reasons", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) const response = await api .get("/admin/return-reasons", { @@ -151,15 +399,191 @@ describe("/admin/return-reasons", () => { }, }) .catch((err) => { - console.log(err); - }); + console.log(err) + }) - expect(response.status).toEqual(200); + expect(response.status).toEqual(200) expect(response.data.return_reasons).toEqual([ expect.objectContaining({ value: "too_big", }), - ]); - }); - }); -}); + ]) + }) + }) + + describe("DELETE /admin/return-reasons", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("deletes single return reason", async () => { + expect.assertions(6) + + const api = useApi() + + const payload = { + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }) + ) + + const deleteResult = await api.delete( + `/admin/return-reasons/${response.data.return_reason.id}`, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(deleteResult.status).toEqual(200) + + expect(deleteResult.data).toEqual({ + id: response.data.return_reason.id, + object: "return_reason", + deleted: true, + }) + + const getResult = await api + .get(`/admin/return-reasons/${response.data.return_reason.id}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + expect(err.response.status).toEqual(404) + expect(err.response.data.type).toEqual("not_found") + }) + }) + + it("deletes cascade through nested return reasons", async () => { + expect.assertions(10) + + const api = useApi() + + const payload = { + label: "Wrong Size", + description: "Use this if the size was wrong", + value: "wrong_size", + } + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Wrong Size", + description: "Use this if the size was wrong", + value: "wrong_size", + }) + ) + + const payload_child = { + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + parent_return_reason_id: response.data.return_reason.id, + } + + const response_child = await api + .post("/admin/return-reasons", payload_child, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response_child.status).toEqual(200) + + expect(response_child.data.return_reason).toEqual( + expect.objectContaining({ + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + parent_return_reason_id: response.data.return_reason.id, + }) + ) + + const deleteResult = await api + .delete(`/admin/return-reasons/${response.data.return_reason.id}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err.response.data) + }) + + expect(deleteResult.status).toEqual(200) + + expect(deleteResult.data).toEqual({ + id: response.data.return_reason.id, + object: "return_reason", + deleted: true, + }) + + await api + .get(`/admin/return-reasons/${response.data.return_reason.id}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + expect(err.response.status).toEqual(404) + expect(err.response.data.type).toEqual("not_found") + }) + + await api + .get(`/admin/return-reasons/${response_child.data.return_reason.id}`, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + expect(err.response.status).toEqual(404) + expect(err.response.data.type).toEqual("not_found") + }) + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/return-reason.js b/integration-tests/api/__tests__/store/return-reason.js index 4698fb86d6..8aa0c4c9d4 100644 --- a/integration-tests/api/__tests__/store/return-reason.js +++ b/integration-tests/api/__tests__/store/return-reason.js @@ -26,16 +26,35 @@ describe("/store/return-reasons", () => { describe("GET /store/return-reasons", () => { let rrId; + let rrId_1; + let rrId_2; beforeEach(async () => { try { const created = dbConnection.manager.create(ReturnReason, { - value: "too_big", - label: "Too Big", + value: "wrong_size", + label: "Wrong size", }); const result = await dbConnection.manager.save(created); rrId = result.id; + + const created_child = dbConnection.manager.create(ReturnReason, { + value: "too_big", + label: "Too Big", + parent_return_reason_id: rrId + }); + + const result_child = await dbConnection.manager.save(created_child); + rrId_1 = result_child.id; + + const created_2 = dbConnection.manager.create(ReturnReason, { + value: "too_big_1", + label: "Too Big 1", + }); + + const result_2 = await dbConnection.manager.save(created_2); + rrId_2 = result_2.id; } catch (err) { console.log(err); throw err; @@ -59,7 +78,15 @@ describe("/store/return-reasons", () => { expect(response.data.return_reasons).toEqual([ expect.objectContaining({ id: rrId, - value: "too_big", + value: "wrong_size", + return_reason_children:[expect.objectContaining({ + id: rrId_1, + value: "too_big", + }),] + }), + expect.objectContaining({ + id: rrId_2, + value: "too_big_1", }), ]); }); diff --git a/integration-tests/api/__tests__/store/returns.js b/integration-tests/api/__tests__/store/returns.js index f916572a52..3d5151dedb 100644 --- a/integration-tests/api/__tests__/store/returns.js +++ b/integration-tests/api/__tests__/store/returns.js @@ -8,6 +8,7 @@ const { Product, ProductVariant, ShippingOption, + FulfillmentProvider, LineItem, Discount, DiscountRule, @@ -37,6 +38,8 @@ describe("/store/carts", () => { describe("POST /store/returns", () => { let rrId; + let rrId_child; + let rrResult; beforeEach(async () => { const manager = dbConnection.manager; @@ -44,13 +47,17 @@ describe("/store/carts", () => { `ALTER SEQUENCE order_display_id_seq RESTART WITH 111` ); + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }); + await manager.insert(Region, { id: "region", name: "Test Region", currency_code: "usd", tax_rate: 0, }); - + await manager.insert(Customer, { id: "cus_1234", email: "test@email.com", @@ -98,10 +105,6 @@ describe("/store/carts", () => { await manager.save(ord); - const defaultProfile = await manager.findOne(ShippingProfile, { - type: "default", - }); - await manager.insert(Product, { id: "test-product", title: "test product", @@ -147,12 +150,23 @@ describe("/store/carts", () => { }); const created = dbConnection.manager.create(ReturnReason, { - value: "too_big", - label: "Too Big", + value: "wrong_size", + label: "Wrong Size", }); const result = await dbConnection.manager.save(created); + rrResult = result rrId = result.id; + + const created_1 = dbConnection.manager.create(ReturnReason, { + value: "too_big", + label: "Too Big", + parent_return_reason_id: rrId, + }); + + const result_1 = await dbConnection.manager.save(created_1); + + rrId_child = result_1.id; }); afterEach(async () => { @@ -181,6 +195,59 @@ describe("/store/carts", () => { expect(response.data.return.refund_amount).toEqual(8000); }); + it("failes to create a return with a reason category", async () => { + const api = useApi(); + + const response = await api + .post("/store/returns", { + order_id: "order_test", + items: [ + { + reason_id: rrId, + note: "TOO small", + item_id: "test-item", + quantity: 1, + }, + ], + }) + .catch((err) => { + return err.response; + }); + + expect(response.status).toEqual(400); + expect(response.data.message).toEqual('Cannot apply return reason category') + + }); + + it("creates a return with reasons", async () => { + const api = useApi(); + + const response = await api + .post("/store/returns", { + order_id: "order_test", + items: [ + { + reason_id: rrId_child, + note: "TOO small", + item_id: "test-item", + quantity: 1, + }, + ], + }) + .catch((err) => { + console.log(err.response) + return err.response; + }); + expect(response.status).toEqual(200); + + expect(response.data.return.items).toEqual([ + expect.objectContaining({ + reason_id: rrId_child, + note: "TOO small", + }), + ]); + }); + it("creates a return with discount and non-discountable item", async () => { const api = useApi(); @@ -234,32 +301,5 @@ describe("/store/carts", () => { expect(response.data.return.refund_amount).toEqual(7000); }); - it("creates a return with reasons", async () => { - const api = useApi(); - - const response = await api - .post("/store/returns", { - order_id: "order_test", - items: [ - { - reason_id: rrId, - note: "TOO small", - item_id: "test-item", - quantity: 1, - }, - ], - }) - .catch((err) => { - return err.response; - }); - expect(response.status).toEqual(200); - - expect(response.data.return.items).toEqual([ - expect.objectContaining({ - reason_id: rrId, - note: "TOO small", - }), - ]); - }); }); }); diff --git a/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js index 42b9eb8760..d089865088 100644 --- a/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js +++ b/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js @@ -39,6 +39,7 @@ export default async (req, res) => { const schema = Validator.object().keys({ value: Validator.string().required(), label: Validator.string().required(), + parent_return_reason_id: Validator.string().optional(), description: Validator.string() .optional() .allow(""), diff --git a/packages/medusa/src/api/routes/admin/return-reasons/delete-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/delete-reason.js new file mode 100644 index 0000000000..9688d69205 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/delete-reason.js @@ -0,0 +1,41 @@ +/** + * @oas [delete] /return-reason/{id} + * operationId: "DeleteReturnReason" + * summary: "Delete a return reason" + * description: "Deletes a return reason." + * parameters: + * - (path) id=* {string} The id of the return reason + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The id of the deleted return reason + * object: + * type: string + * description: The type of the object that was deleted. + * deleted: + * type: boolean + */ +export default async (req, res) => { + const { id } = req.params + + try { + const returnReasonService = req.scope.resolve("returnReasonService") + await returnReasonService.delete(id) + + res.json({ + id: id, + object: "return_reason", + deleted: true, + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/return-reasons/index.js b/packages/medusa/src/api/routes/admin/return-reasons/index.js index 7ad111137f..b535b488f5 100644 --- a/packages/medusa/src/api/routes/admin/return-reasons/index.js +++ b/packages/medusa/src/api/routes/admin/return-reasons/index.js @@ -26,6 +26,11 @@ export default app => { */ route.post("/:id", middlewares.wrap(require("./update-reason").default)) + /** + * Delete a reason + */ + route.delete("/:id", middlewares.wrap(require("./delete-reason").default)) + return app } @@ -33,10 +38,14 @@ export const defaultFields = [ "id", "value", "label", + "parent_return_reason_id", "description", "created_at", "updated_at", "deleted_at", ] -export const defaultRelations = [] +export const defaultRelations = [ + "parent_return_reason", + "return_reason_children", +] diff --git a/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js b/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js index 367a2fb651..2c1f8664ed 100644 --- a/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js +++ b/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js @@ -24,7 +24,7 @@ export default async (req, res) => { try { const returnReasonService = req.scope.resolve("returnReasonService") - const query = {} + const query = { parent_return_reason_id: null } const data = await returnReasonService.list(query, { select: defaultFields, relations: defaultRelations, diff --git a/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js index 82d933c406..398c661a9d 100644 --- a/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js +++ b/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js @@ -42,6 +42,7 @@ export default async (req, res) => { const schema = Validator.object().keys({ label: Validator.string().optional(), + parent_return_reason_id: Validator.string().optional(), description: Validator.string() .optional() .allow(""), diff --git a/packages/medusa/src/api/routes/store/return-reasons/index.js b/packages/medusa/src/api/routes/store/return-reasons/index.js index 4590bb5c6a..071d27e8cc 100644 --- a/packages/medusa/src/api/routes/store/return-reasons/index.js +++ b/packages/medusa/src/api/routes/store/return-reasons/index.js @@ -23,10 +23,14 @@ export const defaultFields = [ "id", "value", "label", + "parent_return_reason_id", "description", "created_at", "updated_at", "deleted_at", ] -export const defaultRelations = [] +export const defaultRelations = [ + "parent_return_reason", + "return_reason_children", +] diff --git a/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js b/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js index 367a2fb651..563e5a893b 100644 --- a/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js +++ b/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js @@ -24,7 +24,7 @@ export default async (req, res) => { try { const returnReasonService = req.scope.resolve("returnReasonService") - const query = {} + const query = { parent_return_reason_id: null} const data = await returnReasonService.list(query, { select: defaultFields, relations: defaultRelations, diff --git a/packages/medusa/src/migrations/1631800727788-nested_return_reasons.ts b/packages/medusa/src/migrations/1631800727788-nested_return_reasons.ts new file mode 100644 index 0000000000..c50c957e09 --- /dev/null +++ b/packages/medusa/src/migrations/1631800727788-nested_return_reasons.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class nestedReturnReasons1631800727788 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "return_reason" ADD "parent_return_reason_id" character varying` + + ) + await queryRunner.query(`ALTER TABLE "return_reason" ADD CONSTRAINT "FK_2250c5d9e975987ab212f61a657" FOREIGN KEY ("parent_return_reason_id") REFERENCES "return_reason"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "return_reason" DROP COLUMN "parent_return_reason_id"` + ) + + } + +} diff --git a/packages/medusa/src/models/return-reason.ts b/packages/medusa/src/models/return-reason.ts index 6846e0da27..9cbfb47a52 100644 --- a/packages/medusa/src/models/return-reason.ts +++ b/packages/medusa/src/models/return-reason.ts @@ -7,6 +7,9 @@ import { CreateDateColumn, UpdateDateColumn, PrimaryColumn, + ManyToOne, + OneToMany, + JoinColumn } from "typeorm" import { ulid } from "ulid" import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column" @@ -26,6 +29,21 @@ export class ReturnReason { @Column({ nullable: true }) description: string + @Column({ nullable: true }) + parent_return_reason_id: string + + @ManyToOne(() => ReturnReason, {cascade: ['soft-remove']} + ) + @JoinColumn({ name: "parent_return_reason_id" }) + parent_return_reason: ReturnReason + + @OneToMany( + () => ReturnReason, + return_reason => return_reason.parent_return_reason, + { cascade: ["insert", 'soft-remove'] } + ) + return_reason_children: ReturnReason[] + @CreateDateColumn({ type: resolveDbType("timestamptz") }) created_at: Date diff --git a/packages/medusa/src/services/return-reason.js b/packages/medusa/src/services/return-reason.js index 34784c78e9..0a25556a79 100644 --- a/packages/medusa/src/services/return-reason.js +++ b/packages/medusa/src/services/return-reason.js @@ -1,6 +1,6 @@ -import _ from "lodash" import { Validator, MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" +import { In } from "typeorm" class ReturnReasonService extends BaseService { constructor({ manager, returnReasonRepository }) { @@ -32,6 +32,17 @@ class ReturnReasonService extends BaseService { return this.atomicPhase_(async manager => { const rrRepo = manager.getCustomRepository(this.retReasonRepo_) + if (data.parent_return_reason_id && data.parent_return_reason_id !== "") { + const parentReason = await this.retrieve(data.parent_return_reason_id) + + if (parentReason.parent_return_reason_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Doubly nested return reasons is not supported" + ) + } + } + const created = rrRepo.create(data) const result = await rrRepo.save(created) @@ -44,14 +55,20 @@ class ReturnReasonService extends BaseService { const rrRepo = manager.getCustomRepository(this.retReasonRepo_) const reason = await this.retrieve(id) - if ("description" in data) { + const { description, label, parent_return_reason_id } = data + + if (description) { reason.description = data.description } - if ("label" in data) { + if (label) { reason.label = data.label } + if (parent_return_reason_id) { + reason.parent_return_reason_id = parent_return_reason_id + } + await rrRepo.save(reason) return reason @@ -92,6 +109,25 @@ class ReturnReasonService extends BaseService { return item } + + async delete(returnReasonId) { + return this.atomicPhase_(async manager => { + const rrRepo = manager.getCustomRepository(this.retReasonRepo_) + + // We include the relation 'return_reason_children' to enable cascading deletes of return reasons if a parent is removed + const reason = await this.retrieve(returnReasonId, { + relations: ["return_reason_children"], + }) + + if (!reason) { + return Promise.resolve() + } + + await rrRepo.softRemove(reason) + + return Promise.resolve() + }) + } } export default ReturnReasonService diff --git a/packages/medusa/src/services/return.js b/packages/medusa/src/services/return.js index 23bbca9e08..0092e0e5c7 100644 --- a/packages/medusa/src/services/return.js +++ b/packages/medusa/src/services/return.js @@ -374,6 +374,18 @@ class ReturnService extends BaseService { refund_amount: Math.floor(toRefund), } + const returnReasons = await this.returnReasonService_.list( + { id: [...returnLines.map(rl => rl.reason_id)] }, + { relations: ["return_reason_children"] } + ) + + if (returnReasons.some(rr => rr.return_reason_children?.length > 0)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot apply return reason category" + ) + } + const rItemRepo = manager.getCustomRepository(this.returnItemRepository_) returnObject.items = returnLines.map(i => rItemRepo.create({ From d8e531890d758e374fdd586f8b718d9b09126320 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 29 Sep 2021 14:07:39 +0200 Subject: [PATCH 14/19] fix: update seeder to product published (#423) --- packages/medusa/src/commands/seed.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/medusa/src/commands/seed.js b/packages/medusa/src/commands/seed.js index df92b2ff40..2d2c329e83 100644 --- a/packages/medusa/src/commands/seed.js +++ b/packages/medusa/src/commands/seed.js @@ -114,6 +114,9 @@ const t = async function({ directory, migrate, seedFile }) { const variants = p.variants delete p.variants + // default to the products being visible + p.status = p.status || "published" + p.profile_id = defaultProfile.id if (p.is_giftcard) { p.profile_id = gcProfile.id From ae0ab03fac2747ec251abbb6161182dd21218ba6 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Wed, 29 Sep 2021 15:20:14 +0200 Subject: [PATCH 15/19] fix: Creating shipping options with requirements (#428) --- .../api/__tests__/admin/shipping-options.js | 327 +++++++++++------- .../api/helpers/shipping-option-seeder.js | 59 ++++ .../src/services/__tests__/shipping-option.js | 6 +- .../medusa/src/services/shipping-option.js | 39 ++- 4 files changed, 300 insertions(+), 131 deletions(-) create mode 100644 integration-tests/api/helpers/shipping-option-seeder.js diff --git a/integration-tests/api/__tests__/admin/shipping-options.js b/integration-tests/api/__tests__/admin/shipping-options.js index e9645f20ae..4b0f99f8d5 100644 --- a/integration-tests/api/__tests__/admin/shipping-options.js +++ b/integration-tests/api/__tests__/admin/shipping-options.js @@ -1,102 +1,53 @@ -const path = require("path"); +const path = require("path") const { Region, ShippingProfile, ShippingOption, ShippingOptionRequirement, -} = require("@medusajs/medusa"); +} = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server"); -const { useApi } = require("../../../helpers/use-api"); -const { initDb, useDb } = require("../../../helpers/use-db"); -const adminSeeder = require("../../helpers/admin-seeder"); +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const adminSeeder = require("../../helpers/admin-seeder") +const shippingOptionSeeder = require("../../helpers/shipping-option-seeder") -jest.setTimeout(30000); +jest.setTimeout(30000) describe("/admin/shipping-options", () => { - let medusaProcess; - let dbConnection; + let medusaProcess + let dbConnection beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")); - dbConnection = await initDb({ cwd }); - medusaProcess = await setupServer({ cwd }); - }); + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) afterAll(async () => { - const db = useDb(); - await db.shutdown(); - medusaProcess.kill(); - }); + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) - describe("POST /admin/shipping-options", () => { + describe("POST /admin/shipping-options/:id", () => { beforeEach(async () => { - const manager = dbConnection.manager; - try { - await adminSeeder(dbConnection); - - await manager.insert(Region, { - id: "region", - name: "Test Region", - currency_code: "usd", - tax_rate: 0, - }); - - const defaultProfile = await manager.findOne(ShippingProfile, { - type: "default", - }); - - await manager.insert(ShippingOption, { - id: "test-out", - name: "Test out", - profile_id: defaultProfile.id, - region_id: "region", - provider_id: "test-ful", - data: {}, - price_type: "flat_rate", - amount: 2000, - is_return: false, - }); - - await manager.insert(ShippingOption, { - id: "test-option-req", - name: "With req", - profile_id: defaultProfile.id, - region_id: "region", - provider_id: "test-ful", - data: {}, - price_type: "flat_rate", - amount: 2000, - is_return: false, - }); - - await manager.insert(ShippingOptionRequirement, { - id: "option-req", - shipping_option_id: "test-option-req", - type: "min_subtotal", - amount: 5, - }); - - await manager.insert(ShippingOptionRequirement, { - id: "option-req-2", - shipping_option_id: "test-option-req", - type: "max_subtotal", - amount: 10, - }); + await adminSeeder(dbConnection) + await shippingOptionSeeder(dbConnection) } catch (err) { - console.error(err); - throw err; + console.error(err) + throw err } - }); + }) afterEach(async () => { - const db = useDb(); - await db.teardown(); - }); + const db = useDb() + await db.teardown() + }) it("updates a shipping option with no existing requirements", async () => { - const api = useApi(); + const api = useApi() const payload = { name: "Test option", @@ -111,36 +62,36 @@ describe("/admin/shipping-options", () => { amount: 2, }, ], - }; + } const res = await api.post(`/admin/shipping-options/test-out`, payload, { headers: { Authorization: "Bearer test_token", }, - }); + }) - const requirements = res.data.shipping_option.requirements; + const requirements = res.data.shipping_option.requirements - expect(res.status).toEqual(200); - expect(requirements.length).toEqual(2); + expect(res.status).toEqual(200) + expect(requirements.length).toEqual(2) expect(requirements[0]).toEqual( expect.objectContaining({ type: "min_subtotal", shipping_option_id: "test-out", amount: 1, }) - ); + ) expect(requirements[1]).toEqual( expect.objectContaining({ type: "max_subtotal", shipping_option_id: "test-out", amount: 2, }) - ); - }); + ) + }) it("fails as it is not allowed to set id from client side", async () => { - const api = useApi(); + const api = useApi() const payload = { name: "Test option", @@ -157,7 +108,7 @@ describe("/admin/shipping-options", () => { amount: 2, }, ], - }; + } const res = await api .post(`/admin/shipping-options/test-out`, payload, { @@ -166,15 +117,15 @@ describe("/admin/shipping-options", () => { }, }) .catch((err) => { - return err.response; - }); + return err.response + }) - expect(res.status).toEqual(400); - expect(res.data.message).toEqual("ID does not exist"); - }); + expect(res.status).toEqual(400) + expect(res.data.message).toEqual("ID does not exist") + }) it("it succesfully updates a set of existing requirements", async () => { - const api = useApi(); + const api = useApi() const payload = { requirements: [ @@ -190,7 +141,7 @@ describe("/admin/shipping-options", () => { }, ], amount: 200, - }; + } const res = await api .post(`/admin/shipping-options/test-option-req`, payload, { @@ -199,14 +150,14 @@ describe("/admin/shipping-options", () => { }, }) .catch((err) => { - console.log(err.response.data.message); - }); + console.log(err.response.data.message) + }) - expect(res.status).toEqual(200); - }); + expect(res.status).toEqual(200) + }) it("it succesfully updates a set of existing requirements by updating one and deleting the other", async () => { - const api = useApi(); + const api = useApi() const payload = { requirements: [ @@ -216,7 +167,7 @@ describe("/admin/shipping-options", () => { amount: 15, }, ], - }; + } const res = await api .post(`/admin/shipping-options/test-option-req`, payload, { @@ -225,14 +176,14 @@ describe("/admin/shipping-options", () => { }, }) .catch((err) => { - console.log(err.response.data.message); - }); + console.log(err.response.data.message) + }) - expect(res.status).toEqual(200); - }); + expect(res.status).toEqual(200) + }) it("succesfully updates a set of requirements because max. subtotal >= min. subtotal", async () => { - const api = useApi(); + const api = useApi() const payload = { requirements: [ @@ -247,7 +198,7 @@ describe("/admin/shipping-options", () => { amount: 200, }, ], - }; + } const res = await api .post(`/admin/shipping-options/test-option-req`, payload, { @@ -256,16 +207,16 @@ describe("/admin/shipping-options", () => { }, }) .catch((err) => { - console.log(err.response.data.message); - }); + console.log(err.response.data.message) + }) - expect(res.status).toEqual(200); - expect(res.data.shipping_option.requirements[0].amount).toEqual(150); - expect(res.data.shipping_option.requirements[1].amount).toEqual(200); - }); + expect(res.status).toEqual(200) + expect(res.data.shipping_option.requirements[0].amount).toEqual(150) + expect(res.data.shipping_option.requirements[1].amount).toEqual(200) + }) it("fails to updates a set of requirements because max. subtotal <= min. subtotal", async () => { - const api = useApi(); + const api = useApi() const payload = { requirements: [ @@ -280,7 +231,7 @@ describe("/admin/shipping-options", () => { amount: 200, }, ], - }; + } const res = await api .post(`/admin/shipping-options/test-option-req`, payload, { @@ -289,13 +240,147 @@ describe("/admin/shipping-options", () => { }, }) .catch((err) => { - return err.response; - }); + return err.response + }) - expect(res.status).toEqual(400); + expect(res.status).toEqual(400) expect(res.data.message).toEqual( "Max. subtotal must be greater than Min. subtotal" - ); - }); - }); -}); + ) + }) + }) + + describe("POST /admin/shipping-options", () => { + let payload + + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await shippingOptionSeeder(dbConnection) + + const api = useApi() + await api.post( + `/admin/regions/region`, + { + fulfillment_providers: ["test-ful"], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + const manager = dbConnection.manager + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }) + + payload = { + name: "Test option", + amount: 100, + price_type: "flat_rate", + region_id: "region", + provider_id: "test-ful", + data: {}, + profile_id: defaultProfile.id, + } + } catch (err) { + console.error(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("creates a shipping option with requirements", async () => { + const api = useApi() + payload.requirements = [ + { + type: "max_subtotal", + amount: 2, + }, + { + type: "min_subtotal", + amount: 1, + }, + ] + + const res = await api.post(`/admin/shipping-options`, payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(res.status).toEqual(200) + expect(res.data.shipping_option.requirements.length).toEqual(2) + }) + + it("creates a shipping option with no requirements", async () => { + const api = useApi() + const res = await api.post(`/admin/shipping-options`, payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(res.status).toEqual(200) + expect(res.data.shipping_option.requirements.length).toEqual(0) + }) + + it("fails on same requirement types", async () => { + const api = useApi() + payload.requirements = [ + { + type: "max_subtotal", + amount: 2, + }, + { + type: "max_subtotal", + amount: 1, + }, + ] + + try { + await api.post(`/admin/shipping-options`, payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + } catch (error) { + expect(error.response.data.message).toEqual( + "Only one requirement of each type is allowed" + ) + } + }) + + it("fails when min_subtotal > max_subtotal", async () => { + const api = useApi() + payload.requirements = [ + { + type: "max_subtotal", + amount: 2, + }, + { + type: "min_subtotal", + amount: 4, + }, + ] + + try { + await api.post(`/admin/shipping-options`, payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + } catch (error) { + expect(error.response.data.message).toEqual( + "Max. subtotal must be greater than Min. subtotal" + ) + } + }) + }) +}) diff --git a/integration-tests/api/helpers/shipping-option-seeder.js b/integration-tests/api/helpers/shipping-option-seeder.js new file mode 100644 index 0000000000..4048169109 --- /dev/null +++ b/integration-tests/api/helpers/shipping-option-seeder.js @@ -0,0 +1,59 @@ +const { + Region, + ShippingProfile, + ShippingOption, + ShippingOptionRequirement, +} = require("@medusajs/medusa") + +module.exports = async (connection, data = {}) => { + const manager = connection.manager + + await manager.insert(Region, { + id: "region", + name: "Test Region", + currency_code: "usd", + tax_rate: 0, + }) + + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }) + + await manager.insert(ShippingOption, { + id: "test-out", + name: "Test out", + profile_id: defaultProfile.id, + region_id: "region", + provider_id: "test-ful", + data: {}, + price_type: "flat_rate", + amount: 2000, + is_return: false, + }) + + await manager.insert(ShippingOption, { + id: "test-option-req", + name: "With req", + profile_id: defaultProfile.id, + region_id: "region", + provider_id: "test-ful", + data: {}, + price_type: "flat_rate", + amount: 2000, + is_return: false, + }) + + await manager.insert(ShippingOptionRequirement, { + id: "option-req", + shipping_option_id: "test-option-req", + type: "min_subtotal", + amount: 5, + }) + + await manager.insert(ShippingOptionRequirement, { + id: "option-req-2", + shipping_option_id: "test-option-req", + type: "max_subtotal", + amount: 10, + }) +} diff --git a/packages/medusa/src/services/__tests__/shipping-option.js b/packages/medusa/src/services/__tests__/shipping-option.js index d1c84d241c..e72c4d74b3 100644 --- a/packages/medusa/src/services/__tests__/shipping-option.js +++ b/packages/medusa/src/services/__tests__/shipping-option.js @@ -272,11 +272,7 @@ describe("ShippingOptionService", () => { amount: 10, }) - expect(shippingOptionRequirementRepository.create).toBeCalledTimes(1) - expect(shippingOptionRequirementRepository.create).toBeCalledWith({ - type: "max_subtotal", - amount: 10, - }) + expect(shippingOptionRequirementRepository.create).toBeCalledTimes(0) expect(shippingOptionRepository.save).toBeCalledTimes(1) expect(shippingOptionRepository.save).toBeCalledWith({ diff --git a/packages/medusa/src/services/shipping-option.js b/packages/medusa/src/services/shipping-option.js index e78d41f759..c745bfbfb1 100644 --- a/packages/medusa/src/services/shipping-option.js +++ b/packages/medusa/src/services/shipping-option.js @@ -96,6 +96,13 @@ class ShippingOptionService extends BaseService { throw new MedusaError(MedusaError.Types.INVALID_DATA, "ID does not exist") } + // If no option id is provided, we are currently in the process of creating + // a new shipping option. Therefore, simply return the requirement, such + // that the cascading will take care of the creation of the requirement. + if (!optionId) { + return requirement + } + let req if (existingReq) { req = await reqRepo.save({ @@ -359,11 +366,33 @@ class ShippingOptionService extends BaseService { } if ("requirements" in data) { - option.requirements = await Promise.all( - data.requirements.map(r => { - return this.validateRequirement_(r, option.id) - }) - ) + const acc = [] + for (const r of data.requirements) { + const validated = await this.validateRequirement_(r) + + if (acc.find(raw => raw.type === validated.type)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Only one requirement of each type is allowed" + ) + } + + if ( + acc.find( + raw => + (raw.type === "max_subtotal" && + validated.amount > raw.amount) || + (raw.type === "min_subtotal" && validated.amount < raw.amount) + ) + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Max. subtotal must be greater than Min. subtotal" + ) + } + + acc.push(validated) + } } const result = await optionRepo.save(option) From 9b64828ec36f0ed6826ba9dc5be667536bb0a559 Mon Sep 17 00:00:00 2001 From: pKorsholm <88927411+pKorsholm@users.noreply.github.com> Date: Thu, 30 Sep 2021 12:13:59 +0200 Subject: [PATCH 16/19] Feat:discount expiration date (#403) * discount expiration validation and testing * integration testing * double quotes * add iso8601 package * api testing * add joi validation of start and end dates as well as valid_duration * valid_duration column * service testing * discount validation in services * integration test with invalid date interval * include valid_duration when fetching a discount * rename variable for clarity * add test for dynamic discount with expiration date * remove debug code * adjust tests to reflect valid_duration being included in default fields * additional discount update validation * fixed failing test * set ends_at on dynamic discount creation * discount integration tests * removed unused console.log * removed validation of dynamic discounts by duration and added ends_at to dynamic discount creation * integration tests for dynamic discount with and without duration * optional valid duration for dynamic discounts * allow nullable dynamic discount durations * expect assertions * fix unit test after change to dynamic discounts without duration * change to date instead of string * add assertions * error handling * addressed feedback --- .../api/__tests__/admin/discount.js | 238 +++++++++++++++++- integration-tests/api/__tests__/store/cart.js | 73 +++++- integration-tests/api/helpers/cart-seeder.js | 105 ++++++++ integration-tests/setup.js | 16 +- packages/medusa/package.json | 1 + .../admin/discounts/__tests__/add-region.js | 1 + .../discounts/__tests__/add-valid-product.js | 1 + .../discounts/__tests__/create-discount.js | 169 +++++++++++++ .../admin/discounts/__tests__/get-discount.js | 1 + .../discounts/__tests__/remove-region.js | 1 + .../__tests__/remove-valid-product.js | 1 + .../discounts/__tests__/update-discount.js | 136 ++++++++++ .../routes/admin/discounts/create-discount.js | 8 +- .../admin/discounts/create-dynamic-code.js | 4 +- .../src/api/routes/admin/discounts/index.js | 1 + .../routes/admin/discounts/update-discount.js | 12 +- ...31696624528-valid_duration_for_discount.ts | 14 ++ packages/medusa/src/models/discount.ts | 5 +- .../medusa/src/services/__tests__/cart.js | 139 ++++++++++ .../medusa/src/services/__tests__/discount.js | 71 ++++++ packages/medusa/src/services/cart.js | 15 ++ packages/medusa/src/services/discount.js | 18 ++ 22 files changed, 1009 insertions(+), 21 deletions(-) create mode 100644 packages/medusa/src/migrations/1631696624528-valid_duration_for_discount.ts diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index cbc124ce53..b381ff9ab9 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -158,6 +158,193 @@ describe("/admin/discounts", () => { }) ) }) + + it("creates a discount with start and end dates", async () => { + const api = useApi() + + const response = await api + .post( + "/admin/discounts", + { + code: "HELLOWORLD", + rule: { + description: "test", + type: "percentage", + value: 10, + allocation: "total", + }, + usage_limit: 10, + starts_at: new Date("09/15/2021 11:50"), + ends_at: new Date("09/15/2021 17:50"), + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.discount).toEqual( + expect.objectContaining({ + code: "HELLOWORLD", + usage_limit: 10, + starts_at: expect.any(String), + ends_at: expect.any(String), + }) + ) + + expect(new Date(response.data.discount.starts_at)).toEqual( + new Date("09/15/2021 11:50") + ) + + expect(new Date(response.data.discount.ends_at)).toEqual( + new Date("09/15/2021 17:50") + ) + + const updated = await api + .post( + `/admin/discounts/${response.data.discount.id}`, + { + usage_limit: 20, + starts_at: new Date("09/14/2021 11:50"), + ends_at: new Date("09/17/2021 17:50"), + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(updated.status).toEqual(200) + expect(updated.data.discount).toEqual( + expect.objectContaining({ + code: "HELLOWORLD", + usage_limit: 20, + starts_at: expect.any(String), + ends_at: expect.any(String), + }) + ) + + expect(new Date(updated.data.discount.starts_at)).toEqual( + new Date("09/14/2021 11:50") + ) + + expect(new Date(updated.data.discount.ends_at)).toEqual( + new Date("09/17/2021 17:50") + ) + }) + + it("fails to update end date to a date before start date", async () => { + expect.assertions(6) + + const api = useApi() + + const response = await api + .post( + "/admin/discounts", + { + code: "HELLOWORLD", + rule: { + description: "test", + type: "percentage", + value: 10, + allocation: "total", + }, + usage_limit: 10, + starts_at: new Date("09/15/2021 11:50"), + ends_at: new Date("09/15/2021 17:50"), + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.discount).toEqual( + expect.objectContaining({ + code: "HELLOWORLD", + usage_limit: 10, + starts_at: expect.any(String), + ends_at: expect.any(String), + }) + ) + + expect(new Date(response.data.discount.starts_at)).toEqual( + new Date("09/15/2021 11:50") + ) + + expect(new Date(response.data.discount.ends_at)).toEqual( + new Date("09/15/2021 17:50") + ) + + await api + .post( + `/admin/discounts/${response.data.discount.id}`, + { + usage_limit: 20, + ends_at: new Date("09/11/2021 17:50"), + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toEqual( + `"ends_at" must be greater than "starts_at"` + ) + }) + }) + + it("fails to create discount with end date before start date", async () => { + expect.assertions(2) + const api = useApi() + + const response = await api + .post( + "/admin/discounts", + { + code: "HELLOWORLD", + rule: { + description: "test", + type: "percentage", + value: 10, + allocation: "total", + }, + usage_limit: 10, + starts_at: new Date("09/15/2021 11:50"), + ends_at: new Date("09/14/2021 17:50"), + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toEqual([ + expect.objectContaining({ + message: `"ends_at" must be greater than "ref:starts_at"`, + }), + ]) + }) + }) }) describe("testing for soft-deletion + uniqueness on discount codes", () => { @@ -286,6 +473,21 @@ describe("/admin/discounts", () => { is_dynamic: true, is_disabled: false, rule_id: "test-discount-rule", + valid_duration: "P2Y", + }) + await manager.insert(DiscountRule, { + id: "test-discount-rule1", + description: "Dynamic rule", + type: "percentage", + value: 10, + allocation: "total", + }) + await manager.insert(Discount, { + id: "test-discount1", + code: "DYNAMICCode", + is_dynamic: true, + is_disabled: false, + rule_id: "test-discount-rule1", }) } catch (err) { console.log(err) @@ -298,7 +500,7 @@ describe("/admin/discounts", () => { await db.teardown() }) - it("creates a dynamic discount", async () => { + it("creates a dynamic discount with ends_at", async () => { const api = useApi() const response = await api @@ -318,6 +520,40 @@ describe("/admin/discounts", () => { }) expect(response.status).toEqual(200) + expect(response.data.discount).toEqual( + expect.objectContaining({ + code: "HELLOWORLD", + ends_at: expect.any(String), + }) + ) + }) + + it("creates a dynamic discount without ends_at", async () => { + const api = useApi() + + const response = await api + .post( + "/admin/discounts/test-discount1/dynamic-codes", + { + code: "HELLOWORLD", + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + // console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.discount).toEqual( + expect.objectContaining({ + code: "HELLOWORLD", + ends_at: null, + }) + ) }) }) }) diff --git a/integration-tests/api/__tests__/store/cart.js b/integration-tests/api/__tests__/store/cart.js index 215f77d194..034601e839 100644 --- a/integration-tests/api/__tests__/store/cart.js +++ b/integration-tests/api/__tests__/store/cart.js @@ -131,6 +131,7 @@ describe("/store/carts", () => { }) it("fails on apply discount if limit has been reached", async () => { + expect.assertions(2) const api = useApi() try { @@ -145,6 +146,62 @@ describe("/store/carts", () => { } }) + it("fails to apply expired discount", async () => { + expect.assertions(2) + const api = useApi() + + try { + await api.post("/store/carts/test-cart", { + discounts: [{ code: "EXP_DISC" }], + }) + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual("Discount is expired") + } + }) + + it("fails on discount before start day", async () => { + expect.assertions(2) + const api = useApi() + + try { + await api.post("/store/carts/test-cart", { + discounts: [{ code: "PREM_DISC" }], + }) + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual("Discount is not valid yet") + } + }) + + it("fails on apply invalid dynamic discount", async () => { + const api = useApi() + + try { + await api.post("/store/carts/test-cart", { + discounts: [{ code: "INV_DYN_DISC" }], + }) + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual("Discount is expired") + } + }) + + it("Applies dynamic discount to cart correctly", async () => { + const api = useApi() + + const cart = await api.post( + "/store/carts/test-cart", + { + discounts: [{ code: "DYN_DISC" }], + }, + { withCredentials: true } + ) + + expect(cart.data.cart.shipping_total).toBe(1000) + expect(cart.status).toEqual(200) + }) + it("updates cart customer id", async () => { const api = useApi() @@ -425,13 +482,15 @@ describe("/store/carts", () => { ) // Add a 10% discount to the cart - const cartWithGiftcard = await api.post( - "/store/carts/test-cart", - { - discounts: [{ code: "10PERCENT" }], - }, - { withCredentials: true } - ) + const cartWithGiftcard = await api + .post( + "/store/carts/test-cart", + { + discounts: [{ code: "10PERCENT" }], + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) // Ensure that the discount is only applied to the standard item expect(cartWithGiftcard.data.cart.total).toBe(1900) // 1000 (giftcard) + 900 (standard item with 10% discount) diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index 7a75d418b9..f7e499368a 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -17,6 +17,18 @@ const { } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { + const yesterday = ((today) => new Date(today.setDate(today.getDate() - 1)))( + new Date() + ) + const tomorrow = ((today) => new Date(today.setDate(today.getDate() + 1)))( + new Date() + ) + const tenDaysAgo = ((today) => new Date(today.setDate(today.getDate() - 10)))( + new Date() + ) + const tenDaysFromToday = ((today) => + new Date(today.setDate(today.getDate() + 10)))(new Date()) + const manager = connection.manager const defaultProfile = await manager.findOne(ShippingProfile, { @@ -88,6 +100,8 @@ module.exports = async (connection, data = {}) => { code: "10PERCENT", is_dynamic: false, is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, }) tenPercent.regions = [r] @@ -114,6 +128,92 @@ module.exports = async (connection, data = {}) => { await manager.save(d) + const expiredRule = manager.create(DiscountRule, { + id: "expiredRule", + description: "expired rule", + type: "fixed", + value: 100, + allocation: "total", + }) + + const expiredDisc = manager.create(Discount, { + id: "expiredDisc", + code: "EXP_DISC", + is_dynamic: false, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: yesterday, + }) + + expiredDisc.regions = [r] + expiredDisc.rule = expiredRule + await manager.save(expiredDisc) + + const prematureRule = manager.create(DiscountRule, { + id: "prematureRule", + description: "premature rule", + type: "fixed", + value: 100, + allocation: "total", + }) + + const prematureDiscount = manager.create(Discount, { + id: "prematureDiscount", + code: "PREM_DISC", + is_dynamic: false, + is_disabled: false, + starts_at: tomorrow, + ends_at: tenDaysFromToday, + }) + + prematureDiscount.regions = [r] + prematureDiscount.rule = prematureRule + await manager.save(prematureDiscount) + + const invalidDynamicRule = manager.create(DiscountRule, { + id: "invalidDynamicRule", + description: "invalidDynamic rule", + type: "fixed", + value: 100, + allocation: "total", + }) + + const invalidDynamicDiscount = manager.create(Discount, { + id: "invalidDynamicDiscount", + code: "INV_DYN_DISC", + is_dynamic: true, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, + valid_duration: "P1D", // one day + }) + + invalidDynamicDiscount.regions = [r] + invalidDynamicDiscount.rule = invalidDynamicRule + await manager.save(invalidDynamicDiscount) + + const DynamicRule = manager.create(DiscountRule, { + id: "DynamicRule", + description: "Dynamic rule", + type: "fixed", + value: 10000, + allocation: "total", + }) + + const DynamicDiscount = manager.create(Discount, { + id: "DynamicDiscount", + code: "DYN_DISC", + is_dynamic: true, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, + valid_duration: "P1M", //one month + }) + + DynamicDiscount.regions = [r] + DynamicDiscount.rule = DynamicRule + await manager.save(DynamicDiscount) + await manager.query( `UPDATE "country" SET region_id='test-region' WHERE iso_2 = 'us'` ) @@ -304,6 +404,11 @@ module.exports = async (connection, data = {}) => { data: {}, }) + await manager.save(pay) + + cart2.payment = pay + + await manager.save(cart2) const swapPay = manager.create(Payment, { id: "test-swap-payment", amount: 10000, diff --git a/integration-tests/setup.js b/integration-tests/setup.js index 497b44047e..3f689687b9 100644 --- a/integration-tests/setup.js +++ b/integration-tests/setup.js @@ -1,16 +1,16 @@ -const path = require('path'); -const {dropDatabase} = require('pg-god'); +const path = require("path") +const { dropDatabase } = require("pg-god") -require('dotenv').config({path: path.join(__dirname, '.env')}); +require("dotenv").config({ path: path.join(__dirname, ".env") }) -const DB_USERNAME = process.env.DB_USERNAME || 'postgres'; -const DB_PASSWORD = process.env.DB_PASSWORD || ''; +const DB_USERNAME = process.env.DB_USERNAME || "postgres" +const DB_PASSWORD = process.env.DB_PASSWORD || "" const pgGodCredentials = { user: DB_USERNAME, password: DB_PASSWORD, -}; +} afterAll(() => { - dropDatabase({databaseName: 'medusa-integration'}, pgGodCredentials); -}); + dropDatabase({ databaseName: "medusa-integration" }, pgGodCredentials) +}) diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 1553f00401..3c6e0f2f33 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -63,6 +63,7 @@ "glob": "^7.1.6", "ioredis": "^4.17.3", "ioredis-mock": "^5.6.0", + "iso8601-duration": "^1.3.0", "joi": "^17.3.0", "joi-objectid": "^3.0.1", "jsonwebtoken": "^8.5.1", diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/add-region.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/add-region.js index 654c46ff99..cef0080af1 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/add-region.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/add-region.js @@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/regions/:region_id", () => { "updated_at", "deleted_at", "metadata", + "valid_duration", ], relations: ["rule", "parent_discount", "regions", "rule.valid_for"], } diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/add-valid-product.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/add-valid-product.js index 05e521083d..0f37872e4c 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/add-valid-product.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/add-valid-product.js @@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/variants/:variant_id", () => { "updated_at", "deleted_at", "metadata", + "valid_duration", ], relations: ["rule", "parent_discount", "regions", "rule.valid_for"], } diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/create-discount.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/create-discount.js index 18536d7533..2e4ab927a8 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/create-discount.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/create-discount.js @@ -16,6 +16,8 @@ describe("POST /admin/discounts", () => { value: 10, allocation: "total", }, + starts_at: "02/02/2021 13:45", + ends_at: "03/14/2021 04:30", }, adminSession: { jwt: { @@ -39,12 +41,99 @@ describe("POST /admin/discounts", () => { value: 10, allocation: "total", }, + starts_at: new Date("02/02/2021 13:45"), + ends_at: new Date("03/14/2021 04:30"), is_disabled: false, is_dynamic: false, }) }) }) + describe("unsuccessful creation with dynamic discount using an invalid iso8601 duration", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request("POST", "/admin/discounts", { + payload: { + code: "TEST", + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: "02/02/2021 13:45", + is_dynamic: true, + valid_duration: "PaMT2D", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message[0].message).toEqual( + `"valid_duration" must be a valid ISO 8601 duration` + ) + }) + }) + + describe("successful creation with dynamic discount", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request("POST", "/admin/discounts", { + payload: { + code: "TEST", + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: "02/02/2021 13:45", + is_dynamic: true, + valid_duration: "P1Y2M03DT04H05M", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service create", () => { + expect(DiscountServiceMock.create).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.create).toHaveBeenCalledWith({ + code: "TEST", + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: new Date("02/02/2021 13:45"), + is_disabled: false, + is_dynamic: true, + valid_duration: "P1Y2M03DT04H05M", + }) + }) + }) + describe("fails on invalid data", () => { let subject @@ -74,4 +163,84 @@ describe("POST /admin/discounts", () => { expect(subject.body.message[0].message).toEqual(`"rule.type" is required`) }) }) + + describe("fails on invalid date intervals", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", "/admin/discounts", { + payload: { + code: "TEST", + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + ends_at: "02/02/2021", + starts_at: "03/14/2021", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message[0].message).toEqual( + `"ends_at" must be greater than "ref:starts_at"` + ) + }) + }) + + describe("succesfully creates a dynamic discount without setting valid duration", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request("POST", "/admin/discounts", { + payload: { + code: "TEST", + is_dynamic: true, + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: "03/14/2021 14:30", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns error", () => { + expect(DiscountServiceMock.create).toHaveBeenCalledWith({ + code: "TEST", + is_dynamic: true, + is_disabled: false, + rule: { + description: "Test", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: new Date("03/14/2021 14:30"), + }) + }) + }) }) diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/get-discount.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/get-discount.js index 9260e22679..7616b1d0a4 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/get-discount.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/get-discount.js @@ -17,6 +17,7 @@ const defaultFields = [ "updated_at", "deleted_at", "metadata", + "valid_duration", ] const defaultRelations = [ diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-region.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-region.js index ec33ba1d17..895ca98c7f 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-region.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-region.js @@ -17,6 +17,7 @@ const defaultFields = [ "updated_at", "deleted_at", "metadata", + "valid_duration", ] const defaultRelations = [ diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-valid-product.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-valid-product.js index 7dbc6d2912..4762083e9a 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-valid-product.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/remove-valid-product.js @@ -17,6 +17,7 @@ const defaultFields = [ "updated_at", "deleted_at", "metadata", + "valid_duration", ] const defaultRelations = [ diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/update-discount.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/update-discount.js index fd7ab4b7b5..a3feed420c 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/update-discount.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/update-discount.js @@ -7,6 +7,7 @@ describe("POST /admin/discounts", () => { let subject beforeAll(async () => { + jest.clearAllMocks() subject = await request( "POST", `/admin/discounts/${IdMap.getId("total10")}`, @@ -50,4 +51,139 @@ describe("POST /admin/discounts", () => { ) }) }) + + describe("unsuccessful update with dynamic discount using an invalid iso8601 duration", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request( + "POST", + `/admin/discounts/${IdMap.getId("total10")}`, + { + payload: { + code: "10TOTALOFF", + rule: { + id: "1234", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: "02/02/2021 13:45", + is_dynamic: true, + valid_duration: "PaMT2D", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message[0].message).toEqual( + `"valid_duration" must be a valid ISO 8601 duration` + ) + }) + }) + + describe("successful update with dynamic discount", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request( + "POST", + `/admin/discounts/${IdMap.getId("total10")}`, + { + payload: { + code: "10TOTALOFF", + rule: { + id: "1234", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: "02/02/2021 13:45", + is_dynamic: true, + valid_duration: "P1Y2M03DT04H05M", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service update", () => { + expect(DiscountServiceMock.update).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("total10"), + { + code: "10TOTALOFF", + rule: { + id: "1234", + type: "fixed", + value: 10, + allocation: "total", + }, + starts_at: new Date("02/02/2021 13:45"), + is_dynamic: true, + valid_duration: "P1Y2M03DT04H05M", + } + ) + }) + }) + + describe("fails on invalid date intervals", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request( + "POST", + `/admin/discounts/${IdMap.getId("total10")}`, + { + payload: { + code: "10TOTALOFF", + rule: { + id: "1234", + type: "fixed", + value: 10, + allocation: "total", + }, + ends_at: "02/02/2021", + starts_at: "03/14/2021", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns error", () => { + expect(subject.body.message[0].message).toEqual( + `"ends_at" must be greater than "ref:starts_at"` + ) + }) + }) }) diff --git a/packages/medusa/src/api/routes/admin/discounts/create-discount.js b/packages/medusa/src/api/routes/admin/discounts/create-discount.js index 4e42bdc882..e7b9ea364e 100644 --- a/packages/medusa/src/api/routes/admin/discounts/create-discount.js +++ b/packages/medusa/src/api/routes/admin/discounts/create-discount.js @@ -71,7 +71,13 @@ export default async (req, res) => { .required(), is_disabled: Validator.boolean().default(false), starts_at: Validator.date().optional(), - ends_at: Validator.date().optional(), + ends_at: Validator.date() + .greater(Validator.ref("starts_at")) + .optional(), + valid_duration: Validator.string() + .isoDuration() + .allow(null) + .optional(), usage_limit: Validator.number() .positive() .optional(), diff --git a/packages/medusa/src/api/routes/admin/discounts/create-dynamic-code.js b/packages/medusa/src/api/routes/admin/discounts/create-dynamic-code.js index 30e7288f62..dcda7047a6 100644 --- a/packages/medusa/src/api/routes/admin/discounts/create-dynamic-code.js +++ b/packages/medusa/src/api/routes/admin/discounts/create-dynamic-code.js @@ -37,9 +37,9 @@ export default async (req, res) => { try { const discountService = req.scope.resolve("discountService") - await discountService.createDynamicCode(discount_id, value) + const created = await discountService.createDynamicCode(discount_id, value) - const discount = await discountService.retrieve(discount_id, { + const discount = await discountService.retrieve(created.id, { relations: ["rule", "rule.valid_for", "regions"], }) diff --git a/packages/medusa/src/api/routes/admin/discounts/index.js b/packages/medusa/src/api/routes/admin/discounts/index.js index b7d1cd0f6c..765ca01cfa 100644 --- a/packages/medusa/src/api/routes/admin/discounts/index.js +++ b/packages/medusa/src/api/routes/admin/discounts/index.js @@ -74,6 +74,7 @@ export const defaultFields = [ "updated_at", "deleted_at", "metadata", + "valid_duration", ] export const defaultRelations = [ diff --git a/packages/medusa/src/api/routes/admin/discounts/update-discount.js b/packages/medusa/src/api/routes/admin/discounts/update-discount.js index f3cfecc1d3..dd62b10840 100644 --- a/packages/medusa/src/api/routes/admin/discounts/update-discount.js +++ b/packages/medusa/src/api/routes/admin/discounts/update-discount.js @@ -68,7 +68,16 @@ export default async (req, res) => { .optional(), is_disabled: Validator.boolean().optional(), starts_at: Validator.date().optional(), - ends_at: Validator.date().optional(), + ends_at: Validator.when("starts_at", { + not: undefined, + then: Validator.date() + .greater(Validator.ref("starts_at")) + .optional(), + otherwise: Validator.date().optional(), + }), + valid_duration: Validator.string() + .isoDuration().allow(null) + .optional(), usage_limit: Validator.number() .positive() .optional(), @@ -78,6 +87,7 @@ export default async (req, res) => { }) const { value, error } = schema.validate(req.body) + if (error) { throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) } diff --git a/packages/medusa/src/migrations/1631696624528-valid_duration_for_discount.ts b/packages/medusa/src/migrations/1631696624528-valid_duration_for_discount.ts new file mode 100644 index 0000000000..267954d4e1 --- /dev/null +++ b/packages/medusa/src/migrations/1631696624528-valid_duration_for_discount.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class validDurationForDiscount1631696624528 implements MigrationInterface { + name = 'validDurationForDiscount1631696624528' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "discount" ADD "valid_duration" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "discount" DROP COLUMN "valid_duration"`); + } + +} \ No newline at end of file diff --git a/packages/medusa/src/models/discount.ts b/packages/medusa/src/models/discount.ts index 708fdb9110..a4ef833b3c 100644 --- a/packages/medusa/src/models/discount.ts +++ b/packages/medusa/src/models/discount.ts @@ -44,7 +44,7 @@ export class Discount { @Column({ nullable: true }) parent_discount_id: string - + @ManyToOne(() => Discount) @JoinColumn({ name: "parent_discount_id" }) parent_discount: Discount @@ -58,6 +58,9 @@ export class Discount { @Column({ type: resolveDbType("timestamptz"), nullable: true }) ends_at: Date + @Column({ nullable: true }) + valid_duration: string + @ManyToMany(() => Region, { cascade: true }) @JoinTable({ name: "discount_regions", diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index f887a6a9d5..55817e2592 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1471,6 +1471,12 @@ describe("CartService", () => { }) describe("applyDiscount", () => { + const getOffsetDate = offset => { + const date = new Date() + date.setDate(date.getDate() + offset) + return date + } + const cartRepository = MockRepository({ findOneWithRelations: (rels, q) => { if (q.where.id === IdMap.getId("with-d")) { @@ -1538,6 +1544,69 @@ describe("CartService", () => { }, }) } + if (code === "EarlyDiscount") { + return Promise.resolve({ + id: IdMap.getId("10off"), + code: "10%OFF", + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(1), + ends_at: getOffsetDate(10), + }) + } + if (code === "ExpiredDiscount") { + return Promise.resolve({ + id: IdMap.getId("10off"), + code: "10%OFF", + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + ends_at: getOffsetDate(-1), + starts_at: getOffsetDate(-10), + }) + } + if (code === "ExpiredDynamicDiscount") { + return Promise.resolve({ + id: IdMap.getId("10off"), + code: "10%OFF", + is_dynamic: true, + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(-10), + ends_at: getOffsetDate(-1), + }) + } + if (code === "ExpiredDynamicDiscountEndDate") { + return Promise.resolve({ + id: IdMap.getId("10off"), + is_dynamic: true, + code: "10%OFF", + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(-10), + ends_at: getOffsetDate(-3), + valid_duration: "P0Y0M1D", + }) + } + if (code === "ValidDiscount") { + return Promise.resolve({ + id: IdMap.getId("10off"), + code: "10%OFF", + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(-10), + ends_at: getOffsetDate(10), + }) + } return Promise.resolve({ id: IdMap.getId("10off"), code: "10%OFF", @@ -1688,6 +1757,76 @@ describe("CartService", () => { }) }) + it("successfully applies valid discount with expiration date to cart", async () => { + await cartService.update(IdMap.getId("fr-cart"), { + discounts: [ + { + code: "ValidDiscount", + }, + ], + }) + expect(eventBusService.emit).toHaveBeenCalledTimes(1) + expect(eventBusService.emit).toHaveBeenCalledWith( + "cart.updated", + expect.any(Object) + ) + + expect(cartRepository.save).toHaveBeenCalledTimes(1) + expect(cartRepository.save).toHaveBeenCalledWith({ + id: IdMap.getId("cart"), + region_id: IdMap.getId("good"), + discount_total: 0, + shipping_total: 0, + subtotal: 0, + tax_total: 0, + total: 0, + discounts: [ + { + id: IdMap.getId("10off"), + code: "10%OFF", + regions: [{ id: IdMap.getId("good") }], + rule: { + type: "percentage", + }, + starts_at: expect.any(Date), + ends_at: expect.any(Date), + }, + ], + }) + }) + + it("throws if discount is applied too before it's valid", async () => { + await expect( + cartService.update(IdMap.getId("cart"), { + discounts: [{ code: "EarlyDiscount" }], + }) + ).rejects.toThrow("Discount is not valid yet") + }) + + it("throws if expired discount is applied", async () => { + await expect( + cartService.update(IdMap.getId("cart"), { + discounts: [{ code: "ExpiredDiscount" }], + }) + ).rejects.toThrow("Discount is expired") + }) + + it("throws if expired dynamic discount is applied", async () => { + await expect( + cartService.update(IdMap.getId("cart"), { + discounts: [{ code: "ExpiredDynamicDiscount" }], + }) + ).rejects.toThrow("Discount is expired") + }) + + it("throws if expired dynamic discount is applied after ends_at", async () => { + await expect( + cartService.update(IdMap.getId("cart"), { + discounts: [{ code: "ExpiredDynamicDiscountEndDate" }], + }) + ).rejects.toThrow("Discount is expired") + }) + it("throws if discount limit is reached", async () => { await expect( cartService.update(IdMap.getId("cart"), { diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js index 8b740fb1a8..5802063386 100644 --- a/packages/medusa/src/services/__tests__/discount.js +++ b/packages/medusa/src/services/__tests__/discount.js @@ -55,6 +55,74 @@ describe("DiscountService", () => { expect(discountRepository.save).toHaveBeenCalledTimes(1) }) + + it("successfully creates discount with start and end dates", async () => { + await discountService.create({ + code: "test", + rule: { + type: "percentage", + allocation: "total", + value: 20, + }, + starts_at: new Date("03/14/2021"), + ends_at: new Date("03/15/2021"), + regions: [IdMap.getId("france")], + }) + + expect(discountRuleRepository.create).toHaveBeenCalledTimes(1) + expect(discountRuleRepository.create).toHaveBeenCalledWith({ + type: "percentage", + allocation: "total", + value: 20, + }) + + expect(discountRuleRepository.save).toHaveBeenCalledTimes(1) + + expect(discountRepository.create).toHaveBeenCalledTimes(1) + expect(discountRepository.create).toHaveBeenCalledWith({ + code: "TEST", + rule: expect.anything(), + regions: [{ id: IdMap.getId("france") }], + starts_at: new Date("03/14/2021"), + ends_at: new Date("03/15/2021"), + }) + + expect(discountRepository.save).toHaveBeenCalledTimes(1) + }) + + it("successfully creates discount with start date and a valid duration", async () => { + await discountService.create({ + code: "test", + rule: { + type: "percentage", + allocation: "total", + value: 20, + }, + starts_at: new Date("03/14/2021"), + valid_duration: "P0Y0M1D", + regions: [IdMap.getId("france")], + }) + + expect(discountRuleRepository.create).toHaveBeenCalledTimes(1) + expect(discountRuleRepository.create).toHaveBeenCalledWith({ + type: "percentage", + allocation: "total", + value: 20, + }) + + expect(discountRuleRepository.save).toHaveBeenCalledTimes(1) + + expect(discountRepository.create).toHaveBeenCalledTimes(1) + expect(discountRepository.create).toHaveBeenCalledWith({ + code: "TEST", + rule: expect.anything(), + regions: [{ id: IdMap.getId("france") }], + starts_at: new Date("03/14/2021"), + valid_duration: "P0Y0M1D", + }) + + expect(discountRepository.save).toHaveBeenCalledTimes(1) + }) }) describe("retrieve", () => { @@ -376,6 +444,7 @@ describe("DiscountService", () => { id: "parent", is_dynamic: true, rule_id: "parent_rule", + valid_duration: "P1Y", }), }) @@ -412,6 +481,8 @@ describe("DiscountService", () => { rule_id: "parent_rule", parent_discount_id: "parent", code: "HI", + usage_limit: undefined, + ends_at: expect.any(Date), }) }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index de1f3ab164..fede0289d8 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -887,6 +887,21 @@ class CartService extends BaseService { ) } + const today = new Date() + if (discount.starts_at > today) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount is not valid yet" + ) + } + + if (discount.ends_at && discount.ends_at < today) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount is expired" + ) + } + let regions = discount.regions if (discount.parent_discount_id) { const parent = await this.discountService_.retrieve( diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js index a4fabdac65..ef987e617d 100644 --- a/packages/medusa/src/services/discount.js +++ b/packages/medusa/src/services/discount.js @@ -2,6 +2,8 @@ import _ from "lodash" import randomize from "randomatic" import { BaseService } from "medusa-interfaces" import { Validator, MedusaError } from "medusa-core-utils" +import { MedusaErrorCodes } from "medusa-core-utils/dist/errors" +import { parse, toSeconds } from "iso8601-duration" import { Brackets, ILike } from "typeorm" /** @@ -270,6 +272,15 @@ class DiscountService extends BaseService { const { rule, metadata, regions, ...rest } = update + if (rest.ends_at) { + if (discount.starts_at >= new Date(update.ends_at)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"ends_at" must be greater than "starts_at"` + ) + } + } + if (regions) { discount.regions = await Promise.all( regions.map(regionId => this.regionService_.retrieve(regionId)) @@ -329,6 +340,13 @@ class DiscountService extends BaseService { usage_limit: discount.usage_limit, } + if (discount.valid_duration) { + const lastValidDate = new Date() + lastValidDate.setSeconds( + lastValidDate.getSeconds() + toSeconds(parse(discount.valid_duration)) + ) + toCreate.ends_at = lastValidDate + } const created = await discountRepo.create(toCreate) const result = await discountRepo.save(created) return result From cd4afd15768627b3bf7579e7e4c67ed0698ea681 Mon Sep 17 00:00:00 2001 From: pKorsholm <88927411+pKorsholm@users.noreply.github.com> Date: Thu, 30 Sep 2021 12:19:37 +0200 Subject: [PATCH 17/19] fix(medusa): hide password hash (#429) * auth tests * customer auth tests * user auth test * store auth snapshot * auth snapshot * auth with deleted password hashes * manual field input for test scripts * fix circleci with double retrieve of user and customer * add email validation to user * fix: cleanup Co-authored-by: Sebastian Rindom --- .../admin/__snapshots__/auth.js.snap | 16 +++++ integration-tests/api/__tests__/admin/auth.js | 54 ++++++++++++++++ .../store/__snapshots__/auth.js.snap | 18 ++++++ integration-tests/api/__tests__/store/auth.js | 64 +++++++++++++++++++ packages/medusa/src/models/customer.ts | 2 +- packages/medusa/src/models/user.ts | 2 +- packages/medusa/src/services/auth.js | 22 +++++-- packages/medusa/src/services/user.js | 21 ++++-- 8 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap create mode 100644 integration-tests/api/__tests__/admin/auth.js create mode 100644 integration-tests/api/__tests__/store/__snapshots__/auth.js.snap create mode 100644 integration-tests/api/__tests__/store/auth.js diff --git a/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap new file mode 100644 index 0000000000..3a3a924b4b --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/discounts creates admin session correctly 1`] = ` +Object { + "api_token": "test_token", + "created_at": Any, + "deleted_at": null, + "email": "admin@medusa.js", + "first_name": null, + "id": "admin_user", + "last_name": null, + "metadata": null, + "updated_at": Any, +} +`; + diff --git a/integration-tests/api/__tests__/admin/auth.js b/integration-tests/api/__tests__/admin/auth.js new file mode 100644 index 0000000000..39eb1807e1 --- /dev/null +++ b/integration-tests/api/__tests__/admin/auth.js @@ -0,0 +1,54 @@ +const path = require("path") +const { Region, DiscountRule, Discount } = require("@medusajs/medusa") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const adminSeeder = require("../../helpers/admin-seeder") +const { exportAllDeclaration } = require("@babel/types") + +jest.setTimeout(30000) + +describe("/admin/auth", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + + try { + await adminSeeder(dbConnection) + } catch (e) { + throw e + } + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + it("creates admin session correctly", async () => { + const api = useApi() + + const response = await api + .post("/admin/auth", { + email: "admin@medusa.js", + password: "secret_password", + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.user.password_hash).toEqual(undefined) + expect(response.data.user).toMatchSnapshot({ + email: "admin@medusa.js", + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap b/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap new file mode 100644 index 0000000000..aaae35ba9c --- /dev/null +++ b/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/discounts creates store session correctly 1`] = ` +Object { + "billing_address_id": null, + "created_at": Any, + "deleted_at": null, + "email": "test@testesen.dk", + "first_name": "test", + "has_account": true, + "id": Any, + "last_name": "testesen", + "metadata": null, + "orders": Array [], + "phone": "12345678", + "updated_at": Any, +} +`; diff --git a/integration-tests/api/__tests__/store/auth.js b/integration-tests/api/__tests__/store/auth.js new file mode 100644 index 0000000000..4345ca56c9 --- /dev/null +++ b/integration-tests/api/__tests__/store/auth.js @@ -0,0 +1,64 @@ +const path = require("path") +const { Region, DiscountRule, Discount } = require("@medusajs/medusa") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const adminSeeder = require("../../helpers/admin-seeder") +const { exportAllDeclaration } = require("@babel/types") + +jest.setTimeout(30000) + +describe("/admin/auth", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + it("creates store session correctly", async () => { + const api = useApi() + + await api + .post("/store/customers", { + email: "test@testesen.dk", + password: "secret_password", + first_name: "test", + last_name: "testesen", + phone: "12345678", + }) + .catch((err) => { + console.log(err) + }) + + const response = await api + .post("/store/auth", { + email: "test@testesen.dk", + password: "secret_password", + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.customer.password_hash).toEqual(undefined) + expect(response.data.customer).toMatchSnapshot({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + first_name: "test", + last_name: "testesen", + phone: "12345678", + email: "test@testesen.dk", + }) + }) +}) diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index 4a6cdcdea1..ff481327fe 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -46,7 +46,7 @@ export class Customer { ) shipping_addresses: Address[] - @Column({ nullable: true }) + @Column({ nullable: true, select: false }) password_hash: string @Column({ nullable: true }) diff --git a/packages/medusa/src/models/user.ts b/packages/medusa/src/models/user.ts index 212a339494..8dba94cfac 100644 --- a/packages/medusa/src/models/user.ts +++ b/packages/medusa/src/models/user.ts @@ -26,7 +26,7 @@ export class User { @Column({ nullable: true }) last_name: string - @Column({ nullable: true }) + @Column({ nullable: true, select: false }) password_hash: string @Column({ nullable: true }) diff --git a/packages/medusa/src/services/auth.js b/packages/medusa/src/services/auth.js index a9c6312fa5..3216886c5d 100644 --- a/packages/medusa/src/services/auth.js +++ b/packages/medusa/src/services/auth.js @@ -76,12 +76,17 @@ class AuthService extends BaseService { */ async authenticate(email, password) { try { - const user = await this.userService_.retrieveByEmail(email) + const userPasswordHash = await this.userService_.retrieveByEmail(email, { + select: ["password_hash"], + }) + const passwordsMatch = await this.comparePassword_( password, - user.password_hash + userPasswordHash.password_hash ) + if (passwordsMatch) { + const user = await this.userService_.retrieveByEmail(email) return { success: true, user, @@ -113,8 +118,13 @@ class AuthService extends BaseService { */ async authenticateCustomer(email, password) { try { - const customer = await this.customerService_.retrieveByEmail(email) - if (!customer.password_hash) { + const customerPasswordHash = await this.customerService_.retrieveByEmail( + email, + { + select: ["password_hash"], + } + ) + if (!customerPasswordHash.password_hash) { return { success: false, error: "Invalid email or password", @@ -123,9 +133,11 @@ class AuthService extends BaseService { const passwordsMatch = await this.comparePassword_( password, - customer.password_hash + customerPasswordHash.password_hash ) + if (passwordsMatch) { + const customer = await this.customerService_.retrieveByEmail(email) return { success: true, customer, diff --git a/packages/medusa/src/services/user.js b/packages/medusa/src/services/user.js index a57436784e..52a55f6e01 100644 --- a/packages/medusa/src/services/user.js +++ b/packages/medusa/src/services/user.js @@ -48,7 +48,18 @@ class UserService extends BaseService { * @return {string} the validated email */ validateEmail_(email) { - return email + const schema = Validator.string() + .email() + .required() + const { value, error } = schema.validate(email) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The email is not valid" + ) + } + + return value.toLowerCase() } /** @@ -114,13 +125,11 @@ class UserService extends BaseService { * @param {string} email - the email of the user to get. * @return {Promise} the user document. */ - async retrieveByEmail(email, relations = []) { + async retrieveByEmail(email, config = {}) { const userRepo = this.manager_.getCustomRepository(this.userRepository_) - const user = await userRepo.findOne({ - where: { email }, - relations, - }) + const query = this.buildQuery_({ email: email.toLowerCase() }, config) + const user = await userRepo.findOne(query) if (!user) { throw new MedusaError( From 22f3f2af93fcebef5ebf89ce66cd926393ae5d25 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 1 Oct 2021 08:18:56 +0200 Subject: [PATCH 18/19] fix: shipping option updates (#426) * fix to remove req * tested fix --- ...te_date_on_shipping_option_requirements.ts | 18 ++++++++ .../src/models/shipping-option-requirement.ts | 5 +- .../src/services/__tests__/shipping-option.js | 38 ++++++--------- .../medusa/src/services/shipping-option.js | 46 ++++++++++++------- 4 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 packages/medusa/src/migrations/1632828114899-delete_date_on_shipping_option_requirements.ts diff --git a/packages/medusa/src/migrations/1632828114899-delete_date_on_shipping_option_requirements.ts b/packages/medusa/src/migrations/1632828114899-delete_date_on_shipping_option_requirements.ts new file mode 100644 index 0000000000..12011f31a9 --- /dev/null +++ b/packages/medusa/src/migrations/1632828114899-delete_date_on_shipping_option_requirements.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class deleteDateOnShippingOptionRequirements1632828114899 + implements MigrationInterface { + name = "deleteDateOnShippingOptionRequirements1632828114899" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "shipping_option_requirement" ADD "deleted_at" TIMESTAMP WITH TIME ZONE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "shipping_option_requirement" DROP COLUMN "deleted_at"` + ) + } +} diff --git a/packages/medusa/src/models/shipping-option-requirement.ts b/packages/medusa/src/models/shipping-option-requirement.ts index b847687d02..547ddf58cd 100644 --- a/packages/medusa/src/models/shipping-option-requirement.ts +++ b/packages/medusa/src/models/shipping-option-requirement.ts @@ -16,7 +16,7 @@ import { JoinTable, } from "typeorm" import { ulid } from "ulid" -import { DbAwareColumn } from "../utils/db-aware-column" +import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" import { ShippingOption } from "./shipping-option" @@ -44,6 +44,9 @@ export class ShippingOptionRequirement { @Column({ type: "int" }) amount: number + @DeleteDateColumn({ type: resolveDbType("timestamptz") }) + deleted_at: Date + @BeforeInsert() private beforeInsert() { if (this.id) return diff --git a/packages/medusa/src/services/__tests__/shipping-option.js b/packages/medusa/src/services/__tests__/shipping-option.js index e72c4d74b3..8001065535 100644 --- a/packages/medusa/src/services/__tests__/shipping-option.js +++ b/packages/medusa/src/services/__tests__/shipping-option.js @@ -296,24 +296,19 @@ describe("ShippingOptionService", () => { }) describe("removeRequirement", () => { - const shippingOptionRepository = MockRepository({ - findOne: q => { - switch (q.where.id) { - default: - return Promise.resolve({ - requirements: [ - { - id: IdMap.getId("requirement_id"), - }, - ], - }) - } + const shippingOptionRequirementRepository = MockRepository({ + softRemove: q => { + return Promise.resolve() }, + findOne: i => + i.where.id === IdMap.getId("requirement_id") + ? { id: IdMap.getId("requirement_id") } + : null, }) const optionService = new ShippingOptionService({ manager: MockManager, - shippingOptionRepository, + shippingOptionRequirementRepository, }) beforeEach(() => { @@ -321,22 +316,19 @@ describe("ShippingOptionService", () => { }) it("remove requirement successfully", async () => { - await optionService.removeRequirement( - IdMap.getId("validId"), - IdMap.getId("requirement_id") - ) + await optionService.removeRequirement(IdMap.getId("requirement_id")) - expect(shippingOptionRepository.save).toBeCalledTimes(1) - expect(shippingOptionRepository.save).toBeCalledWith({ requirements: [] }) + expect(shippingOptionRequirementRepository.findOne).toBeCalledTimes(1) + expect(shippingOptionRequirementRepository.findOne).toBeCalledWith({ + where: { id: IdMap.getId("requirement_id") }, + }) + expect(shippingOptionRequirementRepository.softRemove).toBeCalledTimes(1) }) it("is idempotent", async () => { await optionService.removeRequirement(IdMap.getId("validId"), "something") - expect(shippingOptionRepository.save).toBeCalledTimes(1) - expect(shippingOptionRepository.save).toBeCalledWith({ - requirements: [{ id: IdMap.getId("requirement_id") }], - }) + expect(shippingOptionRequirementRepository.softRemove).toBeCalledTimes(1) }) }) diff --git a/packages/medusa/src/services/shipping-option.js b/packages/medusa/src/services/shipping-option.js index c745bfbfb1..ad2cbf8724 100644 --- a/packages/medusa/src/services/shipping-option.js +++ b/packages/medusa/src/services/shipping-option.js @@ -450,7 +450,9 @@ class ShippingOptionService extends BaseService { */ async update(optionId, update) { return this.atomicPhase_(async manager => { - const option = await this.retrieve(optionId) + const option = await this.retrieve(optionId, { + relations: ["requirements"], + }) if ("metadata" in update) { option.metadata = await this.setMetadata_(option, update.metadata) @@ -498,6 +500,20 @@ class ShippingOptionService extends BaseService { acc.push(validated) } + + if (option.requirements) { + const accReqs = acc.map(a => a.id) + const toRemove = option.requirements.filter( + r => !accReqs.includes(r.id) + ) + await Promise.all( + toRemove.map(async req => { + await this.removeRequirement(req.id) + }) + ) + } + + option.requirements = acc } if ("price_type" in update) { @@ -585,28 +601,24 @@ class ShippingOptionService extends BaseService { /** * Removes a requirement from a shipping option - * @param {string} optionId - the shipping option to remove from * @param {string} requirementId - the id of the requirement to remove * @return {Promise} the result of update */ - async removeRequirement(optionId, requirementId) { + async removeRequirement(requirementId) { return this.atomicPhase_(async manager => { - const option = await this.retrieve(optionId, { - relations: "requirements", - }) - const newReqs = option.requirements.map(r => { - if (r.id === requirementId) { - return null - } else { - return r - } - }) + try { + const reqRepo = manager.getCustomRepository(this.requirementRepository_) + const requirement = await reqRepo.findOne({ + where: { id: requirementId }, + }) - option.requirements = newReqs.filter(Boolean) + const result = await reqRepo.softRemove(requirement) - const optionRepo = manager.getCustomRepository(this.optionRepository_) - const result = await optionRepo.save(option) - return result + return result + } catch (error) { + // Delete is idempotent, but we return a promise to allow then-chaining + return Promise.resolve() + } }) } From 337fc16c38fe90c2b985f5434fe62da758ba3f59 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 1 Oct 2021 12:58:44 +0200 Subject: [PATCH 19/19] docs: Add fileservice guides for S3 and Spaces (#430) * docs: Add fileservice guides for S3 and Spaces * fix: remove we in s3 guide * fix: typos in spaces guide --- docs/content/how-to/uploading-images-to-s3.md | 85 +++++++++++++++++++ .../how-to/uploading-images-to-spaces.md | 36 ++++++++ packages/medusa-file-s3/README.md | 15 ++++ packages/medusa-file-spaces/README.md | 15 ++++ www/docs/sidebars.js | 18 ++++ 5 files changed, 169 insertions(+) create mode 100644 docs/content/how-to/uploading-images-to-s3.md create mode 100644 docs/content/how-to/uploading-images-to-spaces.md create mode 100644 packages/medusa-file-s3/README.md create mode 100644 packages/medusa-file-spaces/README.md diff --git a/docs/content/how-to/uploading-images-to-s3.md b/docs/content/how-to/uploading-images-to-s3.md new file mode 100644 index 0000000000..bc71a22736 --- /dev/null +++ b/docs/content/how-to/uploading-images-to-s3.md @@ -0,0 +1,85 @@ +# Uploading images to S3 + +In order to work with images in Medusa, you need a file service plugin responsible for hosting. Following this guide will allow you to upload images to AWS S3. + +### Before you start + +At this point, you should have an instance of our store engine running. If not, we have a [full guide](https://docs.medusa-commerce.com/tutorial/set-up-your-development-environment) for setting up your local environment. + +### Set up up AWS + +#### Create an S3 bucket + +In the AWS console navigate to S3 and create a bucket for your images. Make sure to uncheck "Block _all_ public access". + +Additionally, you need to add a policy to your bucket, that will allow public access to objects that are uploaded. Navigate to the permissions tab of your bucket and add the following policy: + +```shell= +{ + "Id": "Policy1397632521960", + "Statement": [ + { + "Sid": "Stmt1397633323327", + "Action": [ + "s3:GetObject" + ], + "Effect": "Allow", + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*", + "Principal": { + "AWS": [ + "*" + ] + } + } + ] +} +``` + +Be aware, that this will allow for anyone to acces your bucket. Avoid storing sensitive data. + +#### Generate access keys + +Navigate to the IAM section of your AWS console and perform the following steps: + +- Add a new user with programmatic access +- Add the existing **AmazonS3FullAccess** policy to the user +- Submit the details + +Upon successfull creation of the user, you are presented with an **Access key ID** and a **Secret access key**. Note both of them down for later use. + +### Installation + +First, install the plugin using your preferred package manager: + +``` +yarn add medusa-file-s3 +``` + +Then configure your `medusa-config.js` to include the plugin alongside the required options: + +```=javascript +{ + resolve: `medusa-file-s3`, + options: { + s3_url: "https://s3-guide-test.s3.eu-west-1.amazonaws.com", + bucket: "test", + region: "eu-west-1" + access_key_id: "YOUR-ACCESS-KEY", + secret_access_key: "YOUR-SECRET-KEY", + }, +}, +``` + +In the above options, an `s3_url` is included. The url has the following format: + +```shell= +https://[bucket].s3.[region].amazonaws.com +``` + +The two access keys in the options are the ones created in the previous section. + +> Make sure to use an environment variable for the secret key in a live environment. + +### Try it out + +Finally, run your Medusa server alongside our admin system to try out your new file service. Upon editing or creating products, you can now upload thumbnails and images, that are stored in an AWS S3 bucket. diff --git a/docs/content/how-to/uploading-images-to-spaces.md b/docs/content/how-to/uploading-images-to-spaces.md new file mode 100644 index 0000000000..976b077dd6 --- /dev/null +++ b/docs/content/how-to/uploading-images-to-spaces.md @@ -0,0 +1,36 @@ +# Uploading images to Spaces +In order to work with images in Medusa, you need a file service plugin responsible for hosting. Following this guide will allow you to upload images to DigitalOcean Spaces. + +### Before you start +At this point, you should have an instance of our store engine running. If not, we have a [full guide](https://docs.medusa-commerce.com/tutorial/set-up-your-development-environment) for setting up your local environment. + +### Set up up DigitalOcean +#### Create a Space +Create an account on DigitalOcean and navigate to Spaces. Create a new Space with the default settings. + +#### Generate access keys +Navigate to API in the left sidebar. Generate a new Spaces access key. This should provide you with an access key id and a secret key. Note them both down. + +### Installation +First, install the plugin using your preferred package manager: +``` +yarn add medusa-file-spaces +``` +Then configure your `medusa-config.js` to include the plugin alongside the required options: +```=javascript +{ + resolve: `medusa-file-spaces`, + options: { + spaces_url: "https://test.fra1.digitaloceanspaces.com", + bucket: "test", + endpoint: "fra1.digitaloceanspaces.com", + access_key_id: "YOUR-ACCESS-KEY", + secret_access_key: "YOUR-SECRET-KEY", + }, +}, +``` +In the above options, a `spaces_url` is included. This can be found in your Space overview. The `bucket` should point to the name you gave your Space. The `endpoint` identifies the region in which you created the Space. And finally the two keys are the ones created in the previous section. +> Make sure to use an environment variable for the secret key in a live environment. + +### Try it out! +Finally, run your Medusa server alongside our admin system to try out your new file service. Upon editing or creating products, you can now upload thumbnails and images, that are stored in DigitalOcean Spaces. diff --git a/packages/medusa-file-s3/README.md b/packages/medusa-file-s3/README.md new file mode 100644 index 0000000000..e5ea97a571 --- /dev/null +++ b/packages/medusa-file-s3/README.md @@ -0,0 +1,15 @@ +# medusa-file-s3 + +Upload files to an AWS S3 bucket. + +## Options + +``` + s3_url: [url of your s3 bucket], + access_key_id: [access-key], + secret_access_key: [secret-access-key], + bucket: [name of your bucket], + region: [region of your bucket], +``` + +Follow [this guide](https://docs.medusa-commerce.com/how-to/uploading-images-to-s3) to configure the plugin. diff --git a/packages/medusa-file-spaces/README.md b/packages/medusa-file-spaces/README.md new file mode 100644 index 0000000000..f756dc1cde --- /dev/null +++ b/packages/medusa-file-spaces/README.md @@ -0,0 +1,15 @@ +# medusa-file-spaces + +Upload files to a DigitalOcean Space. + +## Options + +``` + spaces_url: [url of your DigitalOcean space], + access_key_id: [access-key], + secret_access_key: [secret-access-key], + bucket: [name of your bucket], + endpoint: [endpoint of you DigitalOcean space], +``` + +Follow [this guide](https://docs.medusa-commerce.com/how-to/uploading-images-to-spaces) to configure the plugin. diff --git a/www/docs/sidebars.js b/www/docs/sidebars.js index b8fadb9968..20fd3988f7 100644 --- a/www/docs/sidebars.js +++ b/www/docs/sidebars.js @@ -79,6 +79,14 @@ module.exports = { type: "doc", id: "how-to/create-medusa-app", }, + { + type: "doc", + id: "how-to/uploading-images-to-spaces", + }, + { + type: "doc", + id: "how-to/uploading-images-to-s3", + }, ], }, { @@ -99,5 +107,15 @@ module.exports = { }, ], }, + { + type: "category", + label: "Deploy", + items: [ + { + type: "doc", + id: "how-to/deploying-on-heroku", + }, + ], + }, ], }