feat(gpc): align helper flow with api key queue tasks

This commit is contained in:
朴圣佑
2026-05-07 03:06:09 +08:00
parent 7228a9e531
commit bab7e54384
18 changed files with 1730 additions and 566 deletions
+161 -1
View File
@@ -332,6 +332,167 @@ test('auto-run controller treats phone-number supply exhaustion as round-fatal a
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller treats ended GPC task as round-fatal and skips same-round retries', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => String(error?.message || error || '').replace(/^GPC_TASK_ENDED::/i, ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isGpcTaskEndedFailure: (error) => /GPC_TASK_ENDED::/i.test(error?.message || String(error || '')),
isRestartCurrentAttemptError: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('GPC_TASK_ENDED::等待 OTP 超过 60 秒,任务已超时');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(2, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, 'ended GPC task should fail current round and continue next round');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false);
assert.equal(events.accountRecords.length, 1);
assert.equal(events.accountRecords[0].status, 'failed');
assert.match(events.accountRecords[0].reason, /等待 OTP/);
assert.ok(events.logs.some(({ message }) => /GPC 任务.*继续下一轮|继续下一轮/.test(message)));
});
test('auto-run controller keeps same-round retrying for step9 local replacement exhaustion errors', async () => {
const events = {
logs: [],
@@ -1150,4 +1311,3 @@ test('auto-run controller retries 5sim rate limit failures instead of treating c
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
@@ -150,13 +150,16 @@ const self = {
.replace(/\\/+$/g, '')
.replace(/\\/api\\/checkout\\/start$/i, '')
.replace(/\\/api\\/gopay\\/(?:otp|pin)$/i, '')
.replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, '');
.replace(/\\/api\\/gp\\/tasks(?:\\/[^/?#]+)?(?:\\/(?:otp|pin|stop))?(?:\\?.*)?$/i, '')
.replace(/\\/api\\/gp\\/balance(?:\\?.*)?$/i, '')
.replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, '')
.replace(/\\/api\\/card\\/redeem-api-key(?:\\?.*)?$/i, '');
},
},
};
const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
gopayHelperApiUrl: 'https://gopay.hwork.pro',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz',
mailProvider: '163',
};
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
@@ -191,11 +194,19 @@ return {
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal');
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gopay.hwork.pro/api/checkout/start '),
'https://gopay.hwork.pro'
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.leftcode.xyz/api/checkout/start '),
'https://gpc.leftcode.xyz'
);
assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gopay.hwork.pro');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCardKey', ' card_123 '), 'card_123');
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.leftcode.xyz/api/gp/tasks/task_1/pin '),
'https://gpc.leftcode.xyz'
);
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.leftcode.xyz/api/gp/balance '),
'https://gpc.leftcode.xyz'
);
assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gpc.leftcode.xyz');
assert.equal(api.normalizePersistentSettingValue('gopayHelperApiKey', ' gpc-123 '), 'gpc-123');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456');
@@ -46,4 +46,8 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu
assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页');
assert.equal(loggingStatus.getLoginAuthStateLabel('add_email_page'), '添加邮箱页');
assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页');
assert.equal(
loggingStatus.getErrorMessage(new Error('GPC_TASK_ENDED::GPC OTP 超时,请重新创建任务')),
'GPC OTP 超时,请重新创建任务'
);
});
@@ -44,6 +44,9 @@ function createRouter(overrides = {}) {
clearStopRequest: () => {},
closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {},
completeStepFromBackground: async (step, payload) => {
events.notifyCompletions.push({ step, payload, via: 'completeStepFromBackground' });
},
deleteHotmailAccount: async () => {},
deleteHotmailAccounts: async () => {},
deleteIcloudAlias: async () => {},
@@ -540,7 +543,7 @@ test('message router refreshes GPC balance through explicit sidepanel message',
const state = {
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'http://localhost:18473/',
gopayHelperCardKey: 'state_card',
gopayHelperApiKey: 'state_api_key',
};
const { router, events } = createRouter({ state });
@@ -548,7 +551,7 @@ test('message router refreshes GPC balance through explicit sidepanel message',
type: 'REFRESH_GPC_CARD_BALANCE',
source: 'sidepanel',
payload: {
gopayHelperCardKey: 'payload_card',
gopayHelperApiKey: 'payload_api_key',
reason: 'manual',
},
}, {});
@@ -556,6 +559,6 @@ test('message router refreshes GPC balance through explicit sidepanel message',
assert.deepStrictEqual(response, { ok: true, balance: '余额 3' });
assert.equal(events.balanceRefreshes.length, 1);
assert.equal(events.balanceRefreshes[0].state.gopayHelperApiUrl, 'http://localhost:18473/');
assert.equal(events.balanceRefreshes[0].state.gopayHelperCardKey, 'payload_card');
assert.equal(events.balanceRefreshes[0].state.gopayHelperApiKey, 'payload_api_key');
assert.deepStrictEqual(events.balanceRefreshes[0].options, { reason: 'manual' });
});
+57 -55
View File
@@ -24,88 +24,90 @@ test('GoPay utils keeps GPC helper payment method distinct', () => {
assert.equal(api.normalizePlusPaymentMethod('unknown'), 'paypal');
});
test('GoPay utils builds GPC card balance URL from helper endpoints', () => {
test('GoPay utils builds GPC queue task and balance URLs from helper endpoints', () => {
const api = loadGoPayUtils();
assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gopay.hwork.pro');
assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gopay.hwork.pro');
assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gpc.leftcode.xyz');
assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gpc.leftcode.xyz');
assert.equal(
api.buildGpcHelperApiUrl('', '/api/checkout/start'),
'https://gopay.hwork.pro/api/checkout/start'
'https://gpc.leftcode.xyz/api/checkout/start'
);
assert.equal(
api.buildGpcCardBalanceUrl('http://localhost:18473/', ' card key/1 '),
'http://localhost:18473/api/card/balance?card_key=card%20key%2F1'
api.buildGpcApiKeyBalanceUrl('http://localhost:18473/'),
'http://localhost:18473/api/gp/balance'
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/checkout/start', 'GPC-1'),
'https://gopay.hwork.pro/api/card/balance?card_key=GPC-1'
api.buildGpcCardBalanceUrl('https://gpc.leftcode.xyz/api/gp/balance'),
'https://gpc.leftcode.xyz/api/gp/balance'
);
assert.deepEqual(
api.buildGpcApiKeyHeaders(' gpc-123 ', { Accept: 'application/json' }),
{ Accept: 'application/json', 'X-API-Key': 'gpc-123' }
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/card/balance?card_key=old', 'new'),
'https://gopay.hwork.pro/api/card/balance?card_key=new'
api.buildGpcTaskCreateUrl('https://gpc.leftcode.xyz/api/checkout/start'),
'https://gpc.leftcode.xyz/api/gp/tasks'
);
assert.equal(
api.buildGpcTaskQueryUrl('https://gpc.leftcode.xyz/api/gp/tasks/task_old?card_key=old', 'task/1'),
'https://gpc.leftcode.xyz/api/gp/tasks/task%2F1'
);
assert.equal(
api.buildGpcTaskActionUrl('https://gpc.leftcode.xyz/api/gp/tasks/task_old/stop', 'task_1', 'pin'),
'https://gpc.leftcode.xyz/api/gp/tasks/task_1/pin'
);
});
test('GoPay utils builds GPC OTP/PIN payloads with card_key and flow_id', () => {
test('GoPay utils builds GPC queue OTP/PIN payloads without card_key', () => {
const api = loadGoPayUtils();
assert.deepEqual(
api.buildGpcOtpPayload({
reference_id: ' ref_1 ',
otp: ' 12-34 56 ',
card_key: ' card_1 ',
gopay_guid: ' guid_1 ',
flow_id: ' flow_1 ',
redirect_url: 'https://pm-redirects.stripe.com/test',
}),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
gopay_guid: 'guid_1',
redirect_url: 'https://pm-redirects.stripe.com/test',
}
api.buildGpcTaskOtpPayload({ otp: ' 12-34 56 ', card_key: ' card_1 ', reference_id: 'ref_1' }),
{ otp: '123456' }
);
assert.deepEqual(
api.buildGpcOtpRetryPayload({ referenceId: 'ref_1', otp: '123456', cardKey: 'card_1', flowId: 'flow_1' }),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
code: '123456',
}
);
assert.deepEqual(
api.buildGpcPinPayload({
referenceId: 'ref_1',
challengeId: 'challenge_1',
gopayGuid: 'guid_1',
pin: '65-43-21',
cardKey: 'card_1',
flowId: 'flow_1',
}),
{
reference_id: 'ref_1',
challenge_id: 'challenge_1',
gopay_guid: 'guid_1',
pin: '654321',
card_key: 'card_1',
flow_id: 'flow_1',
}
api.buildGpcTaskPinPayload({ pin: '65-43-21', cardKey: 'card_1', challengeId: 'challenge_1' }),
{ pin: '654321' }
);
});
test('GoPay utils formats balance and maps linked-account errors', () => {
const api = loadGoPayUtils();
assert.equal(
api.formatGpcBalancePayload({ remaining_uses: 12, card_status: 'active', flow_id: 'flow_1' }),
'余额 12,状态 activeflow_id flow_1'
api.formatGpcBalancePayload({ remaining_uses: 12, status: 'active', used_uses: 2, flow_id: 'flow_1' }),
'余额 12已用 2状态 activeflow_id flow_1'
);
assert.equal(
api.formatGpcBalancePayload({
code: 200,
message: 'ok',
data: { remaining_uses: 0, total_uses: 3, used_uses: 3, status: 'active' },
}),
'余额 0/3,已用 3,状态 active'
);
assert.deepEqual(
api.unwrapGpcResponse({ code: 200, message: 'ok', data: { task_id: 'task_1' } }),
{ task_id: 'task_1' }
);
assert.equal(
api.extractGpcResponseErrorDetail({ errors: [{ loc: ['body', 'otp'], msg: 'Field required' }] }, 422),
'body.otp: Field required'
);
assert.equal(
api.extractGpcResponseErrorDetail({
code: 400,
message: 'invalid_param',
data: { detail: '手机号不能为空', fields: [{ field: 'phone_number', message: '必填' }] },
}, 400),
'手机号不能为空'
);
assert.equal(
api.extractGpcResponseErrorDetail({
code: 400,
message: 'invalid_param',
data: { fields: [{ field: 'phone_number', message: '必填' }] },
}, 400),
'phone_number: 必填'
);
assert.equal(
api.extractGpcResponseErrorDetail({ error_messages: ['account already linked'] }, 406),
'GOPAY已经绑了订阅,需要手动解绑'
@@ -84,6 +84,7 @@ function createExecutorHarness({
getState = null,
markCurrentRegistrationAccountUsed = async () => {},
probeIpProxyExit = null,
onSetState = null,
submitRedirectUrl = 'https://www.paypal.com/checkoutnow',
}) {
const api = loadPlusCheckoutBillingModule();
@@ -93,6 +94,7 @@ function createExecutorHarness({
injectedAllFrames: false,
logs: [],
messages: [],
sleeps: [],
states: [],
waitedUrls: [],
};
@@ -158,8 +160,13 @@ function createExecutorHarness({
getTabId: async () => null,
isTabAlive: async () => false,
markCurrentRegistrationAccountUsed,
setState: async (updates) => events.states.push(updates),
sleepWithStop: async () => {},
setState: async (updates) => {
events.states.push(updates);
if (typeof onSetState === 'function') {
await onSetState(updates, events);
}
},
sleepWithStop: async (ms) => events.sleeps.push(ms),
waitForTabCompleteUntilStopped: async () => checkoutTab,
waitForTabUrlMatchUntilStopped: async (tabId, matcher) => {
events.waitedUrls.push({ tabId });
@@ -798,30 +805,68 @@ test('Plus checkout billing reports when the payment iframe exists but cannot re
);
});
test('GPC billing normalizes API URL and submits OTP then PIN with card_key and flow_id', async () => {
function createGpcTaskResponse(data) {
return {
code: 200,
message: 'ok',
data: {
task_id: 'task_123',
phone_mode: 'manual',
status_text: data.status === 'completed' ? '充值完成' : (data.status === 'otp_ready' ? '等待 PIN' : '处理中'),
api_input_deadline_at: data.api_input_deadline_at ?? new Date(Date.now() + 60000).toISOString(),
...data,
},
};
}
test('GPC billing polls queue task, submits WhatsApp OTP then PIN, and waits until completed', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: '',
};
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.endsWith('/api/gopay/otp')) {
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_123') {
pollCount += 1;
if (pollCount === 1) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }),
};
}
if (pollCount === 2) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
return {
ok: true,
status: 200,
json: async () => ({ reference_id: 'ref_123', challenge_id: 'challenge_456' }),
json: async () => createGpcTaskResponse({ status: 'completed', status_text: '充值完成', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
if (url.endsWith('/api/gp/tasks/task_123/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
json: async () => createGpcTaskResponse({ status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
if (url.endsWith('/api/gp/tasks/task_123/pin')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ status: 'active', status_text: '处理中', remote_stage: 'payment_processing' }),
};
}
throw new Error(`unexpected url: ${url}`);
@@ -831,12 +876,10 @@ test('GPC billing normalizes API URL and submits OTP then PIN with card_key and
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_123',
gopayHelperGoPayGuid: 'guid_789',
gopayHelperApiUrl: 'https://gopay.hwork.pro/api/checkout/start',
gopayHelperTaskId: 'task_123',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/api/gp/tasks/task_old/otp',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_billing_123',
gopayHelperFlowId: 'flow_billing_123',
gopayHelperApiKey: 'gpc_billing_123',
});
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -850,29 +893,24 @@ test('GPC billing normalizes API URL and submits OTP then PIN with card_key and
await run;
assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/gopay/otp');
assert.deepEqual(JSON.parse(fetchCalls[0].options.body), {
reference_id: 'ref_123',
otp: '123456',
card_key: 'card_billing_123',
flow_id: 'flow_billing_123',
gopay_guid: 'guid_789',
});
assert.equal(fetchCalls[1].url, 'https://gopay.hwork.pro/api/gopay/pin');
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_123',
challenge_id: 'challenge_456',
gopay_guid: 'guid_789',
pin: '654321',
card_key: 'card_billing_123',
flow_id: 'flow_billing_123',
});
assert.equal(fetchCalls[0].url, 'https://gpc.leftcode.xyz/api/gp/tasks/task_123');
assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_billing_123');
const otpCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_123/otp'));
const pinCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_123/pin'));
assert.deepEqual(JSON.parse(otpCall.options.body), { otp: '123456' });
assert.equal(otpCall.options.headers['X-API-Key'], 'gpc_billing_123');
assert.deepEqual(JSON.parse(pinCall.options.body), { pin: '654321' });
assert.equal(pinCall.options.headers['X-API-Key'], 'gpc_billing_123');
assert.ok(fetchCalls.findIndex((call) => call.url.endsWith('/api/gp/tasks/task_123/pin')) < fetchCalls.length - 1);
assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_123' && state.gopayHelperTaskStatus === 'completed'), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
assert.ok(events.sleeps.includes(3000));
});
test('GPC billing reads OTP from local SMS helper when enabled', async () => {
test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () => {
const fetchCalls = [];
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
@@ -885,18 +923,40 @@ test('GPC billing reads OTP from local SMS helper when enabled', async () => {
json: async () => ({ ok: true, otp: '654321', message_id: 'sms-1' }),
};
}
if (url.endsWith('/api/gopay/otp')) {
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_sms') {
pollCount += 1;
if (pollCount === 1) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp' }),
};
}
if (pollCount === 2) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
return {
ok: true,
status: 200,
json: async () => ({ reference_id: 'ref_sms', challenge_id: 'challenge_sms' }),
json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'completed', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
if (url.endsWith('/api/gp/tasks/task_sms/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
if (url.endsWith('/api/gp/tasks/task_sms/pin')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'active', remote_stage: 'payment_processing' }),
};
}
throw new Error(`unexpected url: ${url}`);
@@ -906,10 +966,10 @@ test('GPC billing reads OTP from local SMS helper when enabled', async () => {
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_sms',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperTaskId: 'task_sms',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_sms',
gopayHelperApiKey: 'gpc_sms',
gopayHelperOtpChannel: 'sms',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
@@ -919,21 +979,21 @@ test('GPC billing reads OTP from local SMS helper when enabled', async () => {
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true);
const helperUrl = new URL(fetchCalls[0].url);
const helperUrl = new URL(fetchCalls[1].url);
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp');
assert.equal(helperUrl.searchParams.get('reference_id'), 'ref_sms');
assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000');
assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000');
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_sms',
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321',
card_key: 'card_sms',
});
assert.equal(events.completed[0].step, 7);
});
test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => {
const fetchCalls = [];
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
@@ -946,18 +1006,40 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
json: async () => ({ ok: true, otp: '765432', message_id: 'wa-1' }),
};
}
if (url.endsWith('/api/gopay/otp')) {
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_wa') {
pollCount += 1;
if (pollCount === 1) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }),
};
}
if (pollCount === 2) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
return {
ok: true,
status: 200,
json: async () => ({ reference_id: 'ref_wa', challenge_id: 'challenge_wa' }),
json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'completed', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
if (url.endsWith('/api/gp/tasks/task_wa/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }),
};
}
if (url.endsWith('/api/gp/tasks/task_wa/pin')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'active', remote_stage: 'payment_processing' }),
};
}
throw new Error(`unexpected url: ${url}`);
@@ -967,10 +1049,10 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_wa',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperTaskId: 'task_wa',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_wa',
gopayHelperApiKey: 'gpc_wa',
gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
@@ -979,19 +1061,221 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true);
const helperUrl = new URL(fetchCalls[0].url);
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp');
assert.equal(helperUrl.searchParams.get('reference_id'), 'ref_wa');
assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000');
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_wa',
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
otp: '765432',
card_key: 'card_wa',
});
assert.equal(events.completed[0].step, 7);
});
test('GPC billing retries OTP with compatibility field after HTTP 400', async () => {
test('GPC billing helper mode does not open OTP dialog when helper has no code and task times out', async () => {
const fetchCalls = [];
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ ok: true, status: 'waiting', otp: '', message: '未查询到验证码' }),
};
}
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_timeout') {
const queryCount = fetchCalls.filter((call) => call.url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_timeout').length;
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse(queryCount === 1
? {
task_id: 'task_timeout',
status: 'active',
remote_stage: 'whatsapp_otp_wait',
api_waiting_for: 'otp',
}
: {
task_id: 'task_timeout',
status: 'failed',
status_text: '充值失败',
remote_stage: 'api_otp_timeout',
error_message: '等待 OTP 超过 60 秒,任务已超时',
}),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_timeout',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_timeout',
gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperPhoneNumber: '+8613800138000',
}),
/GPC_TASK_ENDED::等待 OTP 超过 60 秒,任务已超时/
);
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_timeout/otp')), false);
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_timeout/stop')), false);
assert.ok(events.sleeps.includes(3000));
});
test('GPC billing helper mode requests newer OTP after invalid OTP error', async () => {
const fetchCalls = [];
let taskPollCount = 0;
let helperCallCount = 0;
let otpPostCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) {
helperCallCount += 1;
return {
ok: true,
status: 200,
json: async () => ({ ok: true, otp: helperCallCount === 1 ? '111111' : '222222', message_id: `sms-${helperCallCount}` }),
};
}
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_retry') {
taskPollCount += 1;
if (taskPollCount === 1) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp' }) };
}
if (taskPollCount === 2) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp', last_input_error: 'OTP 校验失败,请重新输入正确的 OTP', otp_invalid_count: 1 }) };
}
if (taskPollCount === 3) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) };
}
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'completed', remote_stage: 'completed' }) };
}
if (url.endsWith('/api/gp/tasks/task_retry/otp')) {
otpPostCount += 1;
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'otp_submitted_local' }) };
}
if (url.endsWith('/api/gp/tasks/task_retry/pin')) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'payment_processing' }) };
}
throw new Error(`unexpected url: ${url}`);
},
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_retry',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_retry',
gopayHelperOtpChannel: 'sms',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperOrderCreatedAt: 1710000000000,
});
const otpBodies = fetchCalls
.filter((call) => call.url.endsWith('/api/gp/tasks/task_retry/otp'))
.map((call) => JSON.parse(call.options.body));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
assert.equal(otpPostCount, 2);
const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/otp')).map((call) => new URL(call.url));
assert.equal(helperUrls.length, 2);
assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000');
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
});
test('GPC billing manual OTP wrong input opens next dialog only after previous one closes', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: '',
};
let pendingDialogCount = 0;
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_manual_retry') {
pollCount += 1;
if (pollCount === 1) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }) };
}
if (pollCount === 2) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp', last_input_error: 'OTP 校验失败,请重新输入正确的 OTP', otp_invalid_count: 1 }) };
}
if (pollCount === 3) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) };
}
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'completed', remote_stage: 'completed' }) };
}
if (url.endsWith('/api/gp/tasks/task_manual_retry/otp')) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'otp_submitted_local' }) };
}
if (url.endsWith('/api/gp/tasks/task_manual_retry/pin')) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'payment_processing' }) };
}
throw new Error(`unexpected url: ${url}`);
},
onSetState: async (updates) => {
if (updates?.plusManualConfirmationMethod !== 'gopay-otp') {
return;
}
pendingDialogCount += 1;
const resolvedOtp = pendingDialogCount === 1 ? '111111' : '222222';
setTimeout(() => {
currentState = {
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: updates.plusManualConfirmationRequestId,
gopayHelperResolvedOtp: resolvedOtp,
};
}, 0);
},
});
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_manual_retry',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_manual_retry',
});
await new Promise((resolve) => setTimeout(resolve, 20));
const firstPending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp');
assert.ok(firstPending);
assert.equal(events.states.filter((state) => state.plusManualConfirmationMethod === 'gopay-otp').length, 1);
await new Promise((resolve) => setTimeout(resolve, 650));
const pendingDialogs = events.states.filter((state) => state.plusManualConfirmationMethod === 'gopay-otp');
assert.equal(pendingDialogs.length, 2);
assert.notEqual(pendingDialogs[1].plusManualConfirmationRequestId, firstPending.plusManualConfirmationRequestId);
assert.match(pendingDialogs[1].plusManualConfirmationMessage, /OTP 校验失败/);
await run;
const otpBodies = fetchCalls
.filter((call) => call.url.endsWith('/api/gp/tasks/task_manual_retry/otp'))
.map((call) => JSON.parse(call.options.body));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
assert.equal(events.completed[0].step, 7);
});
test('GPC billing manual OTP cancel stops task and ends current round', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
@@ -1003,26 +1287,11 @@ test('GPC billing retries OTP with compatibility field after HTTP 400', async ()
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.endsWith('/api/gopay/otp') && fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length === 1) {
return {
ok: false,
status: 400,
json: async () => ({ error: 'otp field invalid' }),
};
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_cancel') {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_cancel', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }) };
}
if (url.endsWith('/api/gopay/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ challenge_id: 'challenge_retry' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
};
if (url.endsWith('/api/gp/tasks/task_cancel/stop')) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_cancel', status: 'discarded', status_text: '已停止' }) };
}
throw new Error(`unexpected url: ${url}`);
},
@@ -1031,35 +1300,145 @@ test('GPC billing retries OTP with compatibility field after HTTP 400', async ()
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_retry',
gopayHelperGoPayGuid: 'guid_retry',
gopayHelperRedirectUrl: 'https://pm-redirects.stripe.com/retry',
gopayHelperApiUrl: 'http://localhost:18473/',
gopayHelperTaskId: 'task_cancel',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_retry',
gopayHelperFlowId: 'flow_retry',
gopayHelperApiKey: 'gpc_cancel',
});
await new Promise((resolve) => setTimeout(resolve, 20));
const pending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp');
assert.ok(pending);
currentState = {
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: pending.plusManualConfirmationRequestId,
gopayHelperResolvedOtp: '123456',
gopayHelperResolvedOtp: '',
};
await run;
assert.equal(fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length, 2);
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_retry',
otp: '123456',
card_key: 'card_retry',
flow_id: 'flow_retry',
gopay_guid: 'guid_retry',
redirect_url: 'https://pm-redirects.stripe.com/retry',
code: '123456',
});
assert.equal(events.logs.some((entry) => /兼容字段重试/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
await assert.rejects(run, /GPC_TASK_ENDED::OTP 输入已取消,已结束当前 GPC 任务。/);
const stopCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_cancel/stop'));
assert.ok(stopCall);
assert.equal(stopCall.options.headers['X-API-Key'], 'gpc_cancel');
assert.equal(events.completed.length, 0);
});
test('GPC billing PIN failure ends task without retrying PIN', async () => {
const fetchCalls = [];
let pollCount = 0;
const { executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_pin_failed') {
pollCount += 1;
if (pollCount === 1) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) };
}
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'failed', status_text: '充值失败', remote_stage: 'gopay_validate_pin', failure_stage: 'gopay_validate_pin', failure_detail: 'PIN 校验失败', error_message: 'GoPay PIN validation failed' }) };
}
if (url.endsWith('/api/gp/tasks/task_pin_failed/pin')) {
return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'active', remote_stage: 'pin_submitted_local' }) };
}
throw new Error(`unexpected url: ${url}`);
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_pin_failed',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_pin_failed',
}),
/GPC_TASK_ENDED::GoPay PIN validation failedgopay_validate_pin/
);
assert.equal(fetchCalls.filter((call) => call.url.endsWith('/api/gp/tasks/task_pin_failed/pin')).length, 1);
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_pin_failed/stop')), false);
});
for (const terminalStatus of ['failed', 'expired', 'discarded']) {
test(`GPC billing throws readable error for terminal ${terminalStatus} task`, async () => {
const fetchCalls = [];
const { executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_bad') {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_bad',
status: terminalStatus,
status_text: terminalStatus,
error_message: '用户可读失败原因',
}),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_bad',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_bad',
}),
/GPC_TASK_ENDED::用户可读失败原因/
);
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_bad/stop')), false);
});
}
test('GPC billing stops task best-effort when flow is interrupted before terminal state', async () => {
const fetchCalls = [];
const { executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.leftcode.xyz/api/gp/tasks/task_stop') {
return {
ok: false,
status: 500,
json: async () => ({ code: 500, message: 'server_error', data: { detail: '临时失败' } }),
};
}
if (url.endsWith('/api/gp/tasks/task_stop/stop')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_stop', status: 'discarded', status_text: '已停止' }),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_stop',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_stop',
}),
/临时失败/
);
const stopCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_stop/stop'));
assert.ok(stopCall);
assert.deepEqual(JSON.parse(stopCall.options.body), {});
assert.equal(stopCall.options.headers['X-API-Key'], 'gpc_stop');
});
+59 -52
View File
@@ -107,7 +107,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
});
test('GPC checkout injects Plus script before reading ChatGPT session token and sends card_key', async () => {
test('GPC checkout injects Plus script before reading ChatGPT session token and sends X-API-Key', async () => {
const events = [];
const fetchCalls = [];
const executor = api.createPlusCheckoutCreateExecutor({
@@ -129,10 +129,16 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and
ok: true,
status: 200,
json: async () => ({
reference_id: 'ref_123',
gopay_guid: 'guid_456',
next_action: 'enter_otp',
flow_id: 'flow_789',
code: 200,
message: 'ok',
data: {
task_id: 'task_123',
status: 'active',
status_text: '处理中',
phone_mode: 'manual',
remote_stage: 'checkout_start',
otp_channel: 'whatsapp',
},
}),
};
},
@@ -149,12 +155,12 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and
await executor.executePlusCheckoutCreate({
email: 'Current.Round+GPC@Example.COM',
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPhoneNumber: '+8613800138000',
gopayPhone: '',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_test_123',
gopayHelperApiKey: 'gpc_test_123',
});
const readyIndex = events.findIndex((event) => event.type === 'ready');
@@ -167,20 +173,27 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and
includeAccessToken: true,
});
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/checkout/start');
assert.equal(fetchCalls[0].url, 'https://gpc.leftcode.xyz/api/gp/tasks');
const helperPayload = JSON.parse(fetchCalls[0].options.body);
assert.equal(helperPayload.customer_email, 'current.round+gpc@example.com');
assert.equal(helperPayload.card_key, 'card_test_123');
assert.deepEqual(helperPayload.gopay_link, {
type: 'gopay',
assert.deepEqual(helperPayload, {
access_token: 'session-access-token',
phone_mode: 'manual',
country_code: '86',
phone_number: '13800138000',
phone_mode: 'manual',
otp_channel: 'whatsapp',
});
assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_test_123');
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'card_key'), false);
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'customer_email'), false);
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'checkout_ui_mode'), false);
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'gopay_link'), false);
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'plan_name'), false);
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.plusCheckoutSource, 'gpc-helper');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, 'ref_123');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperFlowId, 'flow_789');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperTaskId, 'task_123');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperTaskStatus, 'active');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperStatusText, '处理中');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperRemoteStage, 'checkout_start');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, '');
assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0);
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper');
@@ -203,7 +216,11 @@ test('GPC checkout forwards selected SMS OTP channel', async () => {
return {
ok: true,
status: 200,
json: async () => ({ reference_id: 'ref_sms', next_action: 'enter_otp' }),
json: async () => ({
code: 200,
message: 'ok',
data: { task_id: 'task_sms', status: 'active', phone_mode: 'manual', remote_stage: 'checkout_start' },
}),
};
},
registerTab: async () => {},
@@ -216,25 +233,25 @@ test('GPC checkout forwards selected SMS OTP channel', async () => {
await executor.executePlusCheckoutCreate({
email: 'sms@example.com',
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_sms',
gopayHelperApiKey: 'gpc_sms',
gopayHelperOtpChannel: 'sms',
});
const helperPayload = JSON.parse(fetchCalls[0].options.body);
assert.equal(helperPayload.gopay_link.phone_mode, 'manual');
assert.equal(helperPayload.gopay_link.otp_channel, 'sms');
assert.equal(helperPayload.phone_mode, 'manual');
assert.equal(helperPayload.otp_channel, 'sms');
assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_sms');
assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'card_key'), false);
});
test('GPC checkout treats non-zero API amount as non-free-trial and does not create order', async () => {
const markCalls = [];
test('GPC checkout surfaces unified queue API errors', async () => {
const fetchCalls = [];
const events = [];
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info') => events.push({ type: 'log', message, level }),
addLog: async () => {},
chrome: {
tabs: {
create: async () => {
@@ -243,27 +260,20 @@ test('GPC checkout treats non-zero API amount as non-free-trial and does not cre
remove: async () => {},
},
},
completeStepFromBackground: async () => {
throw new Error('should not complete step 6 for non-free-trial checkout');
},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
return {
ok: true,
status: 200,
ok: false,
status: 400,
json: async () => ({
reference_id: 'ref_paid',
gopay_guid: 'guid_paid',
next_action: 'enter_otp',
checkout: { amount_due: 'Rp 29.000' },
code: 400,
message: 'invalid_param',
data: { detail: 'access_token 无效' },
}),
};
},
markCurrentRegistrationAccountUsed: async (state, options) => {
markCalls.push({ state, options });
return { updated: true };
},
registerTab: async () => {},
sendTabMessageUntilStopped: async () => {},
setState: async () => {},
@@ -275,22 +285,19 @@ test('GPC checkout treats non-zero API amount as non-free-trial and does not cre
() => executor.executePlusCheckoutCreate({
email: 'paid@example.com',
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
chatgptAccessToken: 'state-access-token',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_paid_456',
gopayHelperApiKey: 'gpc_paid_456',
}),
/PLUS_CHECKOUT_NON_FREE_TRIAL::.*余额非 0/
/创建 GPC 订单失败:access_token 无效/
);
assert.equal(fetchCalls.length, 1);
assert.equal(JSON.parse(fetchCalls[0].options.body).card_key, 'card_paid_456');
assert.equal(markCalls.length, 1);
assert.equal(markCalls[0].state.email, 'paid@example.com');
assert.equal(markCalls[0].options.reason, 'plus-checkout-non-free-trial');
assert.equal(events.some((event) => event.type === 'log' && /订单已创建/.test(event.message)), false);
assert.equal(Object.prototype.hasOwnProperty.call(JSON.parse(fetchCalls[0].options.body), 'card_key'), false);
assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_paid_456');
});
test('GPC checkout does not fall back to browser GoPay phone fields', async () => {
@@ -319,7 +326,7 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () =
await assert.rejects(
() => executor.executePlusCheckoutCreate({
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
chatgptAccessToken: 'state-access-token',
email: 'helper-phone-test@example.com',
gopayPhone: '+8613800138000',
@@ -327,13 +334,13 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () =
gopayPin: '123456',
gopayHelperPhoneNumber: '',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_phone_test',
gopayHelperApiKey: 'gpc_phone_test',
}),
/缺少手机号/
);
});
test('GPC checkout rejects missing card key before calling helper API', async () => {
test('GPC checkout rejects missing API Key before calling helper API', async () => {
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async () => {},
chrome: {
@@ -347,7 +354,7 @@ test('GPC checkout rejects missing card key before calling helper API', async ()
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => {
throw new Error('should not call helper API without card key');
throw new Error('should not call helper API without API Key');
},
registerTab: async () => {},
sendTabMessageUntilStopped: async () => {},
@@ -359,14 +366,14 @@ test('GPC checkout rejects missing card key before calling helper API', async ()
await assert.rejects(
() => executor.executePlusCheckoutCreate({
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperApiUrl: 'https://gpc.leftcode.xyz/',
chatgptAccessToken: 'state-access-token',
email: 'missing-card@example.com',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: '',
gopayHelperApiKey: '',
}),
/缺少卡密/
/缺少 API Key/
);
});
+2 -2
View File
@@ -205,7 +205,7 @@ return {
});
});
test('sidepanel Plus UI shows GPC fields and purchase button only for GPC without API input', () => {
test('sidepanel Plus UI shows GPC fields and purchase button only for GPC', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'),
@@ -253,7 +253,7 @@ return {
assert.equal(api.rowPayPalAccount.style.display, 'none');
assert.equal(api.btnGpcCardKeyPurchase.style.display, '');
assert.equal(api.rows.rowGpcHelperApi.style.display, 'none');
assert.equal(api.rows.rowGpcHelperApi.style.display, '');
assert.equal(api.rows.rowGpcHelperCardKey.style.display, '');
assert.equal(api.rows.rowGpcHelperPhone.style.display, '');
assert.equal(api.rows.rowGpcHelperOtpChannel.style.display, '');
+4 -3
View File
@@ -135,9 +135,10 @@ test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => {
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /<option value="gpc-helper">GPC<\/option>/);
assert.match(html, /id="btn-gpc-card-key-purchase"/);
assert.match(html, />购买卡密</);
assert.doesNotMatch(html, /GPC API/);
assert.doesNotMatch(html, /id="input-gpc-helper-api"/);
assert.match(html, />获取 API Key</);
assert.match(html, /GPC API/);
assert.match(html, /id="input-gpc-helper-api"/);
assert.match(html, /GPC API Key/);
assert.match(html, /id="input-gpc-helper-card-key"/);
assert.match(html, /id="btn-gpc-helper-balance"/);
assert.match(html, /id="input-gpc-helper-phone"/);