Files
FlowPilot/tests/plus-checkout-billing-tab-resolution.test.js
T
2026-05-20 11:34:34 +08:00

1790 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadPlusCheckoutBillingModule() {
const source = fs.readFileSync('background/steps/fill-plus-checkout.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutBilling;`)(globalScope);
}
function createAddressSeed() {
return {
countryCode: 'DE',
query: 'Berlin Mitte',
suggestionIndex: 1,
fallback: {
address1: 'Unter den Linden',
city: 'Berlin',
region: 'Berlin',
postalCode: '10117',
},
};
}
function createAuAddressSeed() {
return {
countryCode: 'AU',
query: 'Sydney NSW',
suggestionIndex: 1,
fallback: {
address1: 'George Street',
city: 'Sydney',
region: 'New South Wales',
postalCode: '2000',
},
};
}
function createIdAddressSeed() {
return {
countryCode: 'ID',
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
};
}
function createKrAddressSeed() {
return {
countryCode: 'KR',
query: 'Seoul Jung-gu',
suggestionIndex: 1,
fallback: {
address1: 'Sejong-daero 110',
city: 'Jung-gu',
region: 'Seoul',
postalCode: '04524',
},
};
}
function createSuccessfulBillingResult() {
return {
countryText: 'Germany',
structuredAddress: {
address1: 'Unter den Linden',
city: 'Berlin',
postalCode: '10117',
},
};
}
function createExecutorHarness({
frames,
stateByFrame,
readyByFrame = {},
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
getState = null,
queryTabsInAutomationWindow = null,
markCurrentRegistrationAccountUsed = async () => {},
onClickSubscribe = null,
probeIpProxyExit = null,
onSetState = null,
sleepWithStop = null,
submitRedirectUrl = 'https://www.paypal.com/checkoutnow',
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
completed: [],
ensuredTabs: [],
injectedAllFrames: false,
logs: [],
messages: [],
sleeps: [],
states: [],
waitedUrls: [],
};
const checkoutTab = {
id: 42,
url: 'https://chatgpt.com/checkout/openai_ie/cs_test',
status: 'complete',
};
const executor = api.createPlusCheckoutBillingExecutor({
addLog: async (message, level = 'info') => events.logs.push({ message, level }),
chrome: {
tabs: {
get: async (tabId) => (tabId === checkoutTab.id ? checkoutTab : null),
query: async (queryInfo) => {
if (queryInfo.active && queryInfo.currentWindow) {
return [checkoutTab];
}
if (queryInfo.url === 'https://chatgpt.com/checkout/*') {
return [checkoutTab];
}
return [];
},
sendMessage: async (tabId, message, options = {}) => {
const frameId = Number.isInteger(options.frameId) ? options.frameId : 0;
events.messages.push({ tabId, message, frameId });
const hasConfiguredState = Object.prototype.hasOwnProperty.call(stateByFrame, frameId);
if (message.type === 'PING') {
if (readyByFrame[frameId] === false) {
throw new Error('No receiving end');
}
return { ok: true, source: 'plus-checkout' };
}
if (readyByFrame[frameId] === false && !hasConfiguredState) {
throw new Error('No receiving end');
}
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
if (typeof onClickSubscribe === 'function') {
const clickResult = await onClickSubscribe({ checkoutTab, events, frameId, message, tabId });
if (clickResult !== undefined) {
return clickResult;
}
} else {
checkoutTab.url = submitRedirectUrl;
}
}
return createSuccessfulBillingResult();
},
},
scripting: {
executeScript: async (details) => {
if (details.target?.allFrames) {
events.injectedAllFrames = true;
}
},
},
webNavigation: {
getAllFrames: async () => frames,
},
},
completeNodeFromBackground: async (step, payload) => events.completed.push({ step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }),
fetch: fetchImpl,
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }),
getAddressSeedForCountry,
getState: typeof getState === 'function' ? getState : async () => ({}),
getTabId: async () => null,
isTabAlive: async () => false,
markCurrentRegistrationAccountUsed,
...(typeof queryTabsInAutomationWindow === 'function' ? { queryTabsInAutomationWindow } : {}),
setState: async (updates) => {
events.states.push(updates);
if (typeof onSetState === 'function') {
await onSetState(updates, events);
}
},
sleepWithStop: sleepWithStop || (async (ms) => events.sleeps.push(ms)),
waitForTabCompleteUntilStopped: async () => checkoutTab,
waitForTabUrlMatchUntilStopped: async (tabId, matcher) => {
events.waitedUrls.push({ tabId });
assert.equal(matcher(submitRedirectUrl), true);
return { id: tabId, url: submitRedirectUrl };
},
...(typeof probeIpProxyExit === 'function' ? { probeIpProxyExit } : {}),
});
return { checkoutTab, events, executor };
}
test('Plus checkout billing stops before PayPal when today due amount is non-zero', async () => {
const markCalls = [];
const { events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
checkoutAmountSummary: {
hasTodayDue: true,
amount: 19.33,
isZero: false,
rawAmount: '€19.33',
},
},
},
markCurrentRegistrationAccountUsed: async (state, options) => {
markCalls.push({ state, options });
return { updated: true };
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({ email: 'paid@example.com' }),
/PLUS_CHECKOUT_NON_FREE_TRIAL::/
);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'), false);
assert.equal(events.completed.length, 0);
assert.equal(markCalls.length, 1);
assert.equal(markCalls[0].state.email, 'paid@example.com');
assert.equal(events.logs.some((entry) => /今日应付金额不是 0/.test(entry.message)), true);
});
test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
},
},
});
await executor.executePlusCheckoutBilling({});
assert.deepEqual(events.ensuredTabs[0], { source: 'plus-checkout', tabId: checkoutTab.id });
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true);
assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true);
});
test('Plus checkout billing waits on processing subscribe text before clicking a ready subscribe button again', async () => {
const originalNow = Date.now;
let now = 0;
let clickCalls = 0;
Date.now = () => now;
try {
const { events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
},
},
onClickSubscribe: async ({ checkoutTab }) => {
clickCalls += 1;
if (clickCalls === 1) {
return {
clicked: false,
subscribeButtonStatus: 'processing',
subscribeButtonText: '订阅正在处理',
};
}
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
return {
clicked: true,
subscribeButtonStatus: 'clicked',
subscribeButtonText: '订阅',
};
},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
now += ms;
},
});
await executor.executePlusCheckoutBilling({});
const subscribeMessages = events.messages.filter((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(subscribeMessages.length, 2);
assert.equal(subscribeMessages.some((entry) => entry.message.payload.allowBusySubscribeButton !== undefined), false);
assert.equal(events.sleeps.filter((ms) => ms === 500).length >= 20, true);
assert.equal(events.logs.some((entry) => /本轮未点击/.test(entry.message)), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
} finally {
Date.now = originalNow;
}
});
test('Plus checkout billing searches checkout tabs inside the locked automation window', async () => {
const queries = [];
const { checkoutTab, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
queryTabsInAutomationWindow: async (queryInfo) => {
queries.push(queryInfo);
if (queryInfo?.active) {
return [];
}
if (queryInfo?.url === 'https://chatgpt.com/checkout/*') {
return [checkoutTab];
}
return [];
},
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
},
},
});
await executor.executePlusCheckoutBilling({});
assert.deepEqual(queries, [
{ active: true, currentWindow: true },
{ url: 'https://chatgpt.com/checkout/*' },
]);
});
test('Plus checkout billing sends the billing command to the iframe that contains PayPal', async () => {
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
},
});
await executor.executePlusCheckoutBilling({});
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(selectMessage.frameId, 7);
assert.equal(fillMessage.frameId, 8);
assert.equal(subscribeMessage.frameId, 0);
assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses proxy exit country for GoPay address when available', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'JP' ? {
countryCode: 'JP',
query: 'Tokyo Marunouchi',
suggestionIndex: 1,
fallback: {
address1: 'Marunouchi 1-1',
city: 'Chiyoda-ku',
region: 'Tokyo',
postalCode: '100-0005',
},
} : createIdAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'トウキョウト, チヨダク, マルノウチ, 1-1',
Trans_Address: 'Marunouchi 1-1, Chiyoda-ku, Tokyo',
City: 'Tokyo',
State: 'Tokyo',
Zip_Code: '100-0005',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'JP',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'JP');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Chiyoda-ku',
path: '/jp-address',
method: 'refresh',
});
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true);
});
test('Plus checkout billing refreshes stale GoPay proxy country before filling address', async () => {
const requestedCountries = [];
const probeCalls = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'JP' ? {
countryCode: 'JP',
query: 'Tokyo Chiyoda-ku',
suggestionIndex: 1,
fallback: {
address1: 'Marunouchi 1-1',
city: 'Chiyoda-ku',
region: 'Tokyo',
postalCode: '100-0005',
},
} : createKrAddressSeed();
},
fetchImpl: async () => ({
ok: false,
status: 503,
json: async () => ({ status: 'error' }),
}),
probeIpProxyExit: async (options) => {
probeCalls.push(options);
return {
proxyRouting: {
exitRegion: 'JP',
exitIp: '203.0.113.8',
exitSource: 'page_context',
exitEndpoint: 'https://ipinfo.io/json',
},
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].detectWhenDisabled, true);
assert.equal(requestedCountries[0], 'JP');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP');
assert.equal(events.logs.some((entry) => entry.message.includes('当前代理出口复测结果:JP / 203.0.113.8')), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false);
});
test('Plus checkout billing refuses to reuse stale GoPay proxy country when refresh has no region', async () => {
const requestedCountries = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return createKrAddressSeed();
},
probeIpProxyExit: async () => ({
proxyRouting: {
reason: 'disabled_probe_only',
exitIp: '203.0.113.9',
exitRegion: '',
exitError: 'missing_region',
},
}),
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
}),
/本次复测没有拿到国家码/
);
assert.equal(requestedCountries.length, 0);
assert.equal(events.logs.some((entry) => /已清空旧出口地区 KR/.test(entry.message)), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false);
});
test('Plus checkout billing normalizes legacy Korean postal code for GoPay address', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'KR' ? createKrAddressSeed() : createIdAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: '서울특별시 중구 세종대로 110',
Trans_Address: 'Sejong-daero 110, Jung-gu, Seoul',
City: 'Jung-gu',
State: 'Seoul',
Zip_Code: '150-300',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'KR');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'KR');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.equal(fillMessage.message.payload.addressSeed.fallback.address1, 'Sejong-daero 110, Jung-gu, Seoul');
assert.equal(fillMessage.message.payload.addressSeed.fallback.postalCode, '04524');
assert.match(fillMessage.message.payload.addressSeed.fallback.postalCode, /^\d{5}$/);
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Jung-gu',
path: '/kr-address',
method: 'refresh',
});
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), true);
});
test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: () => createIdAddressSeed(),
fetchImpl: async () => ({
ok: false,
status: 404,
json: async () => ({ status: 'error' }),
}),
submitRedirectUrl: 'https://gopay.co.id/payment/session',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay' });
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_GOPAY');
const paypalSelectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(selectMessage.frameId, 7);
assert.equal(selectMessage.message.payload.paymentMethod, 'gopay');
assert.equal(paypalSelectMessage, undefined);
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session');
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => {
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
hasSubscribeButton: true,
},
7: { hasPayPal: false, paypalCandidates: [] },
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
},
readyByFrame: {
0: false,
},
});
await executor.executePlusCheckoutBilling({});
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
assert.equal(selectMessage.frameId, 0);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => {
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
{ frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
9: { hasPayPal: false, paypalCandidates: [] },
},
});
await executor.executePlusCheckoutBilling({});
const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY');
const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION');
const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS');
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(fillQueryMessage.frameId, 8);
assert.equal(suggestionMessage.frameId, 9);
assert.equal(ensureAddressMessage.frameId, 8);
assert.equal(combinedFillMessage, undefined);
assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => {
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
{ frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
9: { hasPayPal: false, paypalCandidates: [] },
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'Rosa-Luxemburg-Strasse 40',
City: 'Berlin',
State: 'Berlin',
Zip_Code: '69081',
},
}),
};
},
});
await executor.executePlusCheckoutBilling({});
const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY');
const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION');
const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS');
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(fillQueryMessage, undefined);
assert.equal(suggestionMessage, undefined);
assert.equal(ensureAddressMessage, undefined);
assert.equal(combinedFillMessage.frameId, 8);
assert.equal(combinedFillMessage.message.payload.addressSeed.skipAutocomplete, true);
assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, 'Rosa-Luxemburg-Strasse 40');
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.city, 'Berlin');
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.postalCode, '69081');
assert.equal(fetchRequests.length, 1);
assert.equal(fetchRequests[0].url, 'https://www.meiguodizhi.com/api/v1/dz');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Berlin',
path: '/de-address',
method: 'refresh',
});
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
8: {
hasPayPal: false,
paypalCandidates: [],
billingFieldsVisible: true,
countryText: 'Australia',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return /australia|au/i.test(String(countryValue || '')) ? createAuAddressSeed() : createAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: '98 Ocean Street',
City: 'Sydney South',
State: 'New South Wales',
Zip_Code: '2000',
},
}),
};
},
});
await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' });
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'AU');
assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU');
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Sydney',
path: '/au-address',
method: 'refresh',
});
});
test('Plus checkout billing uses meiguodizhi country paths for localized countries without local seeds', async () => {
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
8: {
hasPayPal: false,
paypalCandidates: [],
billingFieldsVisible: true,
countryText: '日本',
},
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'トウキョウト, ミナトク, シバダイモン, 10-4',
Trans_Address: '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo',
City: 'Tokyo',
State: 'Tokyo',
Zip_Code: '105-0012',
},
}),
};
},
});
await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' });
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'JP');
assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Tokyo',
path: '/jp-address',
method: 'refresh',
});
});
test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => {
const { executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
],
stateByFrame: {
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
},
readyByFrame: {
7: false,
},
});
await assert.rejects(
executor.executePlusCheckoutBilling({}),
/已定位到 PayPal 所在 iframeframeId=7),但账单脚本无法注入该 iframe/
);
});
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 === 'https://gpc.qlhazycoder.top/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 () => createGpcTaskResponse({ status: 'completed', status_text: '充值完成', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gp/tasks/task_123/otp')) {
return {
ok: true,
status: 200,
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}`);
},
});
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_123',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/api/gp/tasks/task_old/otp',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_billing_123',
});
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',
};
await run;
assert.equal(fetchCalls[0].url, 'https://gpc.qlhazycoder.top/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.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:等待 WhatsApp OTP'), true);
assert.equal(events.logs.some((entry) => /whatsapp_otp_wait/.test(entry.message)), false);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
assert.ok(events.sleeps.includes(3000));
});
test('GPC billing auto mode only polls until completed without OTP or PIN submission', async () => {
const fetchCalls = [];
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_auto') {
pollCount += 1;
if (pollCount === 1) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_auto', phone_mode: 'auto', status: 'queued', status_text: '排队中', api_waiting_for: '' }),
};
}
if (pollCount === 2) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_auto', phone_mode: 'auto', status: 'active', status_text: '处理中', remote_stage: 'auto_otp_wait', api_waiting_for: 'auto_otp' }),
};
}
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({ task_id: 'task_auto', phone_mode: 'auto', status: 'completed', status_text: '充值完成', remote_stage: 'completed', api_waiting_for: '' }),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_auto',
gopayHelperPhoneMode: 'auto',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperApiKey: 'gpc_auto',
});
assert.equal(fetchCalls.length, 3);
assert.equal(fetchCalls.some((call) => /\/api\/gp\/tasks\/task_auto\/(otp|pin)$/.test(call.url)), false);
assert.equal(events.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:等待自动 OTP'), true);
assert.equal(events.logs.some((entry) => /auto_otp_wait/.test(entry.message)), false);
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_auto' && state.gopayHelperTaskStatus === 'completed'), true);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
});
test('GPC billing logs checkout order stage in Chinese', async () => {
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url) => {
if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_stage') {
pollCount += 1;
if (pollCount === 1) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_stage',
phone_mode: 'auto',
status: 'active',
status_text: '处理中',
remote_stage: 'checkout_order_start',
api_waiting_for: '',
}),
};
}
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_stage',
phone_mode: 'auto',
status: 'completed',
status_text: '充值完成',
remote_stage: 'completed',
api_waiting_for: '',
}),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_stage',
gopayHelperPhoneMode: 'auto',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperApiKey: 'gpc_auto',
});
assert.equal(events.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:创建订单'), true);
assert.equal(events.logs.some((entry) => /checkout_order_start/.test(entry.message)), false);
});
test('GPC billing fails repeated checkout stage as stale so auto-run can recreate task', async () => {
const originalNow = Date.now;
let now = 1710000000000;
const fetchCalls = [];
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
now += ms;
},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_stale') {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_stale',
phone_mode: 'auto',
status: 'active',
status_text: '处理中',
remote_stage: 'checkout_order_start',
api_waiting_for: '',
}),
};
}
if (url.endsWith('/api/gp/tasks/task_stale/stop')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_stale',
status: 'discarded',
status_text: '已停止',
}),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
Date.now = () => now;
try {
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_stale',
gopayHelperPhoneMode: 'auto',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperApiKey: 'gpc_auto',
gopayHelperTaskStaleSeconds: 15,
}),
/GPC_TASK_ENDED::GPC 任务状态超过 15 秒无进展(创建订单),请重新创建任务。/
);
} finally {
Date.now = originalNow;
}
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_stale/stop')), true);
assert.equal(events.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:创建订单'), true);
});
test('GPC billing fails unchanged visible created status even when hidden ids change', async () => {
const originalNow = Date.now;
let now = 1710000000000;
let queryCount = 0;
const fetchCalls = [];
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
now += ms;
},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_created') {
queryCount += 1;
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_created',
phone_mode: 'auto',
status: 'created',
status_text: '已创建',
remote_stage: '',
api_waiting_for: '',
reference_id: `ref_${queryCount}`,
flow_id: `flow_${queryCount}`,
}),
};
}
if (url.endsWith('/api/gp/tasks/task_created/stop')) {
return {
ok: true,
status: 200,
json: async () => createGpcTaskResponse({
task_id: 'task_created',
status: 'discarded',
status_text: '已停止',
}),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
Date.now = () => now;
try {
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_created',
gopayHelperPhoneMode: 'auto',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperApiKey: 'gpc_auto',
gopayHelperTaskStaleSeconds: 15,
}),
/GPC_TASK_ENDED::GPC 任务状态超过 15 秒无进展(已创建),请重新创建任务。/
);
} finally {
Date.now = originalNow;
}
assert.equal(queryCount > 1, true);
assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_created/stop')), true);
assert.equal(events.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:已创建'), true);
});
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: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
return {
ok: true,
status: 200,
json: async () => ({ ok: true, otp: '654321', message_id: 'sms-1' }),
};
}
if (url === 'https://gpc.qlhazycoder.top/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 () => createGpcTaskResponse({ task_id: 'task_sms', status: 'completed', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gp/tasks/task_sms/otp')) {
return {
ok: true,
status: 200,
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}`);
},
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_sms',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_sms',
gopayHelperOtpChannel: 'sms',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperCountryCode: '+86',
gopayHelperPhoneNumber: '13800138000',
gopayHelperOrderCreatedAt: 1710000000000,
});
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[1].url);
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/latest-otp');
assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrl.searchParams.get('consume'), '1');
assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000');
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321',
});
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => {
const fetchCalls = [];
let pollCount = 0;
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
return {
ok: true,
status: 200,
json: async () => ({ ok: true, otp: '765432', message_id: 'wa-1' }),
};
}
if (url === 'https://gpc.qlhazycoder.top/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 () => createGpcTaskResponse({ task_id: 'task_wa', status: 'completed', remote_stage: 'completed' }),
};
}
if (url.endsWith('/api/gp/tasks/task_wa/otp')) {
return {
ok: true,
status: 200,
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}`);
},
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_wa',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperPin: '654321',
gopayHelperApiKey: 'gpc_wa',
gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperCountryCode: '+86',
gopayHelperPhoneNumber: '18984829950',
});
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.find((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).url);
assert.equal(helperUrl.searchParams.get('phone'), '+8618984829950');
assert.equal(helperUrl.searchParams.get('consume'), '1');
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
otp: '765432',
});
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
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/latest-otp')) {
return {
ok: true,
status: 200,
json: async () => ({ ok: true, status: 'waiting', otp: '', message: '未查询到验证码' }),
};
}
if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_timeout') {
const queryCount = fetchCalls.filter((call) => call.url === 'https://gpc.qlhazycoder.top/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.qlhazycoder.top/',
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/latest-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.qlhazycoder.top/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.qlhazycoder.top/',
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/latest-otp')).map((call) => new URL(call.url));
assert.equal(helperUrls.length, 2);
assert.equal(helperUrls[0].searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrls[0].searchParams.get('consume'), '1');
assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000');
assert.equal(helperUrls[1].searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrls[1].searchParams.get('consume'), '1');
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, 'plus-checkout-billing');
});
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.qlhazycoder.top/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.qlhazycoder.top/',
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, 'plus-checkout-billing');
});
test('GPC billing manual OTP cancel stops task and ends current round', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: '',
};
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === 'https://gpc.qlhazycoder.top/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/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}`);
},
});
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperTaskId: 'task_cancel',
gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/',
gopayHelperPin: '654321',
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: '',
};
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.qlhazycoder.top/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.qlhazycoder.top/',
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.qlhazycoder.top/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.qlhazycoder.top/',
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.qlhazycoder.top/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.qlhazycoder.top/',
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');
});