feat(fulfillment): Shipping options, rules CRUD + rules based context filtering (#6455)
**What**
- Update shipping options with its rules and type
- create/update rules independently
- context based validation fundation
- 🔴 list shipping options with context rules fitlering will come in a separate pr to keep this one smaller
FIXES CORE-1743
FIXES CORE-1764
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import { isContextValidForRules, RuleOperator } from "../utils"
|
||||
|
||||
describe("isContextValidForRules", () => {
|
||||
const context = {
|
||||
attribute1: "value1",
|
||||
attribute2: "value2",
|
||||
attribute3: "value3",
|
||||
}
|
||||
|
||||
const validRule = {
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.EQ,
|
||||
value: "value1",
|
||||
}
|
||||
|
||||
const invalidRule = {
|
||||
attribute: "attribute2",
|
||||
operator: RuleOperator.EQ,
|
||||
value: "wrongValue",
|
||||
}
|
||||
|
||||
it("returns true when all rules are valid and atLeastOneValidRule is false", () => {
|
||||
const rules = [validRule, validRule]
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true when all rules are valid and atLeastOneValidRule is true", () => {
|
||||
const rules = [validRule, validRule]
|
||||
const options = { atLeastOneValidRule: true }
|
||||
expect(isContextValidForRules(context, rules, options)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true when some rules are valid and atLeastOneValidRule is true", () => {
|
||||
const rules = [validRule, invalidRule]
|
||||
const options = { atLeastOneValidRule: true }
|
||||
expect(isContextValidForRules(context, rules, options)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when some rules are valid and atLeastOneValidRule is false", () => {
|
||||
const rules = [validRule, invalidRule]
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when no rules are valid and atLeastOneValidRule is true", () => {
|
||||
const rules = [invalidRule, invalidRule]
|
||||
const options = { atLeastOneValidRule: true }
|
||||
expect(isContextValidForRules(context, rules, options)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when no rules are valid and atLeastOneValidRule is false", () => {
|
||||
const rules = [invalidRule, invalidRule]
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'gt' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.GT,
|
||||
value: "1", // 2 > 1
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'gt' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.GT,
|
||||
value: "0", // 0 > 0
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "0" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'gte' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.GTE,
|
||||
value: "2", // 2 >= 2
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'gte' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.GTE,
|
||||
value: "3", // 2 >= 3
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'lt' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.LT,
|
||||
value: "3", // 2 < 3
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'lt' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.LT,
|
||||
value: "2", // 2 < 2
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'lte' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.LTE,
|
||||
value: "2", // 2 <= 2
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
// ... existing tests ...
|
||||
|
||||
it("returns false when the 'lte' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.LTE,
|
||||
value: "1", // 2 <= 1
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'in' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.IN,
|
||||
value: ["1", "2", "3"], // 2 in [1, 2, 3]
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'in' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.IN,
|
||||
value: ["1", "3", "4"], // 2 in [1, 3, 4]
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'nin' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.NIN,
|
||||
value: ["1", "3", "4"], // 2 not in [1, 3, 4]
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'nin' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.NIN,
|
||||
value: ["1", "2", "3"], // 2 not in [1, 2, 3]
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'ne' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.NE,
|
||||
value: "1", // 2 != 1
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'ne' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.NE,
|
||||
value: "2", // 2 != 2
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when the 'eq' operator is valid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.EQ,
|
||||
value: "2", // 2 == 2
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when the 'eq' operator is invalid", () => {
|
||||
const rules = [
|
||||
{
|
||||
attribute: "attribute1",
|
||||
operator: RuleOperator.EQ,
|
||||
value: "1", // 2 == 1
|
||||
},
|
||||
]
|
||||
const context = { attribute1: "2" }
|
||||
expect(isContextValidForRules(context, rules)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,152 @@
|
||||
import { isString, MedusaError, pickValueFromObject } from "@medusajs/utils"
|
||||
|
||||
/**
|
||||
* The rule engine here is kept inside the module as of now, but it could be moved
|
||||
* to the utils package and be used across the different modules that provides context
|
||||
* based rule filtering.
|
||||
*
|
||||
* TODO: discussion around that should happen at some point
|
||||
*/
|
||||
|
||||
export type Rule = {
|
||||
attribute: string
|
||||
operator: RuleOperator
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export enum RuleOperator {
|
||||
IN = "in",
|
||||
EQ = "eq",
|
||||
NE = "ne",
|
||||
GT = "gt",
|
||||
GTE = "gte",
|
||||
LT = "lt",
|
||||
LTE = "lte",
|
||||
NIN = "nin",
|
||||
}
|
||||
|
||||
export const availableOperators = Object.values(RuleOperator)
|
||||
|
||||
const isDate = (str: string) => {
|
||||
return !isNaN(Date.parse(str))
|
||||
}
|
||||
|
||||
const operatorsPredicate = {
|
||||
in: (contextValue: string, ruleValue: string[]) =>
|
||||
ruleValue.includes(contextValue),
|
||||
nin: (contextValue: string, ruleValue: string[]) =>
|
||||
!ruleValue.includes(contextValue),
|
||||
eq: (contextValue: string, ruleValue: string) => contextValue === ruleValue,
|
||||
ne: (contextValue: string, ruleValue: string) => contextValue !== ruleValue,
|
||||
gt: (contextValue: string, ruleValue: string) => {
|
||||
if (isDate(contextValue) && isDate(ruleValue)) {
|
||||
return new Date(contextValue) > new Date(ruleValue)
|
||||
}
|
||||
return Number(contextValue) > Number(ruleValue)
|
||||
},
|
||||
gte: (contextValue: string, ruleValue: string) => {
|
||||
if (isDate(contextValue) && isDate(ruleValue)) {
|
||||
return new Date(contextValue) >= new Date(ruleValue)
|
||||
}
|
||||
return Number(contextValue) >= Number(ruleValue)
|
||||
},
|
||||
lt: (contextValue: string, ruleValue: string) => {
|
||||
if (isDate(contextValue) && isDate(ruleValue)) {
|
||||
return new Date(contextValue) < new Date(ruleValue)
|
||||
}
|
||||
return Number(contextValue) < Number(ruleValue)
|
||||
},
|
||||
lte: (contextValue: string, ruleValue: string) => {
|
||||
if (isDate(contextValue) && isDate(ruleValue)) {
|
||||
return new Date(contextValue) <= new Date(ruleValue)
|
||||
}
|
||||
return Number(contextValue) <= Number(ruleValue)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contextValue context object from contextValue set of rules.
|
||||
* By default, all rules must be valid to return true unless the option atLeastOneValidRule is set to true.
|
||||
* @param context
|
||||
* @param rules
|
||||
* @param options
|
||||
*/
|
||||
export function isContextValidForRules(
|
||||
context: Record<string, any>,
|
||||
rules: Rule[],
|
||||
options: {
|
||||
atLeastOneValidRule: boolean
|
||||
} = {
|
||||
atLeastOneValidRule: false,
|
||||
}
|
||||
) {
|
||||
const { atLeastOneValidRule } = options
|
||||
|
||||
const loopComparator = atLeastOneValidRule ? rules.some : rules.every
|
||||
const predicate = (rule) => {
|
||||
const { attribute, operator, value } = rule
|
||||
const contextValue = pickValueFromObject(attribute, context)
|
||||
return operatorsPredicate[operator](
|
||||
contextValue,
|
||||
value as string & string[]
|
||||
)
|
||||
}
|
||||
|
||||
return loopComparator.apply(rules, [predicate])
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contextValue rule object
|
||||
* @param rule
|
||||
*/
|
||||
export function validateRule(rule: Record<string, unknown>): boolean {
|
||||
if (!rule.attribute || !rule.operator || !rule.value) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Rule must have an attribute, an operator and contextValue value"
|
||||
)
|
||||
}
|
||||
|
||||
if (!isString(rule.attribute)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Rule attribute must be contextValue string"
|
||||
)
|
||||
}
|
||||
|
||||
if (!isString(rule.operator)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Rule operator must be contextValue string"
|
||||
)
|
||||
}
|
||||
|
||||
if (!availableOperators.includes(rule.operator as RuleOperator)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Rule operator ${
|
||||
rule.operator
|
||||
} is not supported. Must be one of ${availableOperators.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
if (rule.operator === RuleOperator.IN || rule.operator === RuleOperator.NIN) {
|
||||
if (!Array.isArray(rule.value)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Rule value must be an array for in/nin operators"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contextValue set of rules
|
||||
* @param rules
|
||||
*/
|
||||
export function validateRules(rules: Record<string, unknown>[]): boolean {
|
||||
rules.forEach(validateRule)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user