feat: update customer groups (#1075)

* update customer service prep

* expand on the update customer groups test case

* add the test case for customer group update

* docs for `groups` prop on update customer endpoint

* refactor, update comments

* expend on integration tests, add customer mock with a group

* refactor to use `customerGroupService.list` method

* update units

* remove `updateCustomerGroups`

* fix rebase conflict

* fix customer seed data, add JoinTable on groups

* group retrieval using the expand param

* fix: use `buildQuery_`

* fix: validation for `groups`, enable `expand`param on update customer endpoint

* fix: remove fileds form the `FilterableCustomerGroupProps`

* fix: spearate body/query validation

Co-authored-by: fPolic <frane@medusajs.com>
This commit is contained in:
Frane Polić
2022-02-21 16:24:38 +01:00
committed by GitHub
parent c2241d1101
commit 75fb2ce9c3
9 changed files with 286 additions and 19 deletions

View File

@@ -62,6 +62,7 @@ Object {
"deleted_at": null,
"email": "test1@email.com",
"first_name": null,
"groups": Array [],
"has_account": false,
"id": "test-customer-1",
"last_name": null,

View File

@@ -56,7 +56,7 @@ describe("/admin/customers", () => {
})
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(4)
expect(response.data.count).toEqual(5)
expect(response.data.customers).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -228,6 +228,106 @@ describe("/admin/customers", () => {
})
)
})
it("Correctly updates customer groups", async () => {
const api = useApi()
let response = await api
.post(
"/admin/customers/test-customer-3?expand=groups",
{
groups: [{ id: "test-group-4" }],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.customer.groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "test-group-4", name: "test-group-4" }),
])
)
// Try adding a non existing group
response = await api
.post(
"/admin/customers/test-customer-3?expand=groups",
{
groups: [{ id: "test-group-4" }, { id: "fake-group-0" }],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.customer.groups.length).toEqual(1)
expect(response.data.customer.groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "test-group-4", name: "test-group-4" }),
])
)
// Delete all groups
response = await api
.post(
"/admin/customers/test-customer-3?expand=groups",
{
groups: [],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.customer.groups.length).toEqual(0)
// Adding a group to a customer with already existing groups.
response = await api
.post(
"/admin/customers/test-customer-5?expand=groups",
{
groups: [{ id: "test-group-5" }, { id: "test-group-4" }],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.customer.groups.length).toEqual(2)
expect(response.data.customer.groups).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "test-group-4", name: "test-group-4" }),
expect.objectContaining({ id: "test-group-5", name: "test-group-5" }),
])
)
})
})
describe("GET /admin/customers/:id", () => {
@@ -278,7 +378,7 @@ describe("/admin/customers", () => {
const api = useApi()
const response = await api
.get("/admin/customers/test-customer-1?expand=billing_address", {
.get("/admin/customers/test-customer-1?expand=billing_address,groups", {
headers: {
Authorization: "Bearer test_token",
},
@@ -295,6 +395,7 @@ describe("/admin/customers", () => {
created_at: expect.any(String),
updated_at: expect.any(String),
},
groups: [],
created_at: expect.any(String),
updated_at: expect.any(String),
})

View File

@@ -36,8 +36,24 @@ module.exports = async (connection, data = {}) => {
has_account: true,
})
await manager.insert(Customer, {
id: "test-customer-5",
email: "test5@email.com",
groups: [{ id: "test-group-5", name: "test-group-5" }],
})
await manager.insert(CustomerGroup, {
id: "customer-group-1",
name: "vip-customers",
})
await manager.insert(CustomerGroup, {
id: "test-group-4",
name: "test-group-4",
})
await manager.insert(CustomerGroup, {
id: "test-group-5",
name: "test-group-5",
})
}

View File

@@ -1,7 +1,17 @@
import { IsEmail, IsObject, IsOptional, IsString } from "class-validator"
import {
IsArray,
IsEmail,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { MedusaError } from "medusa-core-utils"
import CustomerService from "../../../../services/customer"
import { validator } from "../../../../utils/validator"
import { defaultAdminCustomersRelations } from "."
import { Type } from "class-transformer"
import { FindParams } from "../../../../types/common"
/**
* @oas [post] /customers/{id}
@@ -31,6 +41,16 @@ import { validator } from "../../../../utils/validator"
* password:
* type: string
* description: The Customer's password.
* groups:
* type: array
* description: A list of customer groups to which the customer belongs.
* items:
* required:
* - id
* properties:
* id:
* description: The id of a customer group
* type: string
* metadata:
* type: object
* description: Metadata for the customer.
@@ -49,27 +69,43 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const { id } = req.params
const validated = await validator(AdminPostCustomersCustomerReq, req.body)
const validatedBody = await validator(AdminPostCustomersCustomerReq, req.body)
const validatedQuery = await validator(FindParams, req.query)
const customerService: CustomerService = req.scope.resolve("customerService")
let customer = await customerService.retrieve(id)
if (validated.email && customer.has_account) {
if (validatedBody.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, validated)
await customerService.update(id, validatedBody)
let expandFields: string[] = []
if (validatedQuery.expand) {
expandFields = validatedQuery.expand.split(",")
}
const findConfig = {
relations: expandFields.length
? expandFields
: defaultAdminCustomersRelations,
}
customer = await customerService.retrieve(id, findConfig)
customer = await customerService.retrieve(id, {
relations: ["orders"],
})
res.status(200).json({ customer })
}
class Group {
@IsString()
id: string
}
export class AdminPostCustomersCustomerReq {
@IsEmail()
@IsOptional()
@@ -94,4 +130,10 @@ export class AdminPostCustomersCustomerReq {
@IsObject()
@IsOptional()
metadata?: object
@IsArray()
@IsOptional()
@Type(() => Group)
@ValidateNested({ each: true })
groups?: Group[]
}

View File

@@ -43,7 +43,10 @@ export class Customer {
@JoinColumn({ name: "billing_address_id" })
billing_address: Address
@OneToMany(() => Address, (address) => address.customer)
@OneToMany(
() => Address,
(address) => address.customer
)
shipping_addresses: Address[]
@Column({ nullable: true, select: false })
@@ -55,9 +58,23 @@ export class Customer {
@Column({ default: false })
has_account: boolean
@OneToMany(() => Order, (order) => order.customer)
@OneToMany(
() => Order,
(order) => order.customer
)
orders: Order[]
@JoinTable({
name: "customer_group_customers",
inverseJoinColumn: {
name: "customer_group_id",
referencedColumnName: "id",
},
joinColumn: {
name: "customer_id",
referencedColumnName: "id",
},
})
@ManyToMany(() => CustomerGroup, { cascade: true })
groups: CustomerGroup[]

View File

@@ -8,6 +8,13 @@ const eventBusService = {
},
}
const customerGroupService = {
withTransaction: function() {
return this
},
list: jest.fn().mockImplementation(() => Promise.resolve()),
}
describe("CustomerService", () => {
describe("retrieve", () => {
const customerRepository = MockRepository({
@@ -86,7 +93,7 @@ describe("CustomerService", () => {
describe("create", () => {
const customerRepository = MockRepository({
findOne: query => {
findOne: (query) => {
if (query.where.email === "tony@stark.com") {
return Promise.resolve({
id: IdMap.getId("exists"),
@@ -162,14 +169,14 @@ describe("CustomerService", () => {
describe("update", () => {
const customerRepository = MockRepository({
findOne: query => {
findOne: (query) => {
return Promise.resolve({ id: IdMap.getId("ironman") })
},
})
const addressRepository = MockRepository({
create: data => data,
save: data => Promise.resolve(data),
create: (data) => data,
save: (data) => Promise.resolve(data),
})
const customerService = new CustomerService({
@@ -246,9 +253,50 @@ describe("CustomerService", () => {
})
})
describe("update customer groups", () => {
const customerRepository = MockRepository({
findOne: (query) => {
return Promise.resolve({ id: IdMap.getId("ironman") })
},
})
const addressRepository = MockRepository({
create: (data) => data,
save: (data) => Promise.resolve(data),
})
const customerGroupRepository = MockRepository({
findByIds: jest.fn().mockImplementation(() => Promise.resolve()),
})
const customerService = new CustomerService({
manager: MockManager,
addressRepository,
customerRepository,
customerGroupRepository,
customerGroupService,
eventBusService,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls `customerGroupService.list` if `groups` prop is received as a param", async () => {
await customerService.update(IdMap.getId("ironman"), {
groups: [{ id: "group-id" }],
})
expect(customerGroupService.list).toBeCalledTimes(1)
expect(customerGroupService.list).toBeCalledWith({ id: ["group-id"] })
expect(customerRepository.save).toBeCalledTimes(1)
})
})
describe("updateAddress", () => {
const addressRepository = MockRepository({
findOne: query => {
findOne: (query) => {
return Promise.resolve({
id: IdMap.getId("hollywood-boulevard"),
address_1: "Hollywood Boulevard 2",
@@ -312,7 +360,7 @@ describe("CustomerService", () => {
describe("removeAddress", () => {
const addressRepository = MockRepository({
findOne: query => {
findOne: (query) => {
return Promise.resolve({
id: IdMap.getId("hollywood-boulevard"),
address_1: "Hollywood Boulevard 2",
@@ -345,7 +393,7 @@ describe("CustomerService", () => {
describe("delete", () => {
const customerRepository = MockRepository({
findOne: query => {
findOne: (query) => {
return Promise.resolve({ id: IdMap.getId("ironman") })
},
})

View File

@@ -3,6 +3,8 @@ import { BaseService } from "medusa-interfaces"
import { DeepPartial, EntityManager } from "typeorm"
import { CustomerGroup } from ".."
import { CustomerGroupRepository } from "../repositories/customer-group"
import { FindConfig } from "../types/common"
import { FilterableCustomerGroupProps } from "../types/customer-groups"
type CustomerGroupConstructorProps = {
manager: EntityManager
@@ -64,6 +66,25 @@ class CustomerGroupService extends BaseService {
}
})
}
/**
* List customer groups.
*
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableCustomerGroupProps = {},
config: FindConfig<CustomerGroup>
): Promise<CustomerGroup[]> {
const cgRepo: CustomerGroupRepository = this.manager_.getCustomRepository(
this.customerGroupRepository_
)
const query = this.buildQuery_(selector, config)
return await cgRepo.find(query)
}
}
export default CustomerGroupService

View File

@@ -21,6 +21,7 @@ class CustomerService extends BaseService {
customerRepository,
eventBusService,
addressRepository,
customerGroupService,
}) {
super()
@@ -35,6 +36,8 @@ class CustomerService extends BaseService {
/** @private @const {AddressRepository} */
this.addressRepository_ = addressRepository
this.customerGroupService_ = customerGroupService
}
withTransaction(transactionManager) {
@@ -60,7 +63,9 @@ class CustomerService extends BaseService {
* @return {string} the validated email
*/
validateEmail_(email) {
const schema = Validator.string().email().required()
const schema = Validator.string()
.email()
.required()
const { value, error } = schema.validate(email)
if (error) {
throw new MedusaError(
@@ -391,6 +396,7 @@ class CustomerService extends BaseService {
metadata,
billing_address,
billing_address_id,
groups,
...rest
} = update
@@ -417,6 +423,11 @@ class CustomerService extends BaseService {
customer.password_hash = await this.hashPassword_(password)
}
if (groups) {
const id = groups.map((g) => g.id)
customer.groups = await this.customerGroupService_.list({ id })
}
const updated = await customerRepository.save(customer)
await this.eventBus_

View File

@@ -0,0 +1,10 @@
import { ValidateNested } from "class-validator"
import { IsType } from "../utils/validators/is-type"
import { StringComparisonOperator } from "./common"
export class FilterableCustomerGroupProps {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator
}