初始化

This commit is contained in:
DESKTOP-U5J7A7B\Willtech007
2026-10-12 17:55:59 +08:00
parent 54b4d8f8c5
commit 633690b0c4
52 changed files with 15478 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
{
"success": true,
"message": "Get redeemed promotions successfully.",
"data": {
"customerId": "800049052",
"outcomeList": [
{
"gamingDate": "2026-02-13",
"normalPromo": true,
"postedBy": "ballytest",
"postedDtm": "2026-01-14T16:49:57.305069",
"casinoCode": "GLP",
"locationCode": "rs",
"computerName": "QVTERAPP10",
"ipAddress": "10.51.23.199",
"prizeRuleCode": "WEP15pts",
"prizeRuleId": 506,
"promotionCode": "QAWEP01",
"promotionId": 7,
"redeemedQty": 1,
"redemptionId": 18219,
"remark": "",
"outcome": {
"redeemedList": [
{
"outcomeRemark": "mqa-openprizeb",
"outcomeType": "PRIZE",
"prizeCategory": null,
"transId": "f4928051-43ff-4bc5-a1c8-8978c3f4748e",
"prize": {
"correlationId": null,
"prizeCode": "mqa-openprizeb",
"behaviorProfileCode": "RETAIL_OPEN_ITEM",
"name": "MQA-OpenPrizeB",
"description": "MQA-OpenPrizeB",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 7.00,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "Retail",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-10T12:35:22+08:00",
"createdBy": "qa-issaclo",
"updatedAt": "2026-02-06T10:24:57+08:00",
"updatedBy": "qa-issaclo",
"tags": null,
"behaviorProfile": {
"profileCode": "RETAIL_OPEN_ITEM",
"profileName": "Retail (Open Item)",
"prizeCategory": "Retail",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "Retail target-only item, user choice delivery",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": {},
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
},
"outcomeAward": null,
"voidedBy": "v-wt-mark",
"voidedDtm": "2026-02-13T23:17:54.082765",
"voidedGamingDate": "",
"voidedRemark": "mark test",
"displayOnlyPromo": false,
"isVoided": true,
"isDirectOffer": false
}
]
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"status": true,
"errorCode": "",
"message": "Mock member profile",
"data": {
"accountNum": "",
"playerId": "",
"card_num": [],
"isActive": true,
"isDeactive": false,
"isBanned": false,
"currentTier": "Novel",
"onDayTierPoint": 150,
"lastLocn": "Table 22 (VIP)",
"eBonus": "0",
"clubState": 1,
"primaryHost": "MOCK HOST",
"primaryHostID": 1000001,
"secondaryHost": "MOCK SECOND HOST",
"secondaryHostID": 1000002,
"phoneList": [
{
"phoneNum": "+853-68282140",
"phoneType": "Mobile1"
}
],
"player": {
"title": "Mr",
"firstName": "Gary",
"lastName": "Chan",
"middleName": "David",
"nickName": "xxxx",
"image": ""
},
"attribute": {
"birthday": "1990-01-11",
"gender": "Male",
"height": "",
"weight": "",
"smoker": false,
"languageCode": "ENG"
},
"addressList": [],
"identityList": [],
"emailList": []
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"success": true,
"message": "Get backdate promotions successfully.",
"data": {
"customerId": "80038008",
"entitledPromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 1,
"awardsEntitled": 1,
"awardsRedeemLimit": 1,
"earnedPoints": 1,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-01T00:00",
"endDate": "2036-12-31T23:59",
"id": 38008,
"code": "PROMO-BIRTHDAY-2026",
"name": "Birthday Promotion",
"ruleId": 3800801,
"ruleName": "Birthday Redeem - choose 1 gift",
"ruleNameDesc": "Birthday Redeem - choose 1 gift",
"toEarn1Unit": 1,
"isRedeemAll": false,
"outcomeList": [
{
"key": 38008011,
"value": "it-test-fb-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"prizeCode": "it-test-fb-$1",
"name": "Birthday FnB Gift",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"prizeValue": 1,
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"behaviorProfile": {
"deliveryMode": "USER_CHOICE"
},
"bucketPrices": {
"d-comp": 1,
"m-comp": 1
},
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 38008012,
"value": "it-test-nnpc-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"prizeCode": "it-test-nnpc-$1",
"name": "Birthday NNPC Gift",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"prizeValue": 1,
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"behaviorProfile": {
"deliveryMode": "USER_CHOICE"
},
"bucketPrices": {
"d-comp": 1,
"m-comp": 1
},
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 38008013,
"value": "it-test-ebo-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"prizeCode": "it-test-ebo-$1",
"name": "Birthday eBonus Gift",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"prizeValue": 1,
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"behaviorProfile": {
"deliveryMode": "USER_CHOICE"
},
"bucketPrices": {
"d-comp": 1,
"m-comp": 1
},
"uniqueCodeRequirement": "NONE"
}
}
]
}
],
"eligiblePromotionList": []
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"success": true,
"message": "Get all promotions successfully.",
"data": {
"customerId": "80010766",
"eligiblePromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 1,
"awardsRedeemLimit": 1,
"earnedPoints": 8,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 10766001,
"code": "LP-MOCK-10766",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 10766024,
"ruleName": "GS-8pts-LCH Gift Set Abalone + Scallop",
"ruleNameDesc": "GS-8pts-LCH Gift Set Abalone + Scallop",
"toEarn1Unit": 8,
"isRedeemAll": false,
"outcomeList": [
{
"key": 10766069,
"value": "mock-10766-lch-gift-set-abalone-scallop",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
}
],
"entitledPromotionList": []
}
}
File diff suppressed because it is too large Load Diff
+771
View File
@@ -0,0 +1,771 @@
{
"success": true,
"message": "Get all promotions successfully.",
"data": {
"customerId": "800038008",
"eligiblePromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 1,
"awardsEntitled": 1,
"awardsRedeemLimit": 1,
"earnedPoints": 3860,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-30T06:00",
"endDate": "2026-04-27T05:59",
"id": 71768001,
"code": "LP-EE-CNYIPENG",
"name": "CNY iPhone Earn & Get-2026",
"ruleId": 71768001,
"ruleName": "17-19Feb-CNY iPhone Earn & Get-1,388Pts",
"ruleNameDesc": "17-19Feb-CNY iPhone Earn & Get-1,388Pts",
"toEarn1Unit": 1388,
"isRedeemAll": false,
"outcomeList": []
},
{
"awardUsedLimit": 1,
"awardsCount": 0,
"awardsEligible": 44,
"awardsEntitled": 43,
"awardsRedeemLimit": 999,
"earnedPoints": 3948,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768035,
"ruleName": "CNYGS-88pts-LCH Gift Set LCH-Bird's Nest Indonesia",
"ruleNameDesc": "CNYGS-88pts-LCH Gift Set LCH-Bird's Nest Indonesia",
"toEarn1Unit": 88,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768091,
"value": "lp-ee-cnygs26-88pts-birdsnest-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "lp-ee-cnygs26-88pts-birdsnest-me",
"behaviorProfileCode": "MERCH_AUTOSETTLE",
"name": "CNY26 88pts Birds Nest",
"description": null,
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 350.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "Merchandise",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"allowNegativeBalance": false,
"createdAt": "2026-02-16T12:47:41.824397+08:00",
"createdBy": "qa-adelinotou",
"createdByEmployeeId": null,
"updatedAt": "2026-02-16T12:47:41.824397+08:00",
"updatedBy": "qa-adelinotou",
"updatedByEmployeeId": null,
"tags": null,
"behaviorProfile": {
"profileCode": "MERCH_AUTOSETTLE",
"profileName": "Merchandise (AutoSettle)",
"prizeCategory": "Merchandise",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"description": "Merchandise target-only item auto-settled externally",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": "2026-02-16T12:47:41.694+08:00",
"effectiveTo": "2036-02-16T12:47:41.694+08:00",
"bucketPrices": {
"d-comp": 350,
"m-comp": 350
},
"compValue": 350,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
},
{
"awardUsedLimit": 2,
"awardsCount": 0,
"awardsEligible": 11,
"awardsEntitled": 9,
"awardsRedeemLimit": 999,
"earnedPoints": 4636,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768029,
"ruleName": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"ruleNameDesc": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768077,
"value": "lp-ee-cnygs26-388pts-moutai200ml-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "lp-ee-cnygs26-388pts-moutai200ml-me",
"behaviorProfileCode": "MERCH_AUTOSETTLE",
"name": "CNY26 388pts Moutai 200 ML",
"description": null,
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1000.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "Merchandise",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"allowNegativeBalance": false,
"createdAt": "2026-02-16T12:39:46.397008+08:00",
"createdBy": "qa-adelinotou",
"createdByEmployeeId": null,
"updatedAt": "2026-02-16T12:39:46.397008+08:00",
"updatedBy": "qa-adelinotou",
"updatedByEmployeeId": null,
"tags": null,
"behaviorProfile": {
"profileCode": "MERCH_AUTOSETTLE",
"profileName": "Merchandise (AutoSettle)",
"prizeCategory": "Merchandise",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"description": "Merchandise target-only item auto-settled externally",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": "2026-02-16T12:39:46.271+08:00",
"effectiveTo": "2036-02-16T12:39:46.271+08:00",
"bucketPrices": {
"d-comp": 1000,
"m-comp": 1000
},
"compValue": 1000,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 71768078,
"value": "lp-ee-cnygs26-388pts-1000-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "lp-ee-cnygs26-388pts-1000-pc",
"behaviorProfileCode": "NNPC_OPEN_ITEM",
"name": "CNY26 388pts 1000 PC",
"description": null,
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1000.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2026-02-16T12:33:30.522397+08:00",
"createdBy": "qa-adelinotou",
"createdByEmployeeId": null,
"updatedAt": "2026-02-16T12:33:30.522398+08:00",
"updatedBy": "qa-adelinotou",
"updatedByEmployeeId": null,
"tags": null,
"behaviorProfile": {
"profileCode": "NNPC_OPEN_ITEM",
"profileName": "NNPC (Open Item)",
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "NNPC target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": "2026-02-16T12:34:51.704+08:00",
"effectiveTo": "2036-02-16T12:34:51.704+08:00",
"bucketPrices": {
"d-comp": 1000,
"m-comp": 1000
},
"compValue": 1000,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 71768079,
"value": "lp-ee-cnygs26-388pts-1000-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "lp-ee-cnygs26-388pts-1000-eb",
"behaviorProfileCode": "EBONUS_AUTOSETTLE",
"name": "CNY26 388pts 1000 EB",
"description": null,
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1000.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"allowNegativeBalance": false,
"createdAt": "2026-02-16T12:34:53.967027+08:00",
"createdBy": "qa-adelinotou",
"createdByEmployeeId": null,
"updatedAt": "2026-02-16T12:34:53.967027+08:00",
"updatedBy": "qa-adelinotou",
"updatedByEmployeeId": null,
"tags": null,
"behaviorProfile": {
"profileCode": "EBONUS_AUTOSETTLE",
"profileName": "eBonus (AutoSettle)",
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"description": "eBonus target-only item auto-settled externally",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": true
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": "2026-02-16T12:36:15.15+08:00",
"effectiveTo": "2036-02-16T12:36:15.15+08:00",
"bucketPrices": {
"d-comp": 1000,
"m-comp": 1000
},
"compValue": 1000,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 5,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 3,
"minOutcome": 1,
"startDate": "2026-03-16T06:00",
"endDate": "2026-03-17T06:00",
"id": 1,
"code": "PROMO1001",
"name": "Promotion 1",
"ruleId": 17,
"ruleName": "Rule_13pts",
"ruleNameDesc": "daily 13pts",
"toEarn1Unit": 13,
"isRedeemAll": false,
"outcomeList": [
{
"key": 3579,
"value": "it-test-fb-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-fb-$1",
"behaviorProfileCode": "FNB_OPEN_ITEM",
"name": "IT-TEST-FB-$1",
"description": "IT-TEST-FB-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P3D"
}
],
"isEnabled": true
},
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T11:00:53.232361+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2026-02-04T18:04:07.911273+08:00",
"updatedBy": "qa-randyng",
"updatedByEmployeeId": "171853",
"tags": null,
"behaviorProfile": {
"profileCode": "FNB_OPEN_ITEM",
"profileName": "FnB (Open Item)",
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "Food and Beverage target-only item, user choice delivery",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": {},
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 3580,
"value": "it-test-nnpc-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-nnpc-$1",
"behaviorProfileCode": "NNPC_OPEN_ITEM",
"name": "it-test-nnpc-$1",
"description": "test nnpc",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": true
},
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-12-01T16:33:18.420102+08:00",
"createdBy": "qa-henrylai",
"createdByEmployeeId": "165114",
"updatedAt": "2025-12-18T17:56:51.783392+08:00",
"updatedBy": "qa-henrylai",
"updatedByEmployeeId": "165114",
"tags": null,
"behaviorProfile": {
"profileCode": "NNPC_OPEN_ITEM",
"profileName": "NNPC (Open Item)",
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "NNPC target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 3581,
"value": "it-test-ebo-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-ebo-$1",
"behaviorProfileCode": "EBONUS_OPEN_ITEM",
"name": "IT-TEST-EBO-$1",
"description": "IT-TEST-EBO-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P7D"
}
],
"isEnabled": true
},
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T09:48:33.139099+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2025-12-26T15:20:31.57588+08:00",
"updatedBy": "qa-issaclo",
"updatedByEmployeeId": "165148",
"tags": null,
"behaviorProfile": {
"profileCode": "EBONUS_OPEN_ITEM",
"profileName": "eBonus (Open Item)",
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "eBonus target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": true
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 10,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-03-16T06:00",
"endDate": "2026-03-23T05:59:59.999",
"id": 8,
"code": "EARN_N_GET",
"name": "Daily & Weeklyl Earn&Get",
"ruleId": 512,
"ruleName": "Weekly_100pts",
"ruleNameDesc": "Weekly_100pts",
"toEarn1Unit": 100,
"isRedeemAll": false,
"outcomeList": [
{
"key": 5061,
"value": "it-test-ebo-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-ebo-$1",
"behaviorProfileCode": "EBONUS_OPEN_ITEM",
"name": "IT-TEST-EBO-$1",
"description": "IT-TEST-EBO-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P7D"
}
],
"isEnabled": true
},
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T09:48:33.139099+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2025-12-26T15:20:31.57588+08:00",
"updatedBy": "qa-issaclo",
"updatedByEmployeeId": "165148",
"tags": null,
"behaviorProfile": {
"profileCode": "EBONUS_OPEN_ITEM",
"profileName": "eBonus (Open Item)",
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "eBonus target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": true
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 5062,
"value": "it-test-fb-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-fb-$1",
"behaviorProfileCode": "FNB_OPEN_ITEM",
"name": "IT-TEST-FB-$1",
"description": "IT-TEST-FB-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P3D"
}
],
"isEnabled": true
},
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T11:00:53.232361+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2026-02-04T18:04:07.911273+08:00",
"updatedBy": "qa-randyng",
"updatedByEmployeeId": "171853",
"tags": null,
"behaviorProfile": {
"profileCode": "FNB_OPEN_ITEM",
"profileName": "FnB (Open Item)",
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "Food and Beverage target-only item, user choice delivery",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": {},
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 5063,
"value": "it-test-nnpc-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-nnpc-$1",
"behaviorProfileCode": "NNPC_OPEN_ITEM",
"name": "it-test-nnpc-$1",
"description": "test nnpc",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": true
},
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-12-01T16:33:18.420102+08:00",
"createdBy": "qa-henrylai",
"createdByEmployeeId": "165114",
"updatedAt": "2025-12-18T17:56:51.783392+08:00",
"updatedBy": "qa-henrylai",
"updatedByEmployeeId": "165114",
"tags": null,
"behaviorProfile": {
"profileCode": "NNPC_OPEN_ITEM",
"profileName": "NNPC (Open Item)",
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "NNPC target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
}
],
"entitledPromotionList": []
}
}
+401
View File
@@ -0,0 +1,401 @@
{
"success": true,
"message": "Get all promotions successfully.",
"data": {
"customerId": "800048718",
"eligiblePromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768024,
"ruleName": "CNYGS-188pts-Hennessy VSOP + LCH Dried Scallop",
"ruleNameDesc": "CNYGS-188pts-Hennessy VSOP + LCH Dried Scallop",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768069,
"value": "lp-ee-cnygs26-188pts-hennessyvsop-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768070,
"value": "lp-ee-cnygs26-188pts-lchscallop-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768025,
"ruleName": "CNYGS-188pts-LCH Gift Set LCH-Fish Maw & Scallop",
"ruleNameDesc": "CNYGS-188pts-LCH Gift Set LCH-Fish Maw & Scallop",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768071,
"value": "lp-ee-cnygs26-188pts-lchgiftset-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768026,
"ruleName": "CNYGS-188pts-Martell + LCH-Can Abalone",
"ruleNameDesc": "CNYGS-188pts-Martell + LCH-Can Abalone",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768072,
"value": "lp-ee-cnygs26-188pts-martellnoblige-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768073,
"value": "lp-ee-cnygs26-188pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768027,
"ruleName": "CNYGS-388pts-LCH-Gift Set Fish Maw + Scallop",
"ruleNameDesc": "CNYGS-388pts-LCH-Gift Set Fish Maw + Scallop",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768074,
"value": "lp-ee-cnygs26-388pts-lchdriedseafood-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768028,
"ruleName": "CNYGS-388pts-Martell + LCH Abalone",
"ruleNameDesc": "CNYGS-388pts-Martell + LCH Abalone",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768075,
"value": "lp-ee-cnygs26-388pts-martellcny-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768076,
"value": "lp-ee-cnygs26-388pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768029,
"ruleName": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"ruleNameDesc": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768077,
"value": "lp-ee-cnygs26-388pts-moutai200ml-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768078,
"value": "lp-ee-cnygs26-388pts-1000-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768079,
"value": "lp-ee-cnygs26-388pts-1000-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768030,
"ruleName": "CNYGS-688pts-Hennessy XO + $2,200 NP/EB",
"ruleNameDesc": "CNYGS-688pts-Hennessy XO + $2,200 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768080,
"value": "lp-ee-cnygs26-688pts-hennessyxocny-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768081,
"value": "lp-ee-cnygs26-688pts-2200-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768082,
"value": "lp-ee-cnygs26-688pts-2200-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768031,
"ruleName": "CNYGS-688pts-Moutai 500 ML + LCH Gift Set +Abalone",
"ruleNameDesc": "CNYGS-688pts-Moutai 500 ML + LCH Gift Set +Abalone",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768083,
"value": "lp-ee-cnygs26-688pts-moutai500ml-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768084,
"value": "lp-ee-cnygs26-688pts-lchmushroom-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768085,
"value": "lp-ee-cnygs26-688pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768032,
"ruleName": "CNYGS-688pts-Whisky Live + $2,000 NP/EB",
"ruleNameDesc": "CNYGS-688pts-Whisky Live + $2,000 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768086,
"value": "lp-ee-cnygs26-688pts-whiskylive-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768087,
"value": "lp-ee-cnygs26-688pts-2000-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768088,
"value": "lp-ee-cnygs26-688pts-2000-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
}
]
}
}
+402
View File
@@ -0,0 +1,402 @@
{
"success": true,
"message": "Get all promotions successfully.",
"data": {
"customerId": "80010991",
"eligiblePromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 1,
"awardsEntitled": 1,
"awardsRedeemLimit": 1,
"earnedPoints": 5,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-01T00:00",
"endDate": "2036-01-01T00:00",
"id": 10991,
"code": "LP-MOCK-REPRINT-CHAIN",
"name": "Mock Promotion Reprint Chain",
"ruleId": 1099101,
"ruleName": "Mock-5pts-$20 GLP F&B voucher",
"ruleNameDesc": "Mock-5pts-$20 GLP F&B voucher",
"toEarn1Unit": 5,
"isRedeemAll": false,
"outcomeList": [
{
"key": 10991011,
"value": "lp-ee-nsuwr-5pts-20-fb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "lp-ee-nsuwr-5pts-20-fb",
"behaviorProfileCode": "FNB_OPEN_ITEM",
"name": "NSUWR-5PTS-$20 GLP F&B voucher",
"description": "NSUWR-5PTS-$20 GLP F&B voucher",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 20.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2026-03-21T12:40:00+08:00",
"createdBy": "mock-gateway",
"createdByEmployeeId": null,
"updatedAt": "2026-03-21T12:40:00+08:00",
"updatedBy": "mock-gateway",
"updatedByEmployeeId": null,
"tags": null,
"behaviorProfile": {
"profileCode": "FNB_OPEN_ITEM",
"profileName": "FnB (Open Item)",
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "Food and Beverage target-only item, user choice delivery",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": {
"d-comp": 20.0,
"m-comp": 20.0
},
"compValue": 20.0,
"note": "Dedicated mock fixture for promotion redeem -> history -> reprint chain",
"uniqueCodeRequirement": "NONE"
}
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 1,
"awardsEntitled": 1,
"awardsRedeemLimit": 1,
"earnedPoints": 1,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-01T00:00",
"endDate": "2036-01-01T00:00",
"id": 11225,
"code": "LP-MOCK-AUTOSETTLE-CHECK",
"name": "Mock Promotion AutoSettle Check",
"ruleId": 1122502,
"ruleName": "Mock-1pt-qa-ebonus-auto",
"ruleNameDesc": "Mock-1pt-qa-ebonus-auto",
"toEarn1Unit": 1,
"isRedeemAll": false,
"outcomeList": [
{
"key": 11225021,
"value": "qa-ebonus-auto",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "qa-ebonus-auto",
"behaviorProfileCode": "EBONUS_AUTOSETTLE",
"name": "qa-ebonus-auto",
"description": "for automation testing",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": false
},
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"allowNegativeBalance": false,
"createdAt": "2026-01-16T11:32:27.761213+08:00",
"createdBy": "qa-henrylai",
"createdByEmployeeId": null,
"updatedAt": "2026-01-16T11:32:27.761213+08:00",
"updatedBy": "qa-henrylai",
"updatedByEmployeeId": null,
"tags": [],
"behaviorProfile": {
"profileCode": "EBONUS_AUTOSETTLE",
"profileName": "eBonus (AutoSettle)",
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "EXTERNAL_ONLY",
"description": "eBonus target-only item auto-settled externally",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": true
},
"localizations": null,
"messages": null,
"isPrintVoucher": false,
"isExcludeGlobalMessages": null,
"effectiveFrom": "2026-01-16T11:32:27.555+08:00",
"effectiveTo": "2036-01-16T11:32:27.562+08:00",
"bucketPrices": {},
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 3,
"awardsEntitled": 3,
"awardsRedeemLimit": 3,
"earnedPoints": 100,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": true,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 3,
"minOutcome": 1,
"startDate": "2026-01-01T00:00",
"endDate": "2036-01-01T00:00",
"id": 11330,
"code": "LP-MOCK-USER-CHOICE-3-OUTCOMES",
"name": "Mock Promotion 3 User Choice Outcomes",
"ruleId": 1133001,
"ruleName": "Mock-3-user-choice-prizes",
"ruleNameDesc": "Mock-3-user-choice-prizes",
"toEarn1Unit": 1,
"isRedeemAll": false,
"outcomeList": [
{
"key": 11330011,
"value": "it-test-fb-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-fb-$1",
"behaviorProfileCode": "FNB_OPEN_ITEM",
"name": "IT-TEST-FB-$1",
"description": "IT-TEST-FB-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P3D"
}
],
"isEnabled": true
},
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T11:00:53.232361+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2026-02-04T18:04:07.911273+08:00",
"updatedBy": "qa-randyng",
"updatedByEmployeeId": "171853",
"tags": null,
"behaviorProfile": {
"profileCode": "FNB_OPEN_ITEM",
"profileName": "FnB (Open Item)",
"prizeCategory": "FnB",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "Food and Beverage target-only item, user choice delivery",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": {},
"compValue": null,
"note": "Dedicated mock fixture for 3 USER_CHOICE promotion redeem testing",
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 11330012,
"value": "it-test-nnpc-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-nnpc-$1",
"behaviorProfileCode": "NNPC_OPEN_ITEM",
"name": "it-test-nnpc-$1",
"description": "test nnpc",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P90D"
}
],
"isEnabled": true
},
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-12-01T16:33:18.420102+08:00",
"createdBy": "qa-henrylai",
"createdByEmployeeId": "165114",
"updatedAt": "2025-12-18T17:56:51.783392+08:00",
"updatedBy": "qa-henrylai",
"updatedByEmployeeId": "165114",
"tags": null,
"behaviorProfile": {
"profileCode": "NNPC_OPEN_ITEM",
"profileName": "NNPC (Open Item)",
"prizeCategory": "NNPC",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "NNPC target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": false
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
},
{
"key": 11330013,
"value": "it-test-ebo-$1",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": {
"correlationId": null,
"prizeCode": "it-test-ebo-$1",
"behaviorProfileCode": "EBONUS_OPEN_ITEM",
"name": "IT-TEST-EBO-$1",
"description": "IT-TEST-EBO-$1",
"source": "INTERNAL",
"isVoidable": true,
"isEnabled": true,
"imageUrl": null,
"prizeValue": 1.0,
"expiryConfig": {
"strategy": "MIN",
"rules": [
{
"type": "ROLLING_DURATION",
"value": "P7D"
}
],
"isEnabled": true
},
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"allowNegativeBalance": false,
"createdAt": "2025-11-04T09:48:33.139099+08:00",
"createdBy": "qa-nicolefeng",
"createdByEmployeeId": "173094",
"updatedAt": "2025-12-26T15:20:31.57588+08:00",
"updatedBy": "qa-issaclo",
"updatedByEmployeeId": "165148",
"tags": null,
"behaviorProfile": {
"profileCode": "EBONUS_OPEN_ITEM",
"profileName": "eBonus (Open Item)",
"prizeCategory": "eBonus",
"usageBehavior": "TARGET_ONLY",
"deliveryMode": "USER_CHOICE",
"description": "eBonus target-only item, user chooses wallet or external",
"isActive": true,
"allowNegativeBalance": false,
"isBucket": false,
"isEbonus": true
},
"localizations": null,
"messages": null,
"isPrintVoucher": null,
"isExcludeGlobalMessages": null,
"effectiveFrom": null,
"effectiveTo": null,
"bucketPrices": null,
"compValue": null,
"note": null,
"uniqueCodeRequirement": "NONE"
}
}
]
}
],
"entitledPromotionList": []
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
{
"success": true,
"message": "Get all promotions successfully.",
"data": {
"customerId": "800048718",
"eligiblePromotionList": [
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768024,
"ruleName": "CNYGS-188pts-Hennessy VSOP + LCH Dried Scallop",
"ruleNameDesc": "CNYGS-188pts-Hennessy VSOP + LCH Dried Scallop",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768069,
"value": "lp-ee-cnygs26-188pts-hennessyvsop-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768070,
"value": "lp-ee-cnygs26-188pts-lchscallop-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768025,
"ruleName": "CNYGS-188pts-LCH Gift Set LCH-Fish Maw & Scallop",
"ruleNameDesc": "CNYGS-188pts-LCH Gift Set LCH-Fish Maw & Scallop",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768071,
"value": "lp-ee-cnygs26-188pts-lchgiftset-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768026,
"ruleName": "CNYGS-188pts-Martell + LCH-Can Abalone",
"ruleNameDesc": "CNYGS-188pts-Martell + LCH-Can Abalone",
"toEarn1Unit": 188,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768072,
"value": "lp-ee-cnygs26-188pts-martellnoblige-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768073,
"value": "lp-ee-cnygs26-188pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 1,
"minOutcome": 1,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768027,
"ruleName": "CNYGS-388pts-LCH-Gift Set Fish Maw + Scallop",
"ruleNameDesc": "CNYGS-388pts-LCH-Gift Set Fish Maw + Scallop",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768074,
"value": "lp-ee-cnygs26-388pts-lchdriedseafood-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768028,
"ruleName": "CNYGS-388pts-Martell + LCH Abalone",
"ruleNameDesc": "CNYGS-388pts-Martell + LCH Abalone",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768075,
"value": "lp-ee-cnygs26-388pts-martellcny-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768076,
"value": "lp-ee-cnygs26-388pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 999,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768029,
"ruleName": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"ruleNameDesc": "CNYGS-388pts-Moutai 200ML + $1,000 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768077,
"value": "lp-ee-cnygs26-388pts-moutai200ml-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768078,
"value": "lp-ee-cnygs26-388pts-1000-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768079,
"value": "lp-ee-cnygs26-388pts-1000-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768030,
"ruleName": "CNYGS-688pts-Hennessy XO + $2,200 NP/EB",
"ruleNameDesc": "CNYGS-688pts-Hennessy XO + $2,200 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768080,
"value": "lp-ee-cnygs26-688pts-hennessyxocny-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768081,
"value": "lp-ee-cnygs26-688pts-2200-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768082,
"value": "lp-ee-cnygs26-688pts-2200-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768031,
"ruleName": "CNYGS-688pts-Moutai 500 ML + LCH Gift Set +Abalone",
"ruleNameDesc": "CNYGS-688pts-Moutai 500 ML + LCH Gift Set +Abalone",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768083,
"value": "lp-ee-cnygs26-688pts-moutai500ml-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768084,
"value": "lp-ee-cnygs26-688pts-lchmushroom-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768085,
"value": "lp-ee-cnygs26-688pts-lchabalone-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
},
{
"awardUsedLimit": 0,
"awardsCount": 0,
"awardsEligible": 0,
"awardsEntitled": 0,
"awardsRedeemLimit": 688,
"earnedPoints": 0,
"isDirectOffer": false,
"isEligible": true,
"isEntitled": false,
"isOptIn": false,
"isOptedInByPlayer": false,
"isRedeemCompleted": false,
"maxOutcome": 2,
"minOutcome": 2,
"startDate": "2026-01-27T06:00",
"endDate": "2026-04-30T06:00",
"id": 71768004,
"code": "LP-EE-CNYGS26",
"name": "CNY Gift Set Earn & Get-2026",
"ruleId": 71768032,
"ruleName": "CNYGS-688pts-Whisky Live + $2,000 NP/EB",
"ruleNameDesc": "CNYGS-688pts-Whisky Live + $2,000 NP/EB",
"toEarn1Unit": 388,
"isRedeemAll": false,
"outcomeList": [
{
"key": 71768086,
"value": "lp-ee-cnygs26-688pts-whiskylive-me",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768087,
"value": "lp-ee-cnygs26-688pts-2000-pc",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
},
{
"key": 71768088,
"value": "lp-ee-cnygs26-688pts-2000-eb",
"isInventoryCheck": null,
"qtyOnHand": null,
"prize": null
}
]
}
],
"entitledPromotionList": []
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"errorCode": "",
"errorMsg": "",
"accessToken": "",
"refreshToken": "",
"period": 3600
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
const ldap = require('ldapjs')
function createMockLdapService({ ldapConfig }) {
const {
ldapEnabled,
ldapHost,
ldapPort,
ldapBaseDn,
ldapManagerDn,
ldapGroupBaseDn,
ldapManagerPassword,
ldapAllowAnyUserMode,
ldapAllowAnyUserOu,
ldapAllowAnyUserGroupCn,
ldapAllowAnyUserMailDomain,
ldapForceNoSuchObject,
ldapForceNoSuchObjectMatch,
} = ldapConfig
function normalizeDn(value) {
const raw = `${value || ''}`.trim()
if (!raw) {
return ''
}
try {
return ldap.parseDN(raw).toString().toLowerCase()
} catch (error) {
return raw.toLowerCase()
}
}
function splitDnList(value) {
if (Array.isArray(value)) {
return value
.map((entry) => `${entry || ''}`.trim())
.filter(Boolean)
}
if (typeof value !== 'string') {
return []
}
return value
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
}
function dedupeDnValues(values) {
const seen = new Set()
const result = []
for (const value of values) {
const normalized = normalizeDn(value)
if (!normalized || seen.has(normalized)) {
continue
}
seen.add(normalized)
result.push(value)
}
return result
}
function extractCnFromDn(dn) {
const input = `${dn || ''}`.trim()
if (!input) {
return ''
}
const firstPart = input.split(',')[0] || ''
const match = /^cn=([^,]+)$/i.exec(firstPart.trim())
return match ? match[1] : ''
}
function buildDefaultLdapUser() {
const username =
process.env.MOCK_LDAP_USER_USERNAME ||
process.env.MOCK_LDAP_DEFAULT_USER_SAMACCOUNTNAME ||
'mock.user'
const dn =
process.env.MOCK_LDAP_USER_DN ||
process.env.MOCK_LDAP_DEFAULT_USER_DN ||
`cn=${username},${ldapAllowAnyUserOu},${ldapBaseDn}`
const memberOfRaw =
process.env.MOCK_LDAP_USER_MEMBER_OF ||
process.env.MOCK_LDAP_DEFAULT_USER_MEMBER_OF ||
process.env.MOCK_LDAP_USER_GROUPS ||
`cn=${ldapAllowAnyUserGroupCn},${ldapGroupBaseDn}`
const memberOf = dedupeDnValues(splitDnList(memberOfRaw))
return {
dn,
sAMAccountName: username,
cn:
process.env.MOCK_LDAP_USER_CN ||
process.env.MOCK_LDAP_DEFAULT_USER_CN ||
'Mock User',
mail:
process.env.MOCK_LDAP_USER_MAIL ||
process.env.MOCK_LDAP_DEFAULT_USER_MAIL ||
'mock.user@macausjm-glp.com',
password:
process.env.MOCK_LDAP_USER_PASSWORD ||
process.env.MOCK_LDAP_DEFAULT_USER_PASSWORD ||
'MockUser@123',
memberOf,
}
}
function normalizeLdapUser(item, fallbackUser) {
const username = `${item?.sAMAccountName || item?.username || ''}`.trim()
if (!username) {
return null
}
const dn = `${item?.dn || `cn=${username},${ldapAllowAnyUserOu},${ldapBaseDn}`}`.trim()
if (!dn) {
return null
}
const memberOfRaw = item?.memberOf || item?.groups || fallbackUser.memberOf
const memberOf = dedupeDnValues(splitDnList(memberOfRaw))
return {
dn,
sAMAccountName: username,
cn: `${item?.cn || username}`.trim(),
mail: `${item?.mail || `${username}@macausjm-glp.com`}`.trim(),
password: `${item?.password || fallbackUser.password}`,
memberOf,
}
}
function buildLdapUsers() {
const fallbackUser = buildDefaultLdapUser()
const rawUsersJson = process.env.MOCK_LDAP_USERS_JSON
if (!rawUsersJson) {
return [fallbackUser]
}
try {
const parsed = JSON.parse(rawUsersJson)
if (!Array.isArray(parsed)) {
return [fallbackUser]
}
const users = parsed
.map((item) => normalizeLdapUser(item, fallbackUser))
.filter((item) => Boolean(item))
return users.length > 0 ? users : [fallbackUser]
} catch (error) {
console.error(`Failed to parse MOCK_LDAP_USERS_JSON: ${error.message}`)
return [fallbackUser]
}
}
function normalizeGroupEntry(entry) {
if (!entry || typeof entry !== 'object') {
return null
}
const dn = `${entry.dn || ''}`.trim()
if (!dn) {
return null
}
const member = dedupeDnValues(splitDnList(entry.member || []))
const cn = `${entry.cn || extractCnFromDn(dn) || 'UNKNOWN'}`.trim()
return {
dn,
cn,
member,
}
}
function buildLdapGroups(users) {
const groupMap = new Map()
for (const user of users) {
for (const groupDn of user.memberOf) {
const normalizedGroupDn = normalizeDn(groupDn)
if (!normalizedGroupDn) {
continue
}
if (!groupMap.has(normalizedGroupDn)) {
groupMap.set(normalizedGroupDn, {
dn: groupDn,
cn: extractCnFromDn(groupDn) || 'UNKNOWN',
member: [],
})
}
const group = groupMap.get(normalizedGroupDn)
if (!group.member.some((value) => normalizeDn(value) === normalizeDn(user.dn))) {
group.member.push(user.dn)
}
}
}
const rawGroupsJson = process.env.MOCK_LDAP_GROUPS_JSON
if (rawGroupsJson) {
try {
const parsedGroups = JSON.parse(rawGroupsJson)
if (Array.isArray(parsedGroups)) {
for (const item of parsedGroups) {
const group = normalizeGroupEntry(item)
if (!group) {
continue
}
const normalizedDn = normalizeDn(group.dn)
if (!groupMap.has(normalizedDn)) {
groupMap.set(normalizedDn, group)
continue
}
const existing = groupMap.get(normalizedDn)
existing.cn = group.cn || existing.cn
existing.member = dedupeDnValues([...existing.member, ...group.member])
}
}
} catch (error) {
console.error(`Failed to parse MOCK_LDAP_GROUPS_JSON: ${error.message}`)
}
}
return [...groupMap.values()]
}
const ldapUsers = buildLdapUsers()
const ldapGroups = buildLdapGroups(ldapUsers)
const ldapUsersByDn = new Map(ldapUsers.map((user) => [normalizeDn(user.dn), user]))
const normalizedManagerDn = normalizeDn(ldapManagerDn)
function getDnDepth(value) {
const normalized = normalizeDn(value)
if (!normalized) {
return 0
}
try {
return ldap.parseDN(normalized).rdns.length
} catch (error) {
return normalized.split(',').length
}
}
function isDnInScope(entryDn, baseDn, scope) {
const normalizedEntryDn = normalizeDn(entryDn)
const normalizedBaseDn = normalizeDn(baseDn)
if (!normalizedEntryDn || !normalizedBaseDn) {
return false
}
if (scope === 'base') {
return normalizedEntryDn === normalizedBaseDn
}
if (scope === 'one') {
if (!normalizedEntryDn.endsWith(`,${normalizedBaseDn}`)) {
return false
}
return getDnDepth(normalizedEntryDn) === getDnDepth(normalizedBaseDn) + 1
}
return (
normalizedEntryDn === normalizedBaseDn ||
normalizedEntryDn.endsWith(`,${normalizedBaseDn}`)
)
}
function resolveSearchBase(searchBase) {
const raw = `${searchBase || ''}`.trim()
if (!raw) {
return ldapBaseDn
}
const normalizedRaw = normalizeDn(raw)
const normalizedBase = normalizeDn(ldapBaseDn)
if (!normalizedRaw) {
return ldapBaseDn
}
if (normalizedRaw === normalizedBase || normalizedRaw.endsWith(`,${normalizedBase}`)) {
return raw
}
if (!/dc=/i.test(raw)) {
return `${raw},${ldapBaseDn}`
}
return raw
}
function buildUserAttributes(user) {
return {
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
distinguishedName: user.dn,
sAMAccountName: user.sAMAccountName,
employeeId: '123456789',
cn: user.cn,
mail: user.mail,
memberOf: user.memberOf,
}
}
function buildGroupAttributes(group) {
return {
objectClass: ['top', 'group'],
distinguishedName: group.dn,
cn: group.cn,
member: group.member,
}
}
function matchesFilter(filter, attributes) {
try {
return filter.matches(attributes)
} catch (error) {
return false
}
}
function dedupeStringValues(values) {
const seen = new Set()
const result = []
for (const value of values) {
const normalized = `${value || ''}`.trim().toLowerCase()
if (!normalized || seen.has(normalized)) {
continue
}
seen.add(normalized)
result.push(`${value || ''}`.trim())
}
return result
}
function extractEqFilterValues(filter, attributeName, result = []) {
if (!filter || typeof filter !== 'object') {
return result
}
const attr = `${filter.attribute || ''}`.trim().toLowerCase()
if (attr === `${attributeName}`.toLowerCase()) {
const rawValue = filter.value
const value = Buffer.isBuffer(rawValue)
? rawValue.toString('utf8').trim()
: `${rawValue || ''}`.trim()
if (value) {
result.push(value)
}
}
if (Array.isArray(filter.filters)) {
for (const childFilter of filter.filters) {
extractEqFilterValues(childFilter, attributeName, result)
}
}
return result
}
function sanitizeSamAccountName(value) {
return `${value || ''}`
.trim()
.replace(/[\0-\x1f\x7f,=+<>#;"\\]/g, '')
}
function buildAnyModeUser(username) {
const normalizedUsername = sanitizeSamAccountName(username)
if (!normalizedUsername) {
return null
}
const defaultGroupDn = `cn=${ldapAllowAnyUserGroupCn},${ldapGroupBaseDn}`
const configuredGroupDns = ldapGroups.map((group) => group.dn)
const memberOf = dedupeDnValues(
configuredGroupDns.length > 0 ? configuredGroupDns : [defaultGroupDn],
)
return {
dn: `cn=${normalizedUsername},${ldapAllowAnyUserOu},${ldapBaseDn}`,
sAMAccountName: normalizedUsername,
cn: normalizedUsername,
mail: `${normalizedUsername}@${ldapAllowAnyUserMailDomain}`,
memberOf,
}
}
function buildAnyModeGroups(memberDn) {
const normalizedMemberDn = `${memberDn || ''}`.trim()
if (!normalizedMemberDn) {
return []
}
if (ldapGroups.length === 0) {
return [
{
dn: `cn=${ldapAllowAnyUserGroupCn},${ldapGroupBaseDn}`,
cn: ldapAllowAnyUserGroupCn,
member: [normalizedMemberDn],
},
]
}
return ldapGroups.map((group) => ({
dn: group.dn,
cn: group.cn,
member: [normalizedMemberDn],
}))
}
function handleLdapBind(req, res, next) {
const bindDn = req.dn?.toString() || ''
const normalizedBindDn = normalizeDn(bindDn)
const credentials = `${req.credentials || ''}`
if (!normalizedBindDn) {
return next(new ldap.InvalidCredentialsError())
}
if (ldapAllowAnyUserMode) {
res.end()
return next()
}
if (normalizedBindDn === normalizedManagerDn) {
if (!ldapManagerPassword || credentials === ldapManagerPassword) {
res.end()
return next()
}
return next(new ldap.InvalidCredentialsError())
}
const user = ldapUsersByDn.get(normalizedBindDn)
if (!user || credentials !== `${user.password}`) {
return next(new ldap.InvalidCredentialsError())
}
res.end()
return next()
}
function handleLdapSearch(req, res, next) {
const searchBase = resolveSearchBase(req.dn?.toString())
const scope = req.scope || 'sub'
if (ldapForceNoSuchObject) {
// Reproduce QA's "LDAP error code 32 - NO_OBJECT" from real AD.
// The "best match of" suffix mirrors the AD DSID-0310028D / NameErr output seen on QA.
const match = ldapForceNoSuchObjectMatch || 'OU=macauin-glp,DC=com'
const error = new ldap.NoSuchObjectError()
error.message = `NO_OBJECT: best match of: '${match}'\n000001`
return next(error)
}
if (ldapAllowAnyUserMode) {
let hasAnyModeResult = false
const usernames = dedupeStringValues(extractEqFilterValues(req.filter, 'sAMAccountName'))
const memberDns = dedupeDnValues(extractEqFilterValues(req.filter, 'member'))
for (const username of usernames) {
const anyModeUser = buildAnyModeUser(username)
if (!anyModeUser || !isDnInScope(anyModeUser.dn, searchBase, scope)) {
continue
}
const attributes = buildUserAttributes(anyModeUser)
if (!matchesFilter(req.filter, attributes)) {
continue
}
hasAnyModeResult = true
res.send({
dn: anyModeUser.dn,
attributes,
})
}
for (const memberDn of memberDns) {
for (const anyModeGroup of buildAnyModeGroups(memberDn)) {
if (!isDnInScope(anyModeGroup.dn, searchBase, scope)) {
continue
}
const attributes = buildGroupAttributes(anyModeGroup)
if (!matchesFilter(req.filter, attributes)) {
continue
}
hasAnyModeResult = true
res.send({
dn: anyModeGroup.dn,
attributes,
})
}
}
if (hasAnyModeResult) {
res.end()
return next()
}
}
for (const user of ldapUsers) {
if (!isDnInScope(user.dn, searchBase, scope)) {
continue
}
const attributes = buildUserAttributes(user)
if (matchesFilter(req.filter, attributes)) {
res.send({
dn: user.dn,
attributes,
})
}
}
for (const group of ldapGroups) {
if (!isDnInScope(group.dn, searchBase, scope)) {
continue
}
const attributes = buildGroupAttributes(group)
if (matchesFilter(req.filter, attributes)) {
res.send({
dn: group.dn,
attributes,
})
}
}
res.end()
return next()
}
let ldapServer = null
function start() {
if (!ldapEnabled) {
return null
}
if (ldapServer) {
return ldapServer
}
ldapServer = ldap.createServer()
ldapServer.bind(ldapBaseDn, handleLdapBind)
ldapServer.bind('', handleLdapBind)
ldapServer.search(ldapBaseDn, handleLdapSearch)
ldapServer.search('', handleLdapSearch)
ldapServer.on('error', (error) => {
console.error(`LDAP server error: ${error.message}`)
})
ldapServer.listen(ldapPort, ldapHost, () => {
console.log(
`Mock LDAP listening on ldap://${ldapHost}:${ldapPort} (base=${ldapBaseDn}, users=${ldapUsers.length}, groups=${ldapGroups.length})`,
)
})
return ldapServer
}
function getHealthInfo() {
return {
enabled: ldapEnabled,
allowAnyUserMode: ldapAllowAnyUserMode,
url: `ldap://${ldapHost}:${ldapPort}`,
managerDn: ldapManagerDn,
baseDn: ldapBaseDn,
users: ldapUsers.map((user) => ({
sAMAccountName: user.sAMAccountName,
dn: user.dn,
})),
groups: ldapGroups.map((group) => ({
cn: group.cn,
dn: group.dn,
})),
}
}
return {
start,
getHealthInfo,
}
}
module.exports = { createMockLdapService }
+48
View File
@@ -0,0 +1,48 @@
const fs = require('fs')
const path = require('path')
const LOG_FILE = path.join(__dirname, '..', 'mock_gateway.log')
function ts() {
return new Date().toISOString()
}
function writeToFile(line) {
try {
fs.appendFileSync(LOG_FILE, line + '\n', 'utf8')
} catch (_) {
// ignore file write errors so logging never breaks the gateway
}
}
/**
* Unified request logger. Prints to console and appends to mock_gateway.log.
*/
function log(...args) {
const line = `[${ts()}] ${args
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
.join(' ')}`
// eslint-disable-next-line no-console
console.log(line)
writeToFile(line)
}
/**
* Express middleware that logs every incoming request (method, path, query, body size).
*/
function requestLogger(req, res, next) {
const start = Date.now()
const accountNum =
req.body?.data?.accountNum || req.body?.accountNum || req.query?.accountNum || '-'
log(
`[REQ] ${req.method} ${req.path} account=${accountNum} bodyBytes=${req.headers['content-length'] || 0}`,
)
res.on('finish', () => {
log(
`[RES] ${req.method} ${req.path} status=${res.statusCode} tookMs=${Date.now() - start}`,
)
})
next()
}
module.exports = { log, requestLogger, LOG_FILE }
+16
View File
@@ -0,0 +1,16 @@
function registerPrizeRedemptionsSyncRoute({ app, context }) {
app.post('/api/v1/prize-redemptions/sync', (req, res) => {
const correlationId =
req.body?.data?.correlationId || req.body?.correlationId || req.body?.schema?.correlationId || ''
return res.status(200).json({
success: context.prizeRedemptionsSyncSuccess,
message: context.prizeRedemptionsSyncMessage,
data: {
correlationId,
},
})
})
}
module.exports = registerPrizeRedemptionsSyncRoute
+17
View File
@@ -0,0 +1,17 @@
function registerEdipTokenGetRoute({ app, context }) {
app.post('/edip/v1/token/get', (req, res) => {
if (context.edipForceErrorCode) {
return res.status(200).json({
errorCode: context.edipForceErrorCode,
errorMsg: context.edipForceErrorMsg,
accessToken: '',
refreshToken: '',
period: 0,
})
}
return res.status(200).json(context.tokenSuccessResponse())
})
}
module.exports = registerEdipTokenGetRoute
+28
View File
@@ -0,0 +1,28 @@
function registerEdipTokenRefreshRoute({ app, context }) {
app.post('/edip/v1/token/refresh', (req, res) => {
const refreshToken = req.body?.data?.refreshToken
if (!refreshToken) {
return res.status(200).json({
errorCode: 'MISSING_REFRESH_TOKEN',
errorMsg: 'refreshToken is required',
accessToken: '',
refreshToken: '',
period: 0,
})
}
if (context.edipRefreshTokenMustExist && !context.issuedRefreshTokens.has(refreshToken)) {
return res.status(200).json({
errorCode: 'INVALID_REFRESH_TOKEN',
errorMsg: 'refreshToken not recognized by mock gateway',
accessToken: '',
refreshToken: '',
period: 0,
})
}
return res.status(200).json(context.tokenSuccessResponse())
})
}
module.exports = registerEdipTokenRefreshRoute
+24
View File
@@ -0,0 +1,24 @@
function registerCardpinValidateRoute({ app, context }) {
app.post('/ewl/bal/v1/cardpin/validate', (req, res) => {
const accountNum = context.resolveAccountNum(req.body)
const pin = `${req.body?.data?.pin || ''}`
if (context.failPins.has(pin)) {
return res.status(200).json({
errorCode: 'INVALID_PIN',
errorMsg: 'Mock validation failed',
accountNum,
result: 'FAIL',
})
}
return res.status(200).json({
errorCode: '',
errorMsg: '',
accountNum,
result: context.defaultValidateResult,
})
})
}
module.exports = registerCardpinValidateRoute
+27
View File
@@ -0,0 +1,27 @@
function registerPlayerEarnRoute({ app, context }) {
app.post('/ewl/bal/v1/player/earn', (req, res) => {
const accountNum = context.resolveAccountNum(req.body)
const casinoCode = req.body?.data?.casinoCode || 'GLP'
const points = Number(process.env.MOCK_PLAYER_EARN_POINTS || 0)
return res.status(200).json({
errorCode: '',
errorMsg: '',
accountNum,
playerEarn: [
{
casinoCode,
points,
comps: 0,
ebonus: 0,
stubs: 0,
tierPoints: 0,
mgmtComp: 0,
},
],
ratingSum: [],
})
})
}
module.exports = registerPlayerEarnRoute
+13
View File
@@ -0,0 +1,13 @@
function registerPlayerImageRoute({ app, context }) {
app.post('/ewl/bal/v1/player/image', (req, res) => {
const accountNum = context.resolveAccountNum(req.body)
return res.status(200).json({
errorCode: '',
errorMsg: '',
accountNum,
image: context.mockProfile.image,
})
})
}
module.exports = registerPlayerImageRoute
@@ -0,0 +1,91 @@
const {
toIntegerOrDefault,
resolveAccountNum,
buildPlayerProfile,
} = require('./playerPromotionFulfillmentJourneyShared')
function buildFulfillmentJourneyResponse({ context, requestBody = {} }) {
const requestData = requestBody?.data || {}
const accountNum = resolveAccountNum(context, requestData, requestBody)
const ruleId = toIntegerOrDefault(requestData.ruleId, 71768024)
const ruleName = requestData.ruleName || 'Super Flash 2026'
const isEmptyResult = context.playerPromotionFulfillmentJourneyEmptyResult
return {
errorCode: context.playerPromotionFulfillmentJourneyErrorCode,
errorMsg: context.playerPromotionFulfillmentJourneyErrorMsg,
accountNum,
ruleId,
playerProfile: buildPlayerProfile(context, accountNum),
eligibleCriteriaList: isEmptyResult
? []
: [
{
attribute: 'Tag',
operator: 'IN',
value: 'Tag 01',
valueArray: ['Tag 01'],
qualify: true,
},
{
attribute: 'Tag',
operator: 'NOT IN',
value: 'Tag 02',
valueArray: ['Tag 02'],
qualify: false,
},
],
journeyList: isEmptyResult
? []
: [
{
progressId: 1,
memberNo: accountNum,
ruleId,
ruleName,
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: 3000,
},
{
progressId: 2,
memberNo: accountNum,
ruleId: 695,
ruleName: 'QA-TESTING',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: -5,
},
{
progressId: 3,
memberNo: accountNum,
ruleId: 701,
ruleName: 'QA-REGRESSION-TEST',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: -1,
},
],
}
}
function registerPlayerPromotionFulfillmentJourneyRoute({ app, context }) {
app.post(
[
'/ewl/promotion/v1/playerpromotion/fulfillmentJourney',
'/ewl/bal/v1/playerpromotion/fulfillmentJourney',
],
(req, res) =>
res.status(200).json(
buildFulfillmentJourneyResponse({
context,
requestBody: req.body,
}),
),
)
}
module.exports = registerPlayerPromotionFulfillmentJourneyRoute
module.exports.__internal = {
buildFulfillmentJourneyResponse,
}
@@ -0,0 +1,153 @@
const {
toIntegerOrDefault,
resolveAccountNum,
buildPlayerProfile,
} = require('./playerPromotionFulfillmentJourneyShared')
function buildFulfillmentJourneyDetailResponse({ context, requestBody = {} }) {
const requestData = requestBody?.data || {}
const accountNum = resolveAccountNum(context, requestData, requestBody)
const ruleId = toIntegerOrDefault(requestData.ruleId, 71768024)
const isEmptyResult = context.playerPromotionFulfillmentJourneyDetailEmptyResult
return {
errorCode: context.playerPromotionFulfillmentJourneyDetailErrorCode,
errorMsg: context.playerPromotionFulfillmentJourneyDetailErrorMsg,
accountNum,
ruleId,
playerProfile: buildPlayerProfile(context, accountNum),
journeyList: isEmptyResult
? []
: [
{
progressId: 10,
memberNo: accountNum,
postDtm: '2026-04-17T18:40:48+08:00',
gamingDt: '2026-04-17',
earnType: 'EARN',
ballyTranId: 'earn-001',
ruleId,
ruleName: 'Super Flash 2026',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: 251,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
{
progressId: 11,
memberNo: accountNum,
postDtm: '2026-04-17T20:26:13+08:00',
gamingDt: '2026-04-17',
earnType: 'EARN',
ballyTranId: 'earn-002',
ruleId,
ruleName: 'Super Flash 2026',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: 149,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
{
progressId: 12,
memberNo: accountNum,
postDtm: '2026-04-17T22:19:47+08:00',
gamingDt: '2026-04-17',
earnType: 'EARN',
ballyTranId: 'earn-003',
ruleId,
ruleName: 'Super Flash 2026',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: 144,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
{
progressId: 13,
memberNo: accountNum,
postDtm: '2026-04-18T01:38:00+08:00',
gamingDt: '2026-04-18',
earnType: 'BURN',
ballyTranId: 'burn-001',
ruleId,
ruleName: 'Super Flash 2026',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: -60,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
{
progressId: 14,
memberNo: accountNum,
postDtm: '2026-04-18T01:38:12+08:00',
gamingDt: '2026-04-18',
earnType: 'BURN',
ballyTranId: 'burn-002',
ruleId,
ruleName: 'Super Flash 2026',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: -144,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
{
progressId: 15,
memberNo: accountNum,
postDtm: '2026-04-18T01:38:20+08:00',
gamingDt: '2026-04-18',
earnType: 'BURN',
ballyTranId: 'burn-003',
ruleId,
ruleName: 'WEG 10 Pts',
periodStartTime: '2026-04-17T00:00:00+08:00',
periodEndTime: '2026-04-18T23:59:59+08:00',
basePts: -35,
deptCode: 'SLOT',
locnCode: 'P00001',
gameCode: 'GLPSlot',
areaCode: 'VID',
casinoCode: 'GLP',
},
],
}
}
function registerPlayerPromotionFulfillmentJourneyDetailRoute({ app, context }) {
app.post(
[
'/ewl/promotion/v1/playerpromotion/fulfillmentJourneyDetail',
'/ewl/bal/v1/playerpromotion/fulfillmentJourneyDetail',
],
(req, res) =>
res.status(200).json(
buildFulfillmentJourneyDetailResponse({
context,
requestBody: req.body,
}),
),
)
}
module.exports = registerPlayerPromotionFulfillmentJourneyDetailRoute
module.exports.__internal = {
buildFulfillmentJourneyDetailResponse,
}
@@ -0,0 +1,37 @@
function toIntegerOrDefault(value, fallbackValue) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallbackValue
}
function resolveAccountNum(context, requestData, requestBody) {
const resolvedByContext =
typeof context.resolveAccountNum === 'function' ? context.resolveAccountNum(requestBody) : ''
return (
requestData.accountNum ||
requestData.playerId ||
resolvedByContext ||
''
)
}
function buildPlayerProfile(context, accountNum) {
const profile = context.getMockProfileForAccount(accountNum)
return {
clubState: 'ACTIVE',
clubLevelName: profile?.tier || 'MOCK',
setupCasinoCode: 'GLP',
signUpDate: '2024-02-16',
significanceDate: '2025-01-01',
countryCode: 'MO',
tagArray: ['IN Tag 01', 'NOT IN Tag 02'],
xdaysSignOn: 180,
}
}
module.exports = {
toIntegerOrDefault,
resolveAccountNum,
buildPlayerProfile,
}
+643
View File
@@ -0,0 +1,643 @@
const EWALLET_HTTP_ERROR = 'EWALLET_HTTP_ERROR'
const ISSUE_TARGET_TO_WALLET_HEADER = 'x-ewallet-mock-issue-target-to-wallet-by-outcome-id'
function joinUrl(baseUrl, pathname) {
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
return new URL(pathname.replace(/^\//, ''), normalizedBaseUrl).toString()
}
function createRequestOptions(method, body, headers, timeoutMs) {
return {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
timeoutMs,
}
}
function buildHttpErrorMessage(method, url, status, statusText, rawBody) {
const responseText = rawBody ? `: "${rawBody}"` : ''
return `${status} ${statusText} on ${method} request for "${url}"${responseText}`
}
async function requestJson(url, options) {
let response
try {
response = await fetch(url, options)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (
error?.name === 'TimeoutError' ||
/aborted due to timeout/i.test(message)
) {
throw new Error(
`Timeout after ${options.timeoutMs}ms on ${options.method || 'GET'} request for "${url}"`,
)
}
throw error
}
const rawBody = await response.text()
if (!response.ok) {
throw new Error(
buildHttpErrorMessage(options.method || 'GET', url, response.status, response.statusText, rawBody),
)
}
if (!rawBody) {
return null
}
try {
return JSON.parse(rawBody)
} catch (error) {
throw new Error(
`Invalid JSON response on ${options.method || 'GET'} request for "${url}": "${rawBody}"`,
)
}
}
function unwrapApiResponseBody(responseBody, requestLabel) {
if (!responseBody || typeof responseBody !== 'object') {
throw new Error(`${requestLabel} returned an empty response body`)
}
if (Object.prototype.hasOwnProperty.call(responseBody, 'success')) {
if (responseBody.success === false) {
const detail =
responseBody.error?.detail || responseBody.message || `${requestLabel} returned an error`
throw new Error(detail)
}
return responseBody.data
}
return responseBody
}
function toNullableString(value) {
if (value === undefined || value === null) {
return null
}
const text = String(value).trim()
return text ? text : null
}
function toNullableBoolean(value) {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase()
if (['true', '1', 'yes', 'y'].includes(normalizedValue)) {
return true
}
if (['false', '0', 'no', 'n'].includes(normalizedValue)) {
return false
}
}
return null
}
function parseIssueTargetToWalletHeader(headerValue) {
const normalizedHeaderValue = toNullableString(headerValue)
if (!normalizedHeaderValue) {
return null
}
let parsedHeaderValue
try {
parsedHeaderValue = JSON.parse(normalizedHeaderValue)
} catch (error) {
throw new Error(
`Invalid ${ISSUE_TARGET_TO_WALLET_HEADER} header JSON: ${normalizedHeaderValue}`,
)
}
if (!parsedHeaderValue || typeof parsedHeaderValue !== 'object' || Array.isArray(parsedHeaderValue)) {
throw new Error(`Invalid ${ISSUE_TARGET_TO_WALLET_HEADER} header payload`)
}
const normalizedOverrides = {}
for (const [outcomeId, rawValue] of Object.entries(parsedHeaderValue)) {
const normalizedValue = toNullableBoolean(rawValue)
if (normalizedValue === null) {
throw new Error(
`Invalid ${ISSUE_TARGET_TO_WALLET_HEADER} header value for outcomeId=${outcomeId}`,
)
}
normalizedOverrides[outcomeId] = normalizedValue
}
return normalizedOverrides
}
function mergeIssueTargetToWalletOverrides(requestBody, headers) {
const requestData = requestBody?.data || {}
const headerOverrides = parseIssueTargetToWalletHeader(headers?.[ISSUE_TARGET_TO_WALLET_HEADER])
if (!headerOverrides) {
return requestBody
}
const requestOverrides =
requestData.issueTargetToWalletByOutcomeId &&
typeof requestData.issueTargetToWalletByOutcomeId === 'object'
? requestData.issueTargetToWalletByOutcomeId
: {}
return {
...(requestBody || {}),
data: {
...requestData,
issueTargetToWalletByOutcomeId: {
...headerOverrides,
...requestOverrides,
},
},
}
}
function mergeRuntimeIssueTargetToWalletOverrides(requestBody, context, accountNum) {
const requestData = requestBody?.data || {}
const runtimeOverrides = context.resolvePromotionRedeemFulfillmentOverrideByOutcomeId({
accountNum,
promotionId: requestData.promotionId,
promotionRuleId: requestData.promotionRuleId,
outcomeIds: requestData.outcomeId,
})
if (!runtimeOverrides || Object.keys(runtimeOverrides).length === 0) {
return requestBody
}
const requestOverrides =
requestData.issueTargetToWalletByOutcomeId &&
typeof requestData.issueTargetToWalletByOutcomeId === 'object'
? requestData.issueTargetToWalletByOutcomeId
: {}
return {
...(requestBody || {}),
data: {
...requestData,
issueTargetToWalletByOutcomeId: {
...runtimeOverrides,
...requestOverrides,
},
},
}
}
function resolveIssueTargetToWalletOverride(requestData, outcome, index) {
const requestIssueTargetToWalletByOutcomeId = requestData?.issueTargetToWalletByOutcomeId
const outcomeId = outcome?.key
if (
requestIssueTargetToWalletByOutcomeId &&
typeof requestIssueTargetToWalletByOutcomeId === 'object' &&
outcomeId !== undefined &&
outcomeId !== null
) {
const overrideByOutcomeId = toNullableBoolean(requestIssueTargetToWalletByOutcomeId[String(outcomeId)])
if (overrideByOutcomeId !== null) {
return overrideByOutcomeId
}
}
if (Array.isArray(requestData?.issueTargetToWallet) && requestData.issueTargetToWallet.length > index) {
const overrideByIndex = toNullableBoolean(requestData.issueTargetToWallet[index])
if (overrideByIndex !== null) {
return overrideByIndex
}
}
const outcomeOverride = toNullableBoolean(outcome?.issueTargetToWallet)
if (outcomeOverride !== null) {
return outcomeOverride
}
const mockOutcomeOverride = toNullableBoolean(outcome?.mockIssueTargetToWallet)
if (mockOutcomeOverride !== null) {
return mockOutcomeOverride
}
const prizeOverride = toNullableBoolean(outcome?.prize?.issueTargetToWallet)
if (prizeOverride !== null) {
return prizeOverride
}
const mockPrizeOverride = toNullableBoolean(outcome?.prize?.mockIssueTargetToWallet)
if (mockPrizeOverride !== null) {
return mockPrizeOverride
}
return null
}
function resolveIssueTargetToWallet({ requestData, outcome, index, prizeIdentifier }) {
const explicitOverride = resolveIssueTargetToWalletOverride(requestData, outcome, index)
if (explicitOverride !== null) {
return explicitOverride
}
if (toNullableString(prizeIdentifier)) {
return false
}
const prize = outcome?.prize || {}
const deliveryMode = prize?.behaviorProfile?.deliveryMode || prize?.deliveryMode || ''
if (deliveryMode === 'EXTERNAL_ONLY') {
return false
}
if (deliveryMode === 'WALLET_ONLY') {
return true
}
return true
}
function resolveTargetPrizeValue(prize, sourceBucket, promotion) {
const bucketPrice = prize?.bucketPrices?.[sourceBucket]
if (Number.isFinite(bucketPrice)) {
return bucketPrice
}
// When the source bucket price is absent, leave the value unspecified (null)
// so the redemption payload sends valueUsed: null and eWallet computes it
// from its own configuration (instead of falling back to prizeValue).
return null
}
function buildPromotionMetadata(requestData, promotion, redemptionId) {
return {
rule_id: requestData.promotionRuleId,
promotion_type: requestData.isDirectOffer ? 'direct_offer' : 'promotion',
promotion_name: requestData.promotionName || promotion?.name || '',
promotion_code: promotion?.code || '',
rule_code: promotion?.ruleName || '',
promotion_id: requestData.promotionId,
redemption_id: redemptionId,
__policy_segments__: {},
}
}
function buildRedemptionPayload({
context,
requestBody,
accountNum,
promotion,
selectedOutcomes,
promotionRedemptionId,
}) {
const requestData = requestBody?.data || {}
const requestSchema = requestBody?.schema || {}
const quantity = Number.isFinite(Number(requestData.quantity)) && Number(requestData.quantity) > 0
? Number(requestData.quantity)
: 1
const sourceBucket = context.promotionRedemptionSourceBucket
const targetPrizes = selectedOutcomes.map((outcome, index) => {
const prize = outcome?.prize || {}
const prizeIdentifier =
Array.isArray(requestData.gisPrizeIdentifier) && requestData.gisPrizeIdentifier.length > index
? requestData.gisPrizeIdentifier[index]
: null
return {
targetPrizeCode: prize?.prizeCode || outcome?.value || '',
amount: quantity,
prizeIdentifier: toNullableString(prizeIdentifier),
issueTargetToWallet: resolveIssueTargetToWallet({
requestData,
outcome,
index,
prizeIdentifier,
}),
visibleFrom: null,
validFrom: null,
expiryDate: null,
}
})
const totalTargetValue = targetPrizes.reduce((sum, targetPrize, index) => {
const prize = selectedOutcomes[index]?.prize || {}
return sum + resolveTargetPrizeValue(prize, sourceBucket, promotion) * targetPrize.amount
}, 0)
return {
correlationId: requestSchema.transactionId || context.generateToken('mock_promotion_redeem'),
redemptionType: 'PRIZE_EXCHANGE',
metadata: buildPromotionMetadata(requestData, promotion, promotionRedemptionId),
customerId: accountNum,
customerType: 'MEMBER',
targetPrizes,
initiateType: 'API',
initiateSource: context.promotionRedemptionInitiateSource,
initiateIdentifier: requestData.loginId || '',
note: requestData.remark || null,
sourceWalletItems: [
{
sourceWalletItemBucket: sourceBucket,
valueUsed: totalTargetValue > 0 ? totalTargetValue : null,
redemptionRule: 'FEFO',
},
],
}
}
function buildRecordedRedeemedOutcome({
requestBody,
promotion,
selectedOutcomes,
promotionRedemptionId,
redemptionPublicId,
}) {
const requestData = requestBody?.data || {}
const requestSchema = requestBody?.schema || {}
const now = new Date().toISOString()
const gamingDate = now.slice(0, 10)
return {
gamingDate,
normalPromo: !Boolean(requestData.isDirectOffer),
postedBy: requestData.loginId || 'mock.user',
postedDtm: now,
casinoCode: requestData.casinoCode || 'GLP',
locationCode: requestData.locationCode || 'MOCK',
computerName: requestSchema.computerName || 'mock-gateway',
ipAddress: requestSchema.ipAddress || '',
prizeRuleCode: promotion?.ruleName || '',
prizeRuleId: requestData.promotionRuleId,
promotionCode: promotion?.code || '',
promotionId: requestData.promotionId,
redeemedQty:
Number.isFinite(Number(requestData.quantity)) && Number(requestData.quantity) > 0
? Number(requestData.quantity)
: 1,
redemptionId: promotionRedemptionId,
remark: requestData.remark || 'Mock redeemed promotion detail',
outcome: {
redeemedList: selectedOutcomes.map((outcome) => ({
outcomeRemark: outcome?.prize?.prizeCode || outcome?.value || '',
outcomeType: 'PRIZE',
prizeCategory: outcome?.prize?.prizeCategory || 'PROMOTION',
transId: redemptionPublicId,
})),
},
outcomeAward: null,
voidedBy: '',
voidedDtm: '',
voidedGamingDate: '',
voidedRemark: '',
displayOnlyPromo: false,
isVoided: false,
}
}
function isBackdatePromotionRedeemRequest(route, requestBody) {
if (typeof route === 'string' && /redeembackdate/i.test(route)) {
return true
}
const backdate = requestBody?.data?.backdate
return typeof backdate === 'string' && backdate.trim().length > 0
}
function createMockPromotionRedeemResult({ context, requestBody, accountNum, backdate = false }) {
const requestData = requestBody?.data || {}
const selection = context.resolvePromotionRedeemSelection(
accountNum,
requestData.promotionId,
requestData.promotionRuleId,
requestData.outcomeId,
{ backdate },
)
if (!selection || !selection.promotion || selection.selectedOutcomes.length === 0) {
throw new Error(
`Promotion fixture not found for account ${accountNum}, promotionId=${requestData.promotionId}, promotionRuleId=${requestData.promotionRuleId}`,
)
}
const promotionRedemptionId = context.nextMockPromotionRedemptionId()
return {
redeemedOutcome: buildRecordedRedeemedOutcome({
requestBody,
promotion: selection.promotion,
selectedOutcomes: selection.selectedOutcomes,
promotionRedemptionId,
redemptionPublicId: context.generateToken(backdate ? 'mock_backdate_redemption' : 'mock_redemption'),
}),
}
}
async function bridgePromotionRedeemToEwallet({
context,
requestBody,
accountNum,
backdate = false,
}) {
const { log } = require('../../../logger')
const requestData = requestBody?.data || {}
log(
`[BRIDGE] start account=${accountNum} promotionId=${requestData.promotionId} outcomeId=${requestData.outcomeId} backdate=${backdate}`,
)
const selection = context.resolvePromotionRedeemSelection(
accountNum,
requestData.promotionId,
requestData.promotionRuleId,
requestData.outcomeId,
{ backdate },
)
if (!selection || !selection.promotion || selection.selectedOutcomes.length === 0) {
throw new Error(
`Promotion fixture not found for account ${accountNum}, promotionId=${requestData.promotionId}, promotionRuleId=${requestData.promotionRuleId}`,
)
}
const promotionRedemptionId = context.nextMockPromotionRedemptionId()
const redemptionPayload = buildRedemptionPayload({
context,
requestBody,
accountNum,
promotion: selection.promotion,
selectedOutcomes: selection.selectedOutcomes,
promotionRedemptionId,
})
const loginUsername =
context.promotionRedemptionBridgeLoginUsername || requestData.loginId || 'mock.gateway'
const authUrl = joinUrl(context.promotionRedemptionBridgeBaseUrl, '/api/v1/auth/login')
const redemptionUrl = joinUrl(
context.promotionRedemptionBridgeBaseUrl,
'/api/v1/prize-redemptions',
)
const authResponseBody = await requestJson(
authUrl,
createRequestOptions(
'POST',
{
username: loginUsername,
password: context.promotionRedemptionBridgeLoginPassword,
},
{
'Content-Type': 'application/json',
},
context.promotionRedemptionBridgeTimeoutMs,
),
)
const authResponseData = unwrapApiResponseBody(authResponseBody, 'Promotion bridge login')
const accessToken = authResponseData?.accessToken
if (!accessToken) {
throw new Error('Promotion bridge login did not return an access token')
}
log(`[BRIDGE] calling eWallet POST ${redemptionUrl} (sync)`)
const redemptionResponseBody = await requestJson(
redemptionUrl,
createRequestOptions(
'POST',
redemptionPayload,
{
Authorization: `Bearer ${accessToken}`,
'Client-Machine-Id': requestBody?.schema?.computerName || 'mock-gateway',
'Content-Type': 'application/json',
'Location-Code': requestData.locationCode || 'MOCK',
'Property-Code': requestData.casinoCode || 'GLP',
'X-Real-IP': requestBody?.schema?.ipAddress || '',
},
context.promotionRedemptionBridgeTimeoutMs,
),
)
log(`[BRIDGE][SYNC-RAW] ${JSON.stringify(redemptionResponseBody)}`)
const redemptionResponseData = unwrapApiResponseBody(
redemptionResponseBody,
'Promotion bridge prize redemption',
)
log(`[BRIDGE][SYNC-DATA] ${JSON.stringify(redemptionResponseData)}`)
if (!redemptionResponseData?.publicId) {
throw new Error('Promotion bridge prize redemption did not return a publicId')
}
if (redemptionResponseData.overallStatus && redemptionResponseData.overallStatus !== 'COMPLETED') {
const errorMessage =
redemptionResponseData.finalErrorMessage ||
redemptionResponseData.finalStatusReason ||
`Prize redemption finished with status ${redemptionResponseData.overallStatus}`
throw new Error(errorMessage)
}
return {
redeemedOutcome: buildRecordedRedeemedOutcome({
requestBody,
promotion: selection.promotion,
selectedOutcomes: selection.selectedOutcomes,
promotionRedemptionId,
redemptionPublicId: redemptionResponseData.publicId,
}),
}
}
function registerPlayerPromotionRedeemRoute({ app, context }) {
const routes = [
'/ewl/promotion/v1/playerpromotion/redeem',
'/ewl/promotion/v1/playerpromotion/redeemBackDate',
'/ewl/bal/v1/playerpromotion/redeem',
'/ewl/bal/v1/playerpromotion/redeemBackDate',
]
routes.forEach((route) => app.post(route, async (req, res) => {
const { log } = require('../../../logger')
log(
`[REDEEM-RECV] ${route} account=${context.resolveAccountNum(req.body)} promotionId=${req.body?.data?.promotionId} outcomeId=${req.body?.data?.outcomeId}`,
)
const headerMergedRequestBody = mergeIssueTargetToWalletOverrides(req.body, req.headers)
const accountNum = context.resolveAccountNum(headerMergedRequestBody)
const normalizedRequestBody = mergeRuntimeIssueTargetToWalletOverrides(
headerMergedRequestBody,
context,
accountNum,
)
const backdateRedeemRequest = isBackdatePromotionRedeemRequest(route, normalizedRequestBody)
const redeemMock = context.getPlayerPromotionRedeemMock(accountNum)
let errorCode = redeemMock.errorCode
let errorMsg = redeemMock.errorMsg
let result = redeemMock.result
let simulatePublicId = null
if (result && !errorCode) {
try {
if (context.promotionRedemptionBridgeEnabled) {
const bridgeResult = await bridgePromotionRedeemToEwallet({
context,
requestBody: normalizedRequestBody,
accountNum,
backdate: backdateRedeemRequest,
})
if (context.promotionRedemptionSimulateErrorAfterBridge) {
// Plan C: simulate the promotion error AFTER the bridge created the
// eWallet redemption, but do NOT record a redeemed promotion in the
// Mock store so the history won't show a non-voided "success".
simulatePublicId =
bridgeResult.redeemedOutcome?.outcome?.redeemedList?.[0]?.transId || null
errorCode = 'SIMULATED_PROMO_REDEEM_ERROR'
errorMsg = `Simulated promotion redeem failure after eWallet sync created publicId=${simulatePublicId}`
result = false
} else {
context.recordRedeemedPromotionOutcome(accountNum, bridgeResult.redeemedOutcome)
}
} else if (backdateRedeemRequest) {
const mockResult = createMockPromotionRedeemResult({
context,
requestBody: normalizedRequestBody,
accountNum,
backdate: true,
})
context.recordRedeemedPromotionOutcome(accountNum, mockResult.redeemedOutcome)
}
} catch (error) {
errorCode = EWALLET_HTTP_ERROR
errorMsg = error instanceof Error ? error.message : String(error)
result = false
}
}
return res.status(200).json({
errorCode,
errorMsg,
accountNum,
result,
...(simulatePublicId ? { redemptionPublicId: simulatePublicId } : {}),
})
}))
}
module.exports = registerPlayerPromotionRedeemRoute
module.exports.__internal = {
ISSUE_TARGET_TO_WALLET_HEADER,
createMockPromotionRedeemResult,
isBackdatePromotionRedeemRequest,
bridgePromotionRedeemToEwallet,
registerableRoutes: [
'/ewl/promotion/v1/playerpromotion/redeem',
'/ewl/promotion/v1/playerpromotion/redeemBackDate',
'/ewl/bal/v1/playerpromotion/redeem',
'/ewl/bal/v1/playerpromotion/redeemBackDate',
],
mergeIssueTargetToWalletOverrides,
mergeRuntimeIssueTargetToWalletOverrides,
parseIssueTargetToWalletHeader,
resolveIssueTargetToWallet,
resolveIssueTargetToWalletOverride,
toNullableBoolean,
}
+11
View File
@@ -0,0 +1,11 @@
function registerPointVoidRoute({ app, context }) {
app.post('/ewl/bal/v1/point/void', (req, res) => {
return res.status(200).json({
errorCode: context.pointVoidErrorCode,
errorMsg: context.pointVoidErrorMsg,
result: context.pointVoidResult,
})
})
}
module.exports = registerPointVoidRoute
+11
View File
@@ -0,0 +1,11 @@
function registerPrizeRedeemRoute({ app, context }) {
app.post('/ewl/bal/v1/prize/redeem', (req, res) => {
return res.status(200).json({
errorCode: context.prizeRedeemErrorCode,
errorMsg: context.prizeRedeemErrorMsg,
redeemPrize: context.prizeRedeemValue || context.generateToken('mock_redeem_prize'),
})
})
}
module.exports = registerPrizeRedeemRoute
+61
View File
@@ -0,0 +1,61 @@
function registerPromotionAllRoute({ app, context }) {
const fixedBackdateAccountNum = '80038008'
const routes = [
{
path: '/ewl/promotion/v1/promotion/all',
resolvePromotionLists: (accountNum) => context.getPromotionLists(accountNum),
requireAccountNum: true,
},
{
path: '/ewl/bal/v1/promotion/all',
resolvePromotionLists: (accountNum) => context.getPromotionLists(accountNum),
requireAccountNum: true,
},
{
path: '/ewl/promotion/v1/promotion/entitledBackDate',
resolvePromotionLists: () => context.getBackdatePromotionLists(fixedBackdateAccountNum),
requireAccountNum: false,
responseAccountNum: fixedBackdateAccountNum,
},
{
path: '/ewl/promotion/v1/promotion/entitledBackDate/all',
resolvePromotionLists: () => context.getBackdatePromotionLists(fixedBackdateAccountNum),
requireAccountNum: false,
responseAccountNum: fixedBackdateAccountNum,
},
{
path: '/ewl/bal/v1/promotion/entitledBackDate/all',
resolvePromotionLists: () => context.getBackdatePromotionLists(fixedBackdateAccountNum),
requireAccountNum: false,
responseAccountNum: fixedBackdateAccountNum,
},
]
routes.forEach(({ path, resolvePromotionLists, requireAccountNum, responseAccountNum }) =>
app.post(path, (req, res) => {
const accountNum = requireAccountNum ? context.resolveAccountNum(req.body) : responseAccountNum
if (requireAccountNum && !accountNum) {
return res.status(200).json({
errorCode: 'MISSING_ACCOUNT_NUM',
errorMsg: 'accountNum or playerId is required',
accountNum: '',
eligiblePromotionList: [],
entitledPromotionList: [],
})
}
const { eligiblePromotionList, entitledPromotionList } = resolvePromotionLists(accountNum)
return res.status(200).json({
errorCode: '',
errorMsg: '',
accountNum,
eligiblePromotionList,
entitledPromotionList,
})
}),
)
}
module.exports = registerPromotionAllRoute
@@ -0,0 +1,47 @@
function registerRedeemedPromotionDetailRoute({ app, context }) {
app.post(
[
'/ewl/promotion/v1/redeemedpromotion/detail',
'/ewl/bal/v1/redeemedpromotion/detail',
],
(req, res) => {
const fixtureCustomerId =
typeof context.promotionsRedeemedFixture?.data?.customerId === 'string'
? context.promotionsRedeemedFixture.data.customerId
: ''
const fixtureOutcomeList = Array.isArray(context.promotionsRedeemedFixture?.data?.outcomeList)
? context.cloneJsonValue(context.promotionsRedeemedFixture.data.outcomeList)
: []
const accountNum = context.resolveAccountNum(req.body) || fixtureCustomerId
const recordedOutcomeList = context.getRecordedRedeemedPromotionOutcomes(accountNum)
const fallbackOutcomeList = [context.buildMockRedeemedPromotionOutcome(req.body)]
let outcomeList = recordedOutcomeList
if (outcomeList.length === 0) {
const accountFixture = context.getAccountSpecificRedeemedPromotionsFixture(accountNum)
const accountFixtureOutcomeList = Array.isArray(accountFixture?.data?.outcomeList)
? context.cloneJsonValue(accountFixture.data.outcomeList)
: null
if (accountFixtureOutcomeList !== null) {
outcomeList = accountFixtureOutcomeList
} else {
outcomeList = context.redeemedPromotionDetailIncludeDefaultRecord
? fixtureOutcomeList.length > 0
? fixtureOutcomeList
: fallbackOutcomeList
: []
}
}
return res.status(200).json({
errorCode: context.redeemedPromotionDetailErrorCode,
errorMsg: context.redeemedPromotionDetailErrorMsg,
accountNum,
outcomeList,
})
}
)
}
module.exports = registerRedeemedPromotionDetailRoute
+216
View File
@@ -0,0 +1,216 @@
const { log } = require('../../../logger')
function joinUrl(baseUrl, pathname) {
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
return new URL(pathname.replace(/^\//, ''), normalizedBaseUrl).toString()
}
function createRequestOptions(method, body, headers, timeoutMs) {
return {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
timeoutMs,
}
}
async function requestJson(url, options) {
let response
try {
response = await fetch(url, options)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error?.name === 'TimeoutError' || /aborted due to timeout/i.test(message)) {
throw new Error(
`Timeout after ${options.timeoutMs}ms on ${options.method || 'GET'} request for "${url}"`,
)
}
throw error
}
const rawBody = await response.text()
if (!response.ok) {
throw new Error(
`HTTP ${response.status} ${response.statusText} on ${options.method || 'GET'} request for "${url}"${
rawBody ? `: ${rawBody}` : ''
}`,
)
}
if (!rawBody) {
return null
}
try {
return JSON.parse(rawBody)
} catch (error) {
throw new Error(
`Invalid JSON response on ${options.method || 'GET'} request for "${url}": "${rawBody}"`,
)
}
}
function unwrapApiResponseBody(responseBody, requestLabel) {
if (!responseBody || typeof responseBody !== 'object') {
throw new Error(`${requestLabel} returned an empty response body`)
}
if (Object.prototype.hasOwnProperty.call(responseBody, 'success')) {
if (responseBody.success === false) {
const detail =
responseBody.error?.detail || responseBody.message || `${requestLabel} returned an error`
throw new Error(detail)
}
return responseBody.data
}
return responseBody
}
async function loginToEwallet(context) {
const baseUrl = context.promotionRedemptionBridgeBaseUrl
if (!baseUrl) {
throw new Error('promotionRedemptionBridgeBaseUrl is not configured; cannot bridge void to eWallet')
}
const loginUsername = context.promotionRedemptionBridgeLoginUsername || 'mock.gateway'
const authUrl = joinUrl(baseUrl, '/api/v1/auth/login')
const authResp = await requestJson(
authUrl,
createRequestOptions(
'POST',
{
username: loginUsername,
password: context.promotionRedemptionBridgeLoginPassword,
},
{ 'Content-Type': 'application/json' },
context.promotionRedemptionBridgeTimeoutMs,
),
)
const authData = unwrapApiResponseBody(authResp, 'Promotion void bridge login')
const accessToken = authData?.accessToken
if (!accessToken) {
throw new Error('Promotion void bridge login did not return an access token')
}
return accessToken
}
// Mirror playerPromotionRedeem.bridgePromotionRedeemToEwallet: in production the promotion /
// Bally void first calls eWallet's own prize-redemption void, and only after that succeeds does
// Bally mark its promotion record voided. This Mock simulates that cascade so the eWallet side
// (prize_redemption_transactions / prize_redemption_void_transactions) is voided before the
// recorded promotion file flips isVoided.
//
// A promotion outcome may contain multiple redeemed prizes, each with its own eWallet
// prize-redemption publicId (transId) nested under outcome.redeemedList. Void every one of them.
async function voidPrizeRedemptionInEwallet(context, { accessToken, transId, correlationId, voidReason, initiateType, initiateSource, schema, data }) {
const baseUrl = context.promotionRedemptionBridgeBaseUrl
const voidUrl = joinUrl(baseUrl, `/api/v1/prize-redemptions/${transId}/sync`)
log(`[BRIDGE][VOID] calling eWallet DELETE ${voidUrl} (correlationId=${correlationId}, transId=${transId})`)
const voidResp = await requestJson(
voidUrl,
createRequestOptions(
'DELETE',
{
correlationId,
voidReason,
initiateType,
initiateSource,
},
{
Authorization: `Bearer ${accessToken}`,
'Client-Machine-Id': schema?.computerName || 'mock-gateway',
'Content-Type': 'application/json',
'Location-Code': data?.locationCode || 'MOCK',
'Property-Code': data?.casinoCode || 'GLP',
'X-Real-IP': schema?.ipAddress || '',
},
context.promotionRedemptionBridgeTimeoutMs,
),
)
const voidData = unwrapApiResponseBody(voidResp, 'Promotion void bridge prize redemption void')
log(`[BRIDGE][VOID] eWallet void result overallStatus=${voidData?.overallStatus} (transId=${transId})`)
// eWallet treats FAILED / COMPENSATED / PENDING / PROCESSING as errors; a successful void
// comes back as VOIDED (or COMPLETED). Anything else is treated as success here.
const failedStatuses = ['FAILED', 'COMPENSATED', 'PENDING', 'PROCESSING']
if (voidData?.overallStatus && failedStatuses.includes(voidData.overallStatus)) {
throw new Error(`eWallet prize redemption void finished with status ${voidData.overallStatus} (transId=${transId})`)
}
return voidData
}
function registerRedeemedPromotionVoidRoute({ app, context }) {
const routes = [
'/ewl/promotion/v1/redeemedpromotion/void',
'/ewl/bal/v1/redeemedpromotion/void',
]
routes.forEach((route) =>
app.post(
route,
async (req, res) => {
const schema = req.body?.schema || {}
const data = req.body?.data || {}
const redeemedId = data.redeemedId
const loginId = data.loginId || ''
// eWallet void payload. The Bally promotion void request does not carry these, so derive them.
const correlationId = `VOID_PROMO_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const voidReason = data?.remark || 'PROMOTION_VOID'
const initiateType = data?.initiateType || 'SYSTEM'
const initiateSource = data?.initiateSource || 'WEB_APP'
try {
// Resolve the eWallet prize-redemption publicId(s) (transId) tied to this promotion
// redemption. They are nested under outcome.redeemedList[].transId in the recorded
// promotion (NOT at the top level of the recorded outcome entry). A promotion outcome
// may contain multiple redeemed prizes, each with its own transId.
const recordedOutcome = context.findRecordedRedeemedPromotionOutcome
? context.findRecordedRedeemedPromotionOutcome(redeemedId)
: null
const transIds = recordedOutcome?.outcome?.redeemedList
? recordedOutcome.outcome.redeemedList.map((r) => r.transId).filter(Boolean)
: []
if (transIds.length > 0 && context.promotionRedemptionBridgeBaseUrl) {
const accessToken = await loginToEwallet(context)
for (const transId of transIds) {
await voidPrizeRedemptionInEwallet(context, {
accessToken,
transId,
correlationId,
voidReason,
initiateType,
initiateSource,
schema,
data,
})
}
log(`[BRIDGE][VOID] eWallet prize redemption void succeeded for transIds=${transIds.join(',')}`)
} else {
log(
`[BRIDGE][VOID] skipped eWallet void bridge (transIds=${JSON.stringify(transIds)}, baseUrl=${context.promotionRedemptionBridgeBaseUrl})`,
)
}
// Only mark the recorded promotion as voided after the eWallet void succeeded.
if (context.redeemedPromotionVoidResult) {
context.voidRecordedRedeemedPromotionOutcome(redeemedId, loginId)
}
return res.status(200).json({
errorCode: context.redeemedPromotionVoidErrorCode,
errorMsg: context.redeemedPromotionVoidErrorMsg,
result: context.redeemedPromotionVoidResult,
})
} catch (error) {
log(`[BRIDGE][VOID] eWallet void bridge failed: ${error.message}`)
return res.status(200).json({
errorCode: context.redeemedPromotionVoidErrorCode || 'EWALLET_VOID_FAILED',
errorMsg: context.redeemedPromotionVoidErrorMsg || error.message,
result: false,
})
}
},
),
)
}
module.exports = registerRedeemedPromotionVoidRoute
+122
View File
@@ -0,0 +1,122 @@
const {
resolveCardNumForLookup,
resolvePlayerIdForLookup,
} = require('./memberProfileCardNum')
function registerMemberProfileRoute({ app, context }) {
app.post('/ewl/edip/v1/member/profile', (req, res) => {
const sendResponse = () => {
const accountNum = context.resolveAccountNum(req.body)
const playerId = resolvePlayerIdForLookup({
accountNum,
playerId: req.body?.data?.playerId,
})
const cardNum = resolveCardNumForLookup({
accountNum,
playerId,
enabled: context.task15171Enabled,
})
if (!accountNum) {
return res.status(200).json({
status: false,
errorCode: 'NOT_FOUND',
message: 'accountNum or playerId is required',
data: null,
})
}
const mockProfile = context.getMockProfileForAccount(accountNum)
const memberProfileTemplate = context.cloneJsonValue(context.memberProfileResponseFixture)
const memberProfileDataTemplate =
memberProfileTemplate?.data && typeof memberProfileTemplate.data === 'object'
? memberProfileTemplate.data
: {}
const playerTemplate =
memberProfileDataTemplate?.player && typeof memberProfileDataTemplate.player === 'object'
? memberProfileDataTemplate.player
: {}
const attributeTemplate =
memberProfileDataTemplate?.attribute && typeof memberProfileDataTemplate.attribute === 'object'
? memberProfileDataTemplate.attribute
: {}
const phoneListTemplate = Array.isArray(memberProfileDataTemplate.phoneList)
? memberProfileDataTemplate.phoneList
: []
const firstPhoneTemplate =
phoneListTemplate.length > 0 &&
phoneListTemplate[0] &&
typeof phoneListTemplate[0] === 'object'
? phoneListTemplate[0]
: {}
return res.status(200).json({
...memberProfileTemplate,
status: memberProfileTemplate?.status ?? true,
errorCode: `${memberProfileTemplate?.errorCode || ''}`,
message: memberProfileTemplate?.message || 'Mock member profile',
data: {
...memberProfileDataTemplate,
accountNum,
playerId,
card_num: cardNum,
currentTier: mockProfile.tier,
onDayTierPoint: mockProfile.onDayTierPoint,
lastLocn: mockProfile.lastLocn,
eBonus: mockProfile.eBonus,
phoneList: [
{
...firstPhoneTemplate,
phoneNum: mockProfile.phoneNum,
phoneType: firstPhoneTemplate.phoneType || 'Mobile1',
},
],
player: {
...playerTemplate,
firstName: mockProfile.firstName,
lastName: mockProfile.lastName,
nickName: mockProfile.nickName,
image: mockProfile.image,
},
attribute: {
...attributeTemplate,
birthday: mockProfile.birthday,
gender: mockProfile.gender,
},
addressList: Array.isArray(memberProfileDataTemplate.addressList)
? memberProfileDataTemplate.addressList
: [],
identityList: Array.isArray(memberProfileDataTemplate.identityList)
? memberProfileDataTemplate.identityList
: [],
emailList: Array.isArray(memberProfileDataTemplate.emailList)
? memberProfileDataTemplate.emailList
: [],
},
})
}
if (context.memberProfileForceStatusCode) {
const sendForcedError = () =>
res.status(context.memberProfileForceStatusCode).json({
status: false,
errorCode: context.memberProfileForceErrorCode,
message: context.memberProfileForceErrorMsg,
data: null,
})
if (context.memberProfileDelayMs > 0) {
return setTimeout(sendForcedError, context.memberProfileDelayMs)
}
return sendForcedError()
}
if (context.memberProfileDelayMs > 0) {
return setTimeout(sendResponse, context.memberProfileDelayMs)
}
return sendResponse()
})
}
module.exports = registerMemberProfileRoute
@@ -0,0 +1,62 @@
const TASK_15171_PLAYER_ID = '600000154'
const TASK_15171_INACTIVE_CARD = '2600000154001'
const TASK_15171_ACTIVE_CARD = '2600000154002'
function normalizeLookupValue(value) {
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
function isTask15171Scenario({ accountNum, playerId }) {
const normalizedAccountNum = normalizeLookupValue(accountNum)
const normalizedPlayerId = normalizeLookupValue(playerId)
return (
normalizedPlayerId === TASK_15171_PLAYER_ID ||
normalizedAccountNum === TASK_15171_ACTIVE_CARD ||
normalizedAccountNum === TASK_15171_INACTIVE_CARD
)
}
function resolveCardNumForLookup({ accountNum, playerId, enabled }) {
if (!enabled) {
const normalizedAccountNum = normalizeLookupValue(accountNum)
return normalizedAccountNum ? [normalizedAccountNum] : []
}
if (isTask15171Scenario({ accountNum, playerId })) {
return [TASK_15171_ACTIVE_CARD]
}
const normalizedAccountNum = normalizeLookupValue(accountNum)
return normalizedAccountNum ? [normalizedAccountNum] : []
}
function resolvePlayerIdForLookup({ accountNum, playerId }) {
const normalizedAccountNum = normalizeLookupValue(accountNum)
const normalizedPlayerId = normalizeLookupValue(playerId)
if (normalizedPlayerId) {
return normalizedPlayerId
}
if (
normalizedAccountNum === TASK_15171_ACTIVE_CARD ||
normalizedAccountNum === TASK_15171_INACTIVE_CARD
) {
return TASK_15171_PLAYER_ID
}
return normalizedAccountNum
}
module.exports = {
TASK_15171_PLAYER_ID,
TASK_15171_INACTIVE_CARD,
TASK_15171_ACTIVE_CARD,
resolveCardNumForLookup,
resolvePlayerIdForLookup,
}
+62
View File
@@ -0,0 +1,62 @@
function registerTeamHierarchyRoute({ app, context }) {
app.post('/ewl/mkt-rm/v1/team/hierarchy', (req, res) => {
const requestedUserName = `${req.body?.data?.userName || 'mock.user'}`.trim() || 'mock.user'
const requestedStaffId = `${req.body?.data?.staffId || 'M000001'}`.trim() || 'M000001'
const resolvedTags = context.teamHierarchyTags.length > 0 ? context.teamHierarchyTags : ['MOCK']
const teamMember = {
name: requestedUserName,
userName: requestedUserName,
mail: `${requestedUserName}@macausjm-glp.com`,
description: 'Mock team member',
staffId: requestedStaffId,
teamRole: context.teamHierarchyRole,
tags: resolvedTags,
}
const supportTeam = {
teamName: 'Mock Support Team',
description: 'Mock support team',
department: context.teamHierarchyDepartment,
hierarchy: [
{
name: 'Support',
priority: 1,
members: [teamMember],
tags: resolvedTags,
},
],
supportTeams: [],
}
return res.status(200).json({
status: context.teamHierarchyStatus,
errorCode: context.teamHierarchyErrorCode,
message: context.teamHierarchyMessage,
data: {
userName: requestedUserName,
staffId: requestedStaffId,
name: requestedUserName,
mail: `${requestedUserName}@macausjm-glp.com`,
teamRole: context.teamHierarchyRole,
tags: resolvedTags,
teams: [
{
teamName: 'Mock Team',
description: 'Mock team hierarchy',
department: context.teamHierarchyDepartment,
hierarchy: [
{
name: 'Main Team',
priority: 1,
members: [teamMember],
tags: resolvedTags,
},
],
supportTeams: [supportTeam],
},
],
},
})
})
}
module.exports = registerTeamHierarchyRoute
+15
View File
@@ -0,0 +1,15 @@
const { buildCheckPrizeStockResponseXml } = require('./xmlResponse')
function registerGisCheckPrizeStockRoute({ app, context }) {
app.get('/checkprizestock', (req, res) => {
const requestBody = req.body && typeof req.body === 'object' ? req.body : {}
const stockResponse = context.getMockGisCheckPrizeStockResponse(requestBody)
return res
.status(200)
.type('application/xml')
.send(buildCheckPrizeStockResponseXml([stockResponse]))
})
}
module.exports = registerGisCheckPrizeStockRoute
+14
View File
@@ -0,0 +1,14 @@
const { buildPrizeResponseXml } = require('./xmlResponse')
function registerGisPrizeRoute({ app, context }) {
app.post('/prize', (req, res) => {
return res.status(200).type('application/xml').send(
buildPrizeResponseXml({
errorCode: context.prizeRedeemErrorCode,
errorMsg: context.prizeRedeemErrorMsg,
}),
)
})
}
module.exports = registerGisPrizeRoute
+30
View File
@@ -0,0 +1,30 @@
const { buildTokenResponseXml } = require('./xmlResponse')
function registerGisTokenGetRoute({ app, context }) {
app.post('/token/get', (req, res) => {
if (context.edipForceErrorCode) {
return res.status(200).type('application/xml').send(
buildTokenResponseXml({
errorCode: context.edipForceErrorCode,
errorMsg: context.edipForceErrorMsg,
accessToken: '',
refreshToken: '',
period: 0,
}),
)
}
const token = context.tokenSuccessResponse()
return res.status(200).type('application/xml').send(
buildTokenResponseXml({
errorCode: token.errorCode,
errorMsg: token.errorMsg,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
period: token.period,
}),
)
})
}
module.exports = registerGisTokenGetRoute
+48
View File
@@ -0,0 +1,48 @@
const { buildTokenResponseXml } = require('./xmlResponse')
function resolveRefreshToken(body) {
return body?.refreshToken || body?.data?.refreshToken || ''
}
function registerGisTokenRefreshRoute({ app, context }) {
app.post('/token/refresh', (req, res) => {
const refreshToken = resolveRefreshToken(req.body)
if (!refreshToken) {
return res.status(200).type('application/xml').send(
buildTokenResponseXml({
errorCode: 'MISSING_REFRESH_TOKEN',
errorMsg: 'refreshToken is required',
accessToken: '',
refreshToken: '',
period: 0,
}),
)
}
if (context.edipRefreshTokenMustExist && !context.issuedRefreshTokens.has(refreshToken)) {
return res.status(200).type('application/xml').send(
buildTokenResponseXml({
errorCode: 'INVALID_REFRESH_TOKEN',
errorMsg: 'refreshToken not recognized by mock gateway',
accessToken: '',
refreshToken: '',
period: 0,
}),
)
}
const token = context.tokenSuccessResponse()
return res.status(200).type('application/xml').send(
buildTokenResponseXml({
errorCode: token.errorCode,
errorMsg: token.errorMsg,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
period: token.period,
}),
)
})
}
module.exports = registerGisTokenRefreshRoute
+92
View File
@@ -0,0 +1,92 @@
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function toPositiveInteger(value, fallback = 0) {
const number = Number(value)
if (!Number.isFinite(number) || number < 0) {
return fallback
}
return Math.floor(number)
}
function buildTokenResponseXml({
errorCode = '',
errorMsg = '',
accessToken = '',
refreshToken = '',
period = 0,
} = {}) {
return `<?xml version="1.0" encoding="UTF-8"?>
<GisTokenResponse>
<errorCode>${escapeXml(errorCode)}</errorCode>
<errorMsg>${escapeXml(errorMsg)}</errorMsg>
<accessToken>${escapeXml(accessToken)}</accessToken>
<refreshToken>${escapeXml(refreshToken)}</refreshToken>
<period>${toPositiveInteger(period, 0)}</period>
</GisTokenResponse>`
}
function buildPrizeResponseXml({ errorCode = '', errorMsg = '' } = {}) {
return `<?xml version="1.0" encoding="UTF-8"?>
<GisResponse>
<errorCode>${escapeXml(errorCode)}</errorCode>
<errorMsg>${escapeXml(errorMsg)}</errorMsg>
</GisResponse>`
}
function buildCheckPrizeStockResponseEntryXml({
propertyId = '',
locationId = '',
prizeCode = '',
prizeName = '',
prizeCategory = '',
supplierID = '',
uniqueCode = '',
sapItemCode = '',
luxuryFlag = '',
receivingID = '',
quantity = 0,
errorCode = '',
errorMsg = '',
} = {}) {
return ` <CheckPrizeStock_response_VM>
<propertyId>${escapeXml(propertyId)}</propertyId>
<locationId>${escapeXml(locationId)}</locationId>
<prizeCode>${escapeXml(prizeCode)}</prizeCode>
<prizeName>${escapeXml(prizeName)}</prizeName>
<prizeCategory>${escapeXml(prizeCategory)}</prizeCategory>
<supplierID>${escapeXml(supplierID)}</supplierID>
<uniqueCode>${escapeXml(uniqueCode)}</uniqueCode>
<sap_ItemCode>${escapeXml(sapItemCode)}</sap_ItemCode>
<luxuryFlag>${escapeXml(luxuryFlag)}</luxuryFlag>
<receivingID>${escapeXml(receivingID)}</receivingID>
<quantity>${toPositiveInteger(quantity, 0)}</quantity>
<errorCode>${escapeXml(errorCode)}</errorCode>
<errorMsg>${escapeXml(errorMsg)}</errorMsg>
</CheckPrizeStock_response_VM>`
}
function buildCheckPrizeStockResponseXml(entries = []) {
const normalizedEntries = Array.isArray(entries) ? entries : []
const responseEntries =
normalizedEntries.length > 0
? normalizedEntries.map((entry) => buildCheckPrizeStockResponseEntryXml(entry)).join('\n')
: buildCheckPrizeStockResponseEntryXml()
return `<?xml version="1.0" encoding="UTF-8"?>
<ArrayOfCheckPrizeStock_response_VM>
${responseEntries}
</ArrayOfCheckPrizeStock_response_VM>`
}
module.exports = {
buildTokenResponseXml,
buildPrizeResponseXml,
buildCheckPrizeStockResponseXml,
}
+12
View File
@@ -0,0 +1,12 @@
function registerHealthzRoute({ app, ldapService }) {
app.get('/healthz', (req, res) => {
res.json({
status: 'ok',
service: 'ewallet-mock-gateway',
now: new Date().toISOString(),
ldap: ldapService.getHealthInfo(),
})
})
}
module.exports = registerHealthzRoute
@@ -0,0 +1,60 @@
function buildFilterFromRequest(req) {
const query = req.query || {}
const body = req.body && typeof req.body === 'object' ? req.body : {}
return {
accountNum: body.accountNum ?? query.accountNum,
promotionId: body.promotionId ?? query.promotionId,
promotionRuleId: body.promotionRuleId ?? query.promotionRuleId,
outcomeId: body.outcomeId ?? query.outcomeId,
}
}
function registerPromotionRedeemFulfillmentOverridesRoute({ app, context }) {
const route = '/mock-api/promotion-redeem/fulfillment-overrides'
app.get(route, (req, res) => {
const overrides = context.getPromotionRedeemFulfillmentOverrides(buildFilterFromRequest(req))
return res.status(200).json({
success: true,
message: 'Promotion redeem fulfillment overrides retrieved successfully.',
data: overrides,
})
})
app.post(route, (req, res) => {
try {
const override = context.setPromotionRedeemFulfillmentOverride(req.body)
return res.status(200).json({
success: true,
message: 'Promotion redeem fulfillment override saved successfully.',
data: override,
})
} catch (error) {
return res.status(400).json({
success: false,
message: error instanceof Error ? error.message : String(error),
error: {
code: 'INVALID_REQUEST',
detail: error instanceof Error ? error.message : String(error),
},
})
}
})
app.delete(route, (req, res) => {
const removed = context.clearPromotionRedeemFulfillmentOverrides(buildFilterFromRequest(req))
return res.status(200).json({
success: true,
message: 'Promotion redeem fulfillment overrides cleared successfully.',
data: {
removed,
},
})
})
}
module.exports = registerPromotionRedeemFulfillmentOverridesRoute
+51
View File
@@ -0,0 +1,51 @@
const registerHealthzRoute = require('./healthz')
const registerEdipTokenGetRoute = require('./edip/tokenGet')
const registerEdipTokenRefreshRoute = require('./edip/tokenRefresh')
const registerGisTokenGetRoute = require('./gis/tokenGet')
const registerGisTokenRefreshRoute = require('./gis/tokenRefresh')
const registerGisPrizeRoute = require('./gis/prize')
const registerGisCheckPrizeStockRoute = require('./gis/checkPrizeStock')
const registerCardpinValidateRoute = require('./ewl/bal/cardpinValidate')
const registerTeamHierarchyRoute = require('./ewl/mktRm/teamHierarchy')
const registerMemberProfileRoute = require('./ewl/edip/memberProfile')
const registerPromotionAllRoute = require('./ewl/bal/promotionAll')
const registerPlayerImageRoute = require('./ewl/bal/playerImage')
const registerPlayerEarnRoute = require('./ewl/bal/playerEarn')
const registerPrizeRedeemRoute = require('./ewl/bal/prizeRedeem')
const registerPointVoidRoute = require('./ewl/bal/pointVoid')
const registerRedeemedPromotionDetailRoute = require('./ewl/bal/redeemedPromotionDetail')
const registerPlayerPromotionRedeemRoute = require('./ewl/bal/playerPromotionRedeem')
const registerRedeemedPromotionVoidRoute = require('./ewl/bal/redeemedPromotionVoid')
const registerPlayerPromotionFulfillmentJourneyRoute = require('./ewl/bal/playerPromotionFulfillmentJourney')
const registerPlayerPromotionFulfillmentJourneyDetailRoute = require('./ewl/bal/playerPromotionFulfillmentJourneyDetail')
const registerPrizeRedemptionsSyncRoute = require('./api/v1/prizeRedemptionsSync')
const registerPromotionRedeemFulfillmentOverridesRoute = require('./mockApi/promotionRedeemFulfillmentOverrides')
const registerSmsPortalRoutes = require('./sms/smsPortal')
function registerRoutes({ app, context, ldapService }) {
registerHealthzRoute({ app, ldapService })
registerEdipTokenGetRoute({ app, context })
registerEdipTokenRefreshRoute({ app, context })
registerGisTokenGetRoute({ app, context })
registerGisTokenRefreshRoute({ app, context })
registerGisPrizeRoute({ app, context })
registerGisCheckPrizeStockRoute({ app, context })
registerCardpinValidateRoute({ app, context })
registerTeamHierarchyRoute({ app, context })
registerMemberProfileRoute({ app, context })
registerPromotionAllRoute({ app, context })
registerPlayerImageRoute({ app, context })
registerPlayerEarnRoute({ app, context })
registerPrizeRedeemRoute({ app, context })
registerPointVoidRoute({ app, context })
registerRedeemedPromotionDetailRoute({ app, context })
registerPlayerPromotionRedeemRoute({ app, context })
registerRedeemedPromotionVoidRoute({ app, context })
registerPlayerPromotionFulfillmentJourneyRoute({ app, context })
registerPlayerPromotionFulfillmentJourneyDetailRoute({ app, context })
registerPrizeRedemptionsSyncRoute({ app, context })
registerPromotionRedeemFulfillmentOverridesRoute({ app, context })
registerSmsPortalRoutes({ app })
}
module.exports = { registerRoutes }
+39
View File
@@ -0,0 +1,39 @@
function registerSmsPortalRoutes({ app }) {
app.post('/api/SMSToken/get', (req, res) => {
return res.status(200).json({
errorCode: '',
errorMsg: '',
accessToken: 'mock-sms-access-token',
refreshToken: 'mock-sms-refresh-token',
period: 3600,
})
})
app.post('/api/SMSToken/Refresh', (req, res) => {
return res.status(200).json({
errorCode: '',
errorMsg: '',
accessToken: 'mock-sms-access-token',
refreshToken: 'mock-sms-refresh-token',
period: 3600,
})
})
app.post('/api/SMSRequest/Post', (req, res) => {
const recipients = Array.isArray(req.body?.recipientList) ? req.body.recipientList : []
const smsStatus = recipients.length > 0
? recipients.map((r) => ({
recipient: typeof r === 'string' ? r : r?.recipient || '',
status: 'OK',
}))
: [{ recipient: req.body?.recipient || '', status: 'OK' }]
return res.status(200).json({
errorCode: '',
errorMsg: '',
smsID: `mock-sms-${Date.now()}`,
smsStatus,
})
})
}
module.exports = registerSmsPortalRoutes
+31
View File
@@ -0,0 +1,31 @@
require('dotenv').config()
const express = require('express')
const { createMockContext } = require('./context/createMockContext')
const { createMockLdapService } = require('./ldap/createMockLdapService')
const { registerRoutes } = require('./routes/registerRoutes')
const app = express()
app.use(express.json({ limit: '2mb' }))
const { requestLogger, log } = require('./logger')
app.use(requestLogger)
const context = createMockContext()
const ldapService = createMockLdapService({ ldapConfig: context.ldap })
registerRoutes({ app, context, ldapService })
app.use((req, res) => {
res.status(404).json({
error: 'NOT_MOCKED',
message: `No mock configured for ${req.method} ${req.path}`,
})
})
app.listen(context.port, context.host, () => {
console.log(`Mock gateway listening on http://${context.host}:${context.port}`)
})
ldapService.start()
+199
View File
@@ -0,0 +1,199 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { createMockContext } = require('../src/context/createMockContext')
function withEnv(overrides, fn) {
const previousValues = new Map()
for (const [key, value] of Object.entries(overrides)) {
previousValues.set(key, process.env[key])
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
try {
return fn()
} finally {
for (const [key, value] of previousValues.entries()) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}
test('uses a timestamp seed when MOCK_PROMOTION_REDEMPTION_ID_START is unset', () =>
withEnv(
{
MOCK_PROMOTION_REDEMPTION_ID_START: undefined,
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const originalDateNow = Date.now
Date.now = () => 1760000000123
try {
const context = createMockContext()
assert.equal(context.nextMockPromotionRedemptionId(), 1760000000)
assert.equal(context.nextMockPromotionRedemptionId(), 1760000001)
} finally {
Date.now = originalDateNow
}
},
))
test('falls back to an int-safe timestamp seed when configured redemption id start is out of range', () =>
withEnv(
{
MOCK_PROMOTION_REDEMPTION_ID_START: '1774243846923',
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const originalDateNow = Date.now
Date.now = () => 1760000000123
try {
const context = createMockContext()
assert.equal(context.nextMockPromotionRedemptionId(), 1760000000)
} finally {
Date.now = originalDateNow
}
},
))
test('fallback redeemed promotion detail consumes the same incrementing redemption id sequence', () =>
withEnv(
{
MOCK_PROMOTION_REDEMPTION_ID_START: '910000',
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const firstOutcome = context.buildMockRedeemedPromotionOutcome({
data: { loginId: 'tester' },
schema: {},
})
const secondOutcome = context.buildMockRedeemedPromotionOutcome({
data: { loginId: 'tester' },
schema: {},
})
assert.equal(firstOutcome.redemptionId, 910000)
assert.equal(secondOutcome.redemptionId, 910001)
},
))
test('uses the birthday promotion fixture as the default promotion source when no override is configured', () =>
withEnv(
{
MOCK_PROMOTION_CAPTURE_FILE: undefined,
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const promotions = context.getPromotionLists('910700001')
assert.equal(promotions.eligiblePromotionList.length, 1)
assert.equal(promotions.eligiblePromotionList[0].code, 'PROMO-BIRTHDAY-2026')
assert.equal(promotions.eligiblePromotionList[0].ruleId, 3800801)
assert.equal(promotions.eligiblePromotionList[0].outcomeList.length, 3)
assert.equal(promotions.eligiblePromotionList[0].isEntitled, true)
},
))
test('backdate promotions returned by the fixed fixture satisfy the frontend redeemable contract', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const promotions = context.getBackdatePromotionLists('800038008')
assert.ok(promotions.entitledPromotionList.length > 0)
promotions.entitledPromotionList.forEach((item) => {
assert.equal(item.isEntitled, true)
assert.equal(item.isRedeemCompleted, false)
assert.ok(Number(item.awardsEntitled) > Number(item.awardsCount))
})
},
))
test('backdate promotions can be resolved through the default 80038008 member alias', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const promotions = context.getBackdatePromotionLists('80038008')
assert.ok(promotions.entitledPromotionList.length > 0)
assert.equal(promotions.entitledPromotionList[0].ruleId, 3800801)
assert.equal(promotions.entitledPromotionList[0].isEntitled, true)
},
))
test('persists runtime recorded redeemed promotions and resumes redemption id sequence', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mock-gateway-recording-'))
const recordingFile = path.join(tempDir, 'redeemed-promotions.recorded.json')
try {
withEnv(
{
MOCK_PROMOTION_REDEMPTION_ID_START: '910000',
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'true',
MOCK_REDEEMED_PROMOTION_RECORDING_FILE: recordingFile,
},
() => {
const firstContext = createMockContext()
firstContext.recordRedeemedPromotionOutcome('80010991', {
redemptionId: 910005,
promotionId: 11225,
outcome: {
redeemedList: [
{
outcomeRemark: 'qa-ebonus-auto',
transId: 'tx-910005',
},
],
},
})
const persisted = JSON.parse(fs.readFileSync(recordingFile, 'utf8'))
assert.equal(persisted.accounts['80010991'][0].redemptionId, 910005)
const secondContext = createMockContext()
assert.equal(secondContext.getRecordedRedeemedPromotionOutcomes('80010991')[0].redemptionId, 910005)
assert.equal(secondContext.nextMockPromotionRedemptionId(), 910006)
},
)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('account-specific redeemed promotions fixture preserves voidedByEmployeeId for the 2026-04-06 voided record', () => {
const context = createMockContext()
const fixture = context.getAccountSpecificRedeemedPromotionsFixture('100000059')
const targetOutcome = fixture.data.outcomeList.find(
(item) =>
item.voidedDtm === '2026-04-06T16:11:05.584548' &&
item.outcome?.redeemedList?.[0]?.transId === '43a9ab81-bbff-4812-bd64-bfd4eaa50d72',
)
assert.ok(targetOutcome)
assert.equal(targetOutcome.voidedBy, 'qa-randyng')
assert.equal(targetOutcome.voidedByEmployeeId, '171853')
})
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { createMockContext } = require('../src/context/createMockContext')
const { buildCheckPrizeStockResponseXml } = require('../src/routes/gis/xmlResponse')
test('mock gis stock response marks default test unique codes as in stock', () => {
const context = createMockContext()
const response = context.getMockGisCheckPrizeStockResponse({
propertyId: 'GLP',
locationId: 'LOC013',
prizeIdentifier: '88888888',
prizeCode: 'mqa-gisprizec',
prizeName: 'MQA-GISPrizeC',
prizeCategory: 'Entertainment',
})
assert.equal(response.uniqueCode, '88888888')
assert.equal(response.quantity, 1)
assert.equal(response.errorCode, '')
assert.equal(response.errorMsg, '')
})
test('mock gis stock response returns NotFound for unknown unique codes', () => {
const context = createMockContext()
const response = context.getMockGisCheckPrizeStockResponse({
prizeIdentifier: 'unknown-code',
prizeCode: 'mqa-gisprizec',
})
assert.equal(response.quantity, 0)
assert.equal(response.errorCode, 'NotFound')
assert.equal(response.errorMsg, 'Not found matching prize')
})
test('check prize stock xml builder emits backend-compatible root and fields', () => {
const xml = buildCheckPrizeStockResponseXml([
{
propertyId: 'GLP',
locationId: 'LOC013',
prizeCode: 'mqa-gisprizec',
prizeName: 'MQA-GISPrizeC',
prizeCategory: 'Entertainment',
uniqueCode: '12341234',
quantity: 1,
errorCode: '',
errorMsg: '',
},
])
assert.match(xml, /<ArrayOfCheckPrizeStock_response_VM>/)
assert.match(xml, /<CheckPrizeStock_response_VM>/)
assert.match(xml, /<uniqueCode>12341234<\/uniqueCode>/)
assert.match(xml, /<quantity>1<\/quantity>/)
})
+67
View File
@@ -0,0 +1,67 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
resolveCardNumForLookup,
resolvePlayerIdForLookup,
TASK_15171_ACTIVE_CARD,
TASK_15171_INACTIVE_CARD,
TASK_15171_PLAYER_ID,
} = require('../src/routes/ewl/edip/memberProfileCardNum')
test('task 15171 returns only active card for playerId lookup', () => {
assert.deepEqual(
resolveCardNumForLookup({
accountNum: '',
playerId: TASK_15171_PLAYER_ID,
enabled: true,
}),
[TASK_15171_ACTIVE_CARD],
)
})
test('task 15171 returns only active card when inactive card is queried', () => {
assert.deepEqual(
resolveCardNumForLookup({
accountNum: TASK_15171_INACTIVE_CARD,
playerId: TASK_15171_PLAYER_ID,
enabled: true,
}),
[TASK_15171_ACTIVE_CARD],
)
assert.ok(
!resolveCardNumForLookup({
accountNum: TASK_15171_INACTIVE_CARD,
playerId: TASK_15171_PLAYER_ID,
enabled: true,
}).includes(TASK_15171_INACTIVE_CARD),
)
})
test('task 15171 resolves playerId from active or inactive card number', () => {
assert.equal(
resolvePlayerIdForLookup({
accountNum: TASK_15171_INACTIVE_CARD,
playerId: '',
}),
TASK_15171_PLAYER_ID,
)
assert.equal(
resolvePlayerIdForLookup({
accountNum: TASK_15171_ACTIVE_CARD,
playerId: '',
}),
TASK_15171_PLAYER_ID,
)
})
test('default card_num echoes queried account when task 15171 disabled', () => {
assert.deepEqual(
resolveCardNumForLookup({
accountNum: '80000001',
playerId: '80000001',
enabled: false,
}),
['80000001'],
)
})
+103
View File
@@ -0,0 +1,103 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const { createMockContext } = require('../src/context/createMockContext')
const registerMemberProfileRoute = require('../src/routes/ewl/edip/memberProfile')
function withEnv(overrides, fn) {
const previousValues = new Map()
for (const [key, value] of Object.entries(overrides)) {
previousValues.set(key, process.env[key])
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
try {
return fn()
} finally {
for (const [key, value] of previousValues.entries()) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}
async function startMemberProfileApp(envOverrides = {}) {
return withEnv(envOverrides, async () => {
const context = createMockContext()
const app = express()
app.use(express.json({ limit: '2mb' }))
registerMemberProfileRoute({ app, context })
const server = await new Promise((resolve) => {
const instance = app.listen(0, '127.0.0.1', () => resolve(instance))
})
const address = server.address()
return {
baseUrl: `http://${address.address}:${address.port}`,
close: async () =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
}),
}
})
}
test('member profile route returns task 15171 active card_num for playerId 600000154', async () => {
const app = await startMemberProfileApp({
MOCK_TASK_15171_ENABLED: 'true',
})
try {
const response = await fetch(`${app.baseUrl}/ewl/edip/v1/member/profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: {}, data: { playerId: '600000154' } }),
})
const body = await response.json()
assert.equal(response.status, 200)
assert.equal(body.status, true)
assert.equal(body.data.playerId, '600000154')
assert.deepEqual(body.data.card_num, ['2600000154002'])
assert.ok(!body.data.card_num.includes('2600000154001'))
} finally {
await app.close()
}
})
test('member profile route returns active card_num when inactive card is queried', async () => {
const app = await startMemberProfileApp({
MOCK_TASK_15171_ENABLED: 'true',
})
try {
const response = await fetch(`${app.baseUrl}/ewl/edip/v1/member/profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: {}, data: { accountNum: '2600000154001' } }),
})
const body = await response.json()
assert.equal(response.status, 200)
assert.equal(body.data.playerId, '600000154')
assert.deepEqual(body.data.card_num, ['2600000154002'])
} finally {
await app.close()
}
})
+107
View File
@@ -0,0 +1,107 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const { createMockContext } = require('../src/context/createMockContext')
const registerMemberProfileRoute = require('../src/routes/ewl/edip/memberProfile')
function withEnv(overrides, fn) {
const previousValues = new Map()
for (const [key, value] of Object.entries(overrides)) {
previousValues.set(key, process.env[key])
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
try {
return fn()
} finally {
for (const [key, value] of previousValues.entries()) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}
async function startMemberProfileApp(envOverrides = {}) {
return withEnv(envOverrides, async () => {
const context = createMockContext()
const app = express()
app.use(express.json({ limit: '2mb' }))
registerMemberProfileRoute({ app, context })
const server = await new Promise((resolve) => {
const instance = app.listen(0, '127.0.0.1', () => resolve(instance))
})
const address = server.address()
return {
baseUrl: `http://${address.address}:${address.port}`,
close: async () =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
}),
}
})
}
test('member profile route returns forced 503 when configured', async () => {
const app = await startMemberProfileApp({
MOCK_MEMBER_PROFILE_FORCE_STATUS_CODE: '503',
MOCK_MEMBER_PROFILE_FORCE_ERROR_CODE: 'MOCK_503',
MOCK_MEMBER_PROFILE_FORCE_ERROR_MSG: 'Mock member profile unavailable',
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
})
try {
const response = await fetch(`${app.baseUrl}/ewl/edip/v1/member/profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: {}, data: { accountNum: '80000001' } }),
})
const body = await response.json()
assert.equal(response.status, 503)
assert.equal(body.errorCode, 'MOCK_503')
assert.equal(body.message, 'Mock member profile unavailable')
} finally {
await app.close()
}
})
test('member profile route delays response when configured', async () => {
const app = await startMemberProfileApp({
MOCK_MEMBER_PROFILE_DELAY_MS: '120',
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
})
try {
const startedAt = Date.now()
const response = await fetch(`${app.baseUrl}/ewl/edip/v1/member/profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: {}, data: { accountNum: '80000001' } }),
})
const elapsedMs = Date.now() - startedAt
const body = await response.json()
assert.equal(response.status, 200)
assert.equal(body.status, true)
assert.ok(elapsedMs >= 100, `expected delayed response, got ${elapsedMs}ms`)
} finally {
await app.close()
}
})
@@ -0,0 +1,242 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { createMockContext } = require('../src/context/createMockContext')
const registerPlayerPromotionFulfillmentJourneyRoute = require('../src/routes/ewl/bal/playerPromotionFulfillmentJourney')
const registerPlayerPromotionFulfillmentJourneyDetailRoute = require('../src/routes/ewl/bal/playerPromotionFulfillmentJourneyDetail')
const { buildFulfillmentJourneyResponse } =
registerPlayerPromotionFulfillmentJourneyRoute.__internal
const { buildFulfillmentJourneyDetailResponse } =
registerPlayerPromotionFulfillmentJourneyDetailRoute.__internal
function withEnv(overrides, fn) {
const previousValues = new Map()
for (const [key, value] of Object.entries(overrides)) {
previousValues.set(key, process.env[key])
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
try {
return fn()
} finally {
for (const [key, value] of previousValues.entries()) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}
function createRegisteredPostRoute(registerRoute, context) {
const registrations = []
const app = {
post(paths, handler) {
registrations.push({ paths, handler })
},
}
registerRoute({ app, context })
assert.equal(registrations.length, 1)
return registrations[0]
}
function invokeHandler(handler, body) {
const response = {
statusCode: 0,
payload: null,
}
const res = {
status(code) {
response.statusCode = code
return this
},
json(payload) {
response.payload = payload
return this
},
}
handler({ body }, res)
return response
}
test('summary 默认返回 accountNum/ruleId,并包含 Bally 风格字段', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const response = buildFulfillmentJourneyResponse({
context,
requestBody: {
data: {
accountNum: '80010991',
ruleId: 71768024,
},
},
})
assert.equal(response.errorCode, '')
assert.equal(response.errorMsg, '')
assert.equal(response.accountNum, '80010991')
assert.equal(response.ruleId, 71768024)
assert.ok(Array.isArray(response.eligibleCriteriaList))
assert.ok(Array.isArray(response.journeyList))
assert.ok(response.eligibleCriteriaList.length > 0)
assert.ok(response.journeyList.length > 0)
assert.deepEqual(Object.keys(response.eligibleCriteriaList[0]).sort(), [
'attribute',
'operator',
'qualify',
'value',
'valueArray',
])
assert.deepEqual(Object.keys(response.journeyList[0]).sort(), [
'basePts',
'memberNo',
'periodEndTime',
'periodStartTime',
'progressId',
'ruleId',
'ruleName',
])
},
))
test('summary empty-result 开关打开时返回空数组', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
MOCK_PLAYERPROMOTION_FULFILLMENT_JOURNEY_EMPTY_RESULT: 'true',
},
() => {
const context = createMockContext()
const response = buildFulfillmentJourneyResponse({
context,
requestBody: {
data: {
accountNum: '80010991',
ruleId: 71768024,
},
},
})
assert.deepEqual(response.eligibleCriteriaList, [])
assert.deepEqual(response.journeyList, [])
},
))
test('detail 业务错误时保留 errorCode/errorMsg/accountNum/ruleId', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
MOCK_PLAYERPROMOTION_FULFILLMENT_JOURNEY_DETAIL_ERROR_CODE: 'BIZ_1001',
MOCK_PLAYERPROMOTION_FULFILLMENT_JOURNEY_DETAIL_ERROR_MSG: 'rule is not active',
},
() => {
const context = createMockContext()
const response = buildFulfillmentJourneyDetailResponse({
context,
requestBody: {
data: {
accountNum: '80010991',
ruleId: 71768024,
},
},
})
assert.equal(response.errorCode, 'BIZ_1001')
assert.equal(response.errorMsg, 'rule is not active')
assert.equal(response.accountNum, '80010991')
assert.equal(response.ruleId, 71768024)
assert.ok(Array.isArray(response.journeyList))
assert.deepEqual(Object.keys(response.journeyList[0]).sort(), [
'areaCode',
'ballyTranId',
'basePts',
'casinoCode',
'deptCode',
'earnType',
'gameCode',
'gamingDt',
'locnCode',
'memberNo',
'periodEndTime',
'periodStartTime',
'postDtm',
'progressId',
'ruleId',
'ruleName',
])
},
))
test('summary route 会注册正确路径并返回 200 JSON', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
},
() => {
const context = createMockContext()
const registration = createRegisteredPostRoute(
registerPlayerPromotionFulfillmentJourneyRoute,
context,
)
assert.deepEqual(registration.paths, [
'/ewl/promotion/v1/playerpromotion/fulfillmentJourney',
'/ewl/bal/v1/playerpromotion/fulfillmentJourney',
])
const response = invokeHandler(registration.handler, {
data: {
accountNum: '80010991',
ruleId: 71768024,
},
})
assert.equal(response.statusCode, 200)
assert.equal(response.payload.accountNum, '80010991')
assert.equal(response.payload.ruleId, 71768024)
},
))
test('detail route 会注册正确路径并返回 200 JSON', () =>
withEnv(
{
MOCK_REDEEMED_PROMOTION_RECORDING_ENABLED: 'false',
MOCK_PLAYERPROMOTION_FULFILLMENT_JOURNEY_DETAIL_ERROR_CODE: 'BIZ_1001',
},
() => {
const context = createMockContext()
const registration = createRegisteredPostRoute(
registerPlayerPromotionFulfillmentJourneyDetailRoute,
context,
)
assert.deepEqual(registration.paths, [
'/ewl/promotion/v1/playerpromotion/fulfillmentJourneyDetail',
'/ewl/bal/v1/playerpromotion/fulfillmentJourneyDetail',
])
const response = invokeHandler(registration.handler, {
data: {
accountNum: '80010991',
ruleId: 71768024,
},
})
assert.equal(response.statusCode, 200)
assert.equal(response.payload.errorCode, 'BIZ_1001')
assert.equal(response.payload.accountNum, '80010991')
},
))
+463
View File
@@ -0,0 +1,463 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const registerPlayerPromotionRedeemRoute = require('../src/routes/ewl/bal/playerPromotionRedeem')
const {
ISSUE_TARGET_TO_WALLET_HEADER,
bridgePromotionRedeemToEwallet,
createMockPromotionRedeemResult,
isBackdatePromotionRedeemRequest,
registerableRoutes,
mergeIssueTargetToWalletOverrides,
mergeRuntimeIssueTargetToWalletOverrides,
resolveIssueTargetToWallet,
} = registerPlayerPromotionRedeemRoute.__internal
test('registers both normal and backdate promotion redeem routes', () => {
assert.deepEqual(registerableRoutes, [
'/ewl/promotion/v1/playerpromotion/redeem',
'/ewl/promotion/v1/playerpromotion/redeemBackDate',
'/ewl/bal/v1/playerpromotion/redeem',
'/ewl/bal/v1/playerpromotion/redeemBackDate',
])
})
test('detects backdate redeem requests from route and payload', () => {
assert.equal(
isBackdatePromotionRedeemRequest('/ewl/promotion/v1/playerpromotion/redeemBackDate', {
data: {},
}),
true,
)
assert.equal(
isBackdatePromotionRedeemRequest('/ewl/promotion/v1/playerpromotion/redeem', {
data: { backdate: '2026-03-23' },
}),
true,
)
assert.equal(
isBackdatePromotionRedeemRequest('/ewl/promotion/v1/playerpromotion/redeem', {
data: {},
}),
false,
)
})
test('creates a local redeemed outcome for backdate requests without bridging back to backend', () => {
const mockResult = createMockPromotionRedeemResult({
context: {
resolvePromotionRedeemSelection(accountNum, promotionId, promotionRuleId, outcomeIds, options) {
assert.equal(accountNum, '800038008')
assert.equal(promotionId, 71768004)
assert.equal(promotionRuleId, 71768026)
assert.deepEqual(outcomeIds, [71768072, 71768073])
assert.deepEqual(options, { backdate: true })
return {
promotion: {
code: 'LP-EE-CNYGS26',
name: 'CNY Gift Set Earn & Get-2026',
ruleName: 'CNYGS-188pts-Martell + LCH-Can Abalone',
},
selectedOutcomes: [
{
value: 'lp-ee-cnygs26-188pts-martellnoblige-me',
prize: {
prizeCode: 'lp-ee-cnygs26-188pts-martellnoblige-me',
prizeCategory: 'PROMOTION',
},
},
{
value: 'lp-ee-cnygs26-188pts-lchabalone-me',
prize: {
prizeCode: 'lp-ee-cnygs26-188pts-lchabalone-me',
prizeCategory: 'PROMOTION',
},
},
],
}
},
nextMockPromotionRedemptionId() {
return 900001
},
generateToken(prefix) {
return `${prefix}_001`
},
},
requestBody: {
schema: {},
data: {
accountNum: '800038008',
promotionId: 71768004,
promotionRuleId: 71768026,
outcomeId: [71768072, 71768073],
quantity: 1,
backdate: '2026-03-23',
},
},
accountNum: '800038008',
backdate: true,
})
assert.equal(mockResult.redeemedOutcome.redemptionId, 900001)
assert.equal(mockResult.redeemedOutcome.promotionId, 71768004)
assert.equal(mockResult.redeemedOutcome.prizeRuleId, 71768026)
assert.equal(mockResult.redeemedOutcome.outcome.redeemedList.length, 2)
assert.equal(
mockResult.redeemedOutcome.outcome.redeemedList[0].transId,
'mock_backdate_redemption_001',
)
})
test('bridges backdate requests to backend when promotion bridge is enabled', async (t) => {
const originalFetch = global.fetch
t.after(() => {
global.fetch = originalFetch
})
const fetchCalls = []
global.fetch = async (url, options = {}) => {
const parsedBody = options.body ? JSON.parse(options.body) : null
fetchCalls.push({
url,
method: options.method,
headers: options.headers,
body: parsedBody,
})
if (url === 'http://backend.test/api/v1/auth/login') {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify({ success: true, data: { accessToken: 'token-123' } }),
}
}
if (url === 'http://backend.test/api/v1/prize-redemptions') {
assert.equal(parsedBody.customerId, '80038008')
assert.equal(parsedBody.metadata.redemption_id, 900002)
assert.equal(parsedBody.metadata.promotion_id, 71768004)
assert.equal(parsedBody.targetPrizes.length, 2)
assert.deepEqual(
parsedBody.targetPrizes.map((targetPrize) => targetPrize.issueTargetToWallet),
[true, false],
)
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () =>
JSON.stringify({
success: true,
data: {
publicId: 'backend-redemption-001',
overallStatus: 'COMPLETED',
},
}),
}
}
throw new Error(`Unexpected fetch URL: ${url}`)
}
const bridgeResult = await bridgePromotionRedeemToEwallet({
context: {
resolvePromotionRedeemSelection(accountNum, promotionId, promotionRuleId, outcomeIds, options) {
assert.equal(accountNum, '80038008')
assert.equal(promotionId, 71768004)
assert.equal(promotionRuleId, 71768024)
assert.deepEqual(outcomeIds, [71768070, 71768069])
assert.deepEqual(options, { backdate: true })
return {
promotion: {
code: 'LP-EE-CNYGS26',
name: 'CNY Gift Set Earn & Get-2026',
ruleName: 'CNYGS-188pts-Hennessy + LCH-Scallop',
},
selectedOutcomes: [
{
key: 71768070,
value: 'lp-ee-cnygs26-188pts-lchscallop-me',
prize: {
prizeCode: 'lp-ee-cnygs26-188pts-lchscallop-me',
prizeCategory: 'PROMOTION',
},
},
{
key: 71768069,
value: 'lp-ee-cnygs26-188pts-hennessyvsop-me',
prize: {
prizeCode: 'lp-ee-cnygs26-188pts-hennessyvsop-me',
prizeCategory: 'PROMOTION',
},
},
],
}
},
nextMockPromotionRedemptionId() {
return 900002
},
promotionRedemptionBridgeLoginUsername: 'mock.gateway',
promotionRedemptionBridgeLoginPassword: 'secret',
promotionRedemptionBridgeBaseUrl: 'http://backend.test',
promotionRedemptionBridgeTimeoutMs: 5000,
promotionRedemptionSourceBucket: 'POINT',
promotionRedemptionInitiateSource: 'PROMOTION',
generateToken(prefix) {
return `${prefix}_001`
},
},
requestBody: {
schema: {
computerName: 'BOH-DT200244',
ipAddress: '10.20.30.40',
transactionId: 'mock-backdate-request-001',
},
data: {
accountNum: '80038008',
promotionId: 71768004,
promotionRuleId: 71768024,
outcomeId: [71768070, 71768069],
issueTargetToWallet: [true, false],
quantity: 1,
loginId: 'admin',
casinoCode: 'GLP',
locationCode: 'GLP01',
backdate: '2026-04-21',
},
},
accountNum: '80038008',
backdate: true,
})
assert.equal(bridgeResult.redeemedOutcome.redemptionId, 900002)
assert.equal(bridgeResult.redeemedOutcome.outcome.redeemedList.length, 2)
assert.equal(
bridgeResult.redeemedOutcome.outcome.redeemedList[0].transId,
'backend-redemption-001',
)
assert.equal(fetchCalls.length, 2)
assert.equal(fetchCalls[0].url, 'http://backend.test/api/v1/auth/login')
assert.equal(fetchCalls[1].url, 'http://backend.test/api/v1/prize-redemptions')
})
test('header overrides are merged into request data for backend passthrough', () => {
const normalizedRequestBody = mergeIssueTargetToWalletOverrides(
{
data: {
outcomeId: [11330011, 11330012],
},
},
{
[ISSUE_TARGET_TO_WALLET_HEADER]: '{"11330011":false,"11330012":true}',
},
)
assert.deepEqual(normalizedRequestBody.data.issueTargetToWalletByOutcomeId, {
'11330011': false,
'11330012': true,
})
})
test('body overrides win over header overrides when both are present', () => {
const normalizedRequestBody = mergeIssueTargetToWalletOverrides(
{
data: {
issueTargetToWalletByOutcomeId: {
'11330011': true,
},
},
},
{
[ISSUE_TARGET_TO_WALLET_HEADER]: '{"11330011":false,"11330012":true}',
},
)
assert.deepEqual(normalizedRequestBody.data.issueTargetToWalletByOutcomeId, {
'11330011': true,
'11330012': true,
})
})
test('runtime overrides are merged for matching promotion outcomes', () => {
const normalizedRequestBody = mergeRuntimeIssueTargetToWalletOverrides(
{
data: {
promotionId: 11330,
promotionRuleId: 1133001,
outcomeId: [11330011, 11330012],
},
},
{
resolvePromotionRedeemFulfillmentOverrideByOutcomeId(input) {
assert.deepEqual(input, {
accountNum: '80010991',
promotionId: 11330,
promotionRuleId: 1133001,
outcomeIds: [11330011, 11330012],
})
return {
'11330011': false,
'11330012': true,
}
},
},
'80010991',
)
assert.deepEqual(normalizedRequestBody.data.issueTargetToWalletByOutcomeId, {
'11330011': false,
'11330012': true,
})
})
test('request overrides still win over runtime overrides', () => {
const normalizedRequestBody = mergeRuntimeIssueTargetToWalletOverrides(
{
data: {
promotionId: 11330,
promotionRuleId: 1133001,
outcomeId: [11330011, 11330012],
issueTargetToWalletByOutcomeId: {
'11330011': true,
},
},
},
{
resolvePromotionRedeemFulfillmentOverrideByOutcomeId() {
return {
'11330011': false,
'11330012': false,
}
},
},
'80010991',
)
assert.deepEqual(normalizedRequestBody.data.issueTargetToWalletByOutcomeId, {
'11330011': true,
'11330012': false,
})
})
test('defaults USER_CHOICE promotions to wallet when no explicit override is provided', () => {
const resolved = resolveIssueTargetToWallet({
requestData: {},
outcome: {
key: 11330011,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 0,
prizeIdentifier: null,
})
assert.equal(resolved, true)
})
test('uses non-empty gisPrizeIdentifier to resolve USER_CHOICE promotions as external', () => {
const resolved = resolveIssueTargetToWallet({
requestData: {},
outcome: {
key: 11330011,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 0,
prizeIdentifier: 'GIS-CODE-001',
})
assert.equal(resolved, false)
})
test('request outcome override takes precedence over delivery mode inference', () => {
const resolved = resolveIssueTargetToWallet({
requestData: {
issueTargetToWalletByOutcomeId: {
'11330011': false,
},
},
outcome: {
key: 11330011,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 0,
prizeIdentifier: null,
})
assert.equal(resolved, false)
})
test('fixture-level mockIssueTargetToWallet override is honored', () => {
const resolved = resolveIssueTargetToWallet({
requestData: {},
outcome: {
key: 11330011,
mockIssueTargetToWallet: false,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 0,
prizeIdentifier: null,
})
assert.equal(resolved, false)
})
test('request array override is honored by outcome index', () => {
const resolved = resolveIssueTargetToWallet({
requestData: {
issueTargetToWallet: [true, false],
},
outcome: {
key: 11330012,
prize: {
deliveryMode: 'WALLET_ONLY',
},
},
index: 1,
prizeIdentifier: null,
})
assert.equal(resolved, false)
})
test('request array override can distinguish duplicate outcome ids by position', () => {
const firstResolved = resolveIssueTargetToWallet({
requestData: {
issueTargetToWallet: [true, false, true],
},
outcome: {
key: 11330011,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 0,
prizeIdentifier: null,
})
const secondResolved = resolveIssueTargetToWallet({
requestData: {
issueTargetToWallet: [true, false, true],
},
outcome: {
key: 11330011,
prize: {
deliveryMode: 'USER_CHOICE',
},
},
index: 1,
prizeIdentifier: null,
})
assert.equal(firstResolved, true)
assert.equal(secondResolved, false)
})