初始化
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user