Skip to content

Commit a9b3a0e

Browse files
authored
[bcc-kz] Move API to WEB (#955)
1 parent 18fe18f commit a9b3a0e

File tree

13 files changed

+788
-120
lines changed

13 files changed

+788
-120
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { fetchAccounts } from '../../api'
2+
3+
function makeNetworkResponse (body) {
4+
return {
5+
ok: true,
6+
status: 200,
7+
statusText: 'OK',
8+
url: 'https://m.bcc.kz/mock',
9+
headers: {
10+
forEach: () => {}
11+
},
12+
text: async () => JSON.stringify(body)
13+
}
14+
}
15+
16+
function createJwt (payload) {
17+
const header = { alg: 'none', typ: 'JWT' }
18+
const encode = (obj) => Buffer.from(JSON.stringify(obj))
19+
.toString('base64')
20+
.replace(/=/g, '')
21+
.replace(/\+/g, '-')
22+
.replace(/\//g, '_')
23+
24+
return `${encode(header)}.${encode(payload)}.signature`
25+
}
26+
27+
describe('bcc-kz fetchAccounts web api', () => {
28+
beforeEach(() => {
29+
global.fetch = jest.fn()
30+
})
31+
32+
it('calls main endpoint with bearer token and session code', async () => {
33+
global.fetch
34+
.mockResolvedValueOnce(makeNetworkResponse({
35+
success: true,
36+
reason: {
37+
card: [],
38+
current: [],
39+
dep: [],
40+
loan: [],
41+
broker: [],
42+
metal: []
43+
}
44+
}))
45+
.mockResolvedValueOnce(makeNetworkResponse({
46+
success: true,
47+
reason: {
48+
accounts_info: []
49+
}
50+
}))
51+
52+
await fetchAccounts({
53+
accessToken: 'bearer-token',
54+
sessionCode: 'session-code'
55+
})
56+
57+
expect(global.fetch).toHaveBeenCalledTimes(2)
58+
const firstUrl = global.fetch.mock.calls[0][0]
59+
expect(firstUrl).toContain('https://m.bcc.kz/mb/!pkg_w_mb_main.operation?')
60+
expect(firstUrl).toContain('action=ACCOUNTS_INFO')
61+
expect(firstUrl).toContain('level=0')
62+
expect(firstUrl).toContain('session_code=session-code')
63+
expect(firstUrl).toContain('timestamp=')
64+
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer bearer-token')
65+
66+
const secondUrl = global.fetch.mock.calls[1][0]
67+
expect(secondUrl).toContain('action=ACCOUNTS_ADDITIONAL_INFO')
68+
})
69+
70+
it('recovers missing sessionCode from access token payload', async () => {
71+
global.fetch
72+
.mockResolvedValueOnce(makeNetworkResponse({
73+
success: true,
74+
reason: {
75+
card: [],
76+
current: [],
77+
dep: [],
78+
loan: [],
79+
broker: [],
80+
metal: []
81+
}
82+
}))
83+
.mockResolvedValueOnce(makeNetworkResponse({
84+
success: true,
85+
reason: {
86+
accounts_info: []
87+
}
88+
}))
89+
90+
await fetchAccounts({
91+
accessToken: createJwt({ session_state: 'jwt-session-code' })
92+
})
93+
94+
const firstUrl = global.fetch.mock.calls[0][0]
95+
expect(firstUrl).toContain('session_code=jwt-session-code')
96+
})
97+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { fetchTransactions } from '../../api'
2+
3+
function makeNetworkResponse (body) {
4+
return {
5+
ok: true,
6+
status: 200,
7+
statusText: 'OK',
8+
url: 'https://m.bcc.kz/mock',
9+
headers: {
10+
forEach: () => {}
11+
},
12+
text: async () => JSON.stringify(body)
13+
}
14+
}
15+
16+
function makeHtmlResponse (html) {
17+
return {
18+
ok: false,
19+
status: 504,
20+
statusText: 'Gateway Timeout',
21+
url: 'https://m.bcc.kz/mock',
22+
headers: {
23+
forEach: () => {}
24+
},
25+
text: async () => html
26+
}
27+
}
28+
29+
describe('bcc-kz fetchTransactions web api', () => {
30+
beforeEach(() => {
31+
global.fetch = jest.fn()
32+
})
33+
34+
it('uses GET_EXT_STATEMENT query in web format', async () => {
35+
global.fetch.mockResolvedValueOnce(makeNetworkResponse({
36+
success: true,
37+
stmt: []
38+
}))
39+
40+
await fetchTransactions(
41+
{
42+
accessToken: 'bearer-token',
43+
sessionCode: 'session-code'
44+
},
45+
{ productId: 14120379 },
46+
new Date('2026-02-27T00:00:00+06:00'),
47+
new Date('2026-03-05T00:00:00+06:00')
48+
)
49+
50+
expect(global.fetch).toHaveBeenCalledTimes(1)
51+
const url = global.fetch.mock.calls[0][0]
52+
expect(url).toContain('action=GET_EXT_STATEMENT')
53+
expect(url).toContain('account_id=14120379')
54+
expect(url).toContain('date_begin=27.02.2026')
55+
expect(url).toContain('date_end=05.03.2026')
56+
expect(url).toContain('session_code=session-code')
57+
expect(url).toContain('timestamp=')
58+
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer bearer-token')
59+
})
60+
61+
it('falls back to chunked requests on html 504 response', async () => {
62+
global.fetch
63+
.mockResolvedValueOnce(makeHtmlResponse('<html><h1>504 Gateway Time-out</h1></html>'))
64+
.mockResolvedValueOnce(makeNetworkResponse({
65+
success: true,
66+
stmt: [{ trn_id: 'a' }]
67+
}))
68+
.mockResolvedValueOnce(makeNetworkResponse({
69+
success: true,
70+
stmt: [{ trn_id: 'b' }]
71+
}))
72+
73+
const result = await fetchTransactions(
74+
{
75+
accessToken: 'bearer-token',
76+
sessionCode: 'session-code'
77+
},
78+
{ productId: 14120379 },
79+
new Date('2026-01-01T00:00:00+06:00'),
80+
new Date('2026-02-09T00:00:00+06:00')
81+
)
82+
83+
expect(global.fetch).toHaveBeenCalledTimes(3)
84+
expect(global.fetch.mock.calls[1][0]).toContain('date_begin=01.01.2026')
85+
expect(global.fetch.mock.calls[1][0]).toContain('date_end=31.01.2026')
86+
expect(global.fetch.mock.calls[2][0]).toContain('date_begin=01.02.2026')
87+
expect(global.fetch.mock.calls[2][0]).toContain('date_end=09.02.2026')
88+
expect(result).toEqual([{ trn_id: 'a' }, { trn_id: 'b' }])
89+
})
90+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { parse } from 'querystring'
2+
import { InvalidOtpCodeError } from '../../../../errors'
3+
import { login } from '../../api'
4+
5+
function makeNetworkResponse (body) {
6+
return {
7+
ok: true,
8+
status: 200,
9+
statusText: 'OK',
10+
url: 'https://m.bcc.kz/mock',
11+
headers: {
12+
forEach: () => {}
13+
},
14+
text: async () => JSON.stringify(body)
15+
}
16+
}
17+
18+
function createJwt (payload) {
19+
const header = { alg: 'none', typ: 'JWT' }
20+
const encode = (obj) => Buffer.from(JSON.stringify(obj))
21+
.toString('base64')
22+
.replace(/=/g, '')
23+
.replace(/\+/g, '-')
24+
.replace(/\//g, '_')
25+
26+
return `${encode(header)}.${encode(payload)}.signature`
27+
}
28+
29+
describe('bcc-kz login auth flow', () => {
30+
beforeEach(() => {
31+
global.ZenMoney = {
32+
device: { brand: 'Google', model: 'Pixel 7' },
33+
readLine: jest.fn().mockResolvedValue('2009')
34+
}
35+
global.fetch = jest.fn()
36+
})
37+
38+
it('sends web auth payload with lowercase action in PASS and TOKEN', async () => {
39+
global.fetch
40+
.mockResolvedValueOnce(makeNetworkResponse({ success: true, verified: false, token: 'otp-token' }))
41+
.mockResolvedValueOnce(makeNetworkResponse({ success: true, verified: true }))
42+
.mockResolvedValueOnce(makeNetworkResponse({
43+
access_token: 'bearer-token',
44+
provider_response: {
45+
session_code: 'session-code'
46+
}
47+
}))
48+
49+
const auth = { device: { deviceId: 'test-device-id' } }
50+
await login(
51+
{ phone: '87000000000', password: 'test-password' },
52+
auth
53+
)
54+
55+
expect(global.fetch).toHaveBeenCalledTimes(3)
56+
57+
const passBody = parse(global.fetch.mock.calls[0][1].body)
58+
expect(passBody.action).toBe('SIGN')
59+
expect(passBody.ACTION).toBeUndefined()
60+
expect(passBody.authType).toBe('PASS')
61+
expect(passBody.client_id).toBe('dbp-channels-bcc-web')
62+
63+
const otpBody = parse(global.fetch.mock.calls[1][1].body)
64+
expect(otpBody.action).toBe('SIGN')
65+
expect(otpBody.ACTION).toBeUndefined()
66+
expect(otpBody.authType).toBe('TOKEN')
67+
expect(otpBody.token).toBe('otp-token')
68+
expect(otpBody.verify_code).toBe('2009')
69+
70+
const connectBody = parse(global.fetch.mock.calls[2][1].body)
71+
expect(global.fetch.mock.calls[2][0]).toBe('https://m.bcc.kz/auth/realms/bank/protocol/openid-connect/token')
72+
expect(connectBody.action).toBe('CONNECT')
73+
expect(connectBody.client_id).toBe('dbp-channels-bcc-web')
74+
expect(connectBody.device_id).toBe('test-device-id')
75+
expect(connectBody.OS).toContain('OS:Android10')
76+
expect(auth.accessToken).toBe('bearer-token')
77+
expect(auth.sessionCode).toBe('session-code')
78+
})
79+
80+
it('maps OTP timeout response to InvalidOtpCodeError', async () => {
81+
global.fetch
82+
.mockResolvedValueOnce(makeNetworkResponse({ success: true, verified: false, token: 'otp-token' }))
83+
.mockResolvedValueOnce(makeNetworkResponse({ success: false, reason: 'Время жизни OTP истекло.' }))
84+
85+
await expect(
86+
login(
87+
{ phone: '87000000000', password: 'test-password' },
88+
{ device: { deviceId: 'test-device-id' } }
89+
)
90+
).rejects.toBeInstanceOf(InvalidOtpCodeError)
91+
})
92+
93+
it('extracts session code from access token when CONNECT body has no session_code', async () => {
94+
global.fetch
95+
.mockResolvedValueOnce(makeNetworkResponse({ success: true, verified: true, token: 'otp-token' }))
96+
.mockResolvedValueOnce(makeNetworkResponse({
97+
access_token: createJwt({ session_state: 'jwt-session-code' })
98+
}))
99+
100+
const auth = { device: { deviceId: 'test-device-id' } }
101+
await login(
102+
{ phone: '87000000000', password: 'test-password' },
103+
auth
104+
)
105+
106+
expect(auth.sessionCode).toBe('jwt-session-code')
107+
})
108+
})

src/plugins/bcc-kz/__tests__/converters/accounts/creditCard.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('convertAccounts', () => {
99
acc_id: 'I/210330/1/988',
1010
type: 'Visa_Gold_Reward.png',
1111
account20: 'KZ688562204112187559',
12-
card_num: '489993******2773',
12+
card_num: '',
1313
note: '#картакарта',
1414
module: '10',
1515
currency: 'KZT',
@@ -109,7 +109,7 @@ describe('convertAccounts', () => {
109109
savings: false,
110110
syncIds: [
111111
'KZ688562204112187559',
112-
'489993******2773'
112+
''
113113
],
114114
title: '#картакарта',
115115
totalAmountDue: 0,

src/plugins/bcc-kz/__tests__/converters/accounts/debitCard.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('convertAccounts', () => {
99
acc_id: 'ALM/19/P/217952-DC/19/302045',
1010
type: 'MasterCard_World_PayPass.png',
1111
account20: 'KZ248562204107417115',
12-
card_num: '510445******7127',
12+
card_num: '',
1313
note: 'MasterCard World PayPass ЗП',
1414
module: '10',
1515
currency: 'KZT',
@@ -66,7 +66,7 @@ describe('convertAccounts', () => {
6666
savings: false,
6767
syncIds: [
6868
'KZ248562204107417115',
69-
'510445******7127'
69+
''
7070
],
7171
title: 'MasterCard World PayPass ЗП',
7272
type: 'ccard'
@@ -82,7 +82,7 @@ describe('convertAccounts', () => {
8282
acc_id: 'ALM/20/P/378307-C/20/262206',
8383
type: 'Visa_Reward_Virtual.png',
8484
account20: 'KZ348562204109395444',
85-
card_num: '489993******6955',
85+
card_num: '',
8686
note: 'Virtual',
8787
module: '10',
8888
currency: 'KZT',
@@ -166,7 +166,7 @@ describe('convertAccounts', () => {
166166
id: '2879843',
167167
instrument: 'KZT',
168168
savings: false,
169-
syncIds: ['KZ348562204109395444', '489993******6955'],
169+
syncIds: ['KZ348562204109395444', ''],
170170
title: 'Virtual',
171171
type: 'ccard',
172172
virtual: true
@@ -182,7 +182,7 @@ describe('convertAccounts', () => {
182182
acc_id: 'ALM/22/P/251597-C/22/115708',
183183
type: 'Visa_TravelCard.png',
184184
account20: 'KZ908562204116516952',
185-
card_num: '446375******7973',
185+
card_num: '',
186186
note: '#TravelCard',
187187
module: '10',
188188
currency: 'KZT',
@@ -269,7 +269,7 @@ describe('convertAccounts', () => {
269269
savings: false,
270270
syncIds: [
271271
'KZ908562204116516952',
272-
'446375******7973'
272+
''
273273
],
274274
title: '#TravelCard',
275275
type: 'ccard'

0 commit comments

Comments
 (0)