手机号注册模式
This commit is contained in:
@@ -105,6 +105,15 @@ let currentState = {
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
signupMethod: 'phone',
|
||||
resolvedSignupMethod: 'phone',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '+6612345',
|
||||
signupPhoneNumber: '+6612345',
|
||||
signupPhoneActivation: { activationId: 'signup-activation', phoneNumber: '+6612345' },
|
||||
signupPhoneCompletedActivation: { activationId: 'signup-completed', phoneNumber: '+6612345' },
|
||||
signupPhoneVerificationRequestedAt: 123456,
|
||||
signupPhoneVerificationPurpose: 'signup',
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: 'demo@gmail.com',
|
||||
@@ -162,6 +171,15 @@ async function resetState() {
|
||||
autoRunDelayEnabled: prev.autoRunDelayEnabled,
|
||||
autoRunDelayMinutes: prev.autoRunDelayMinutes,
|
||||
autoStepDelaySeconds: prev.autoStepDelaySeconds,
|
||||
signupMethod: prev.signupMethod,
|
||||
resolvedSignupMethod: null,
|
||||
accountIdentifierType: null,
|
||||
accountIdentifier: '',
|
||||
signupPhoneNumber: '',
|
||||
signupPhoneActivation: null,
|
||||
signupPhoneCompletedActivation: null,
|
||||
signupPhoneVerificationRequestedAt: null,
|
||||
signupPhoneVerificationPurpose: '',
|
||||
mailProvider: prev.mailProvider,
|
||||
emailGenerator: prev.emailGenerator,
|
||||
gmailBaseEmail: prev.gmailBaseEmail,
|
||||
@@ -335,6 +353,15 @@ return {
|
||||
assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
|
||||
assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
|
||||
assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
|
||||
assert.strictEqual(snapshot.currentState.signupMethod, 'phone', 'signup method setting should survive fresh-attempt reset');
|
||||
assert.strictEqual(snapshot.currentState.resolvedSignupMethod, null, 'resolved signup method should be cleared before the next run freezes it again');
|
||||
assert.strictEqual(snapshot.currentState.accountIdentifierType, null, 'account identifier type should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.accountIdentifier, '', 'account identifier should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.signupPhoneNumber, '', 'signup phone number should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.signupPhoneActivation, null, 'signup phone activation should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.signupPhoneCompletedActivation, null, 'completed signup phone activation should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.signupPhoneVerificationRequestedAt, null, 'signup phone request time should be runtime-only');
|
||||
assert.strictEqual(snapshot.currentState.signupPhoneVerificationPurpose, '', 'signup phone purpose should be runtime-only');
|
||||
assert.deepStrictEqual(
|
||||
snapshot.currentState.reusablePhoneActivation,
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ test('auto-run stops step4 restart path when mail2925 ends the current thread',
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
@@ -107,6 +108,7 @@ async function ensureAutoEmailReady() {
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function ensureResolvedSignupMethodForRun() { return 'email'; }
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
|
||||
@@ -66,6 +66,7 @@ test('auto-run restarts from step 1 with the same email after step 4 failure', a
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
@@ -111,6 +112,7 @@ async function ensureAutoEmailReady() {
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function ensureResolvedSignupMethodForRun() { return 'email'; }
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
@@ -215,6 +217,7 @@ test('auto-run does not restart step 4 current attempt when user_already_exists
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
@@ -256,6 +259,7 @@ async function ensureAutoEmailReady() {
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function ensureResolvedSignupMethodForRun() { return 'email'; }
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
@@ -336,6 +340,7 @@ test('auto-run skips steps 4/5 when step 2 has already marked registration chain
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
@@ -376,6 +381,7 @@ async function ensureAutoEmailReady() {
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function ensureResolvedSignupMethodForRun() { return 'email'; }
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
|
||||
@@ -74,6 +74,7 @@ function createHarness(options = {}) {
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const LOG_PREFIX = '[test]';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
@@ -93,6 +94,7 @@ async function addLog(message, level = 'info') {
|
||||
}
|
||||
|
||||
async function ensureAutoEmailReady() {}
|
||||
async function ensureResolvedSignupMethodForRun() { return 'email'; }
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function getState() {
|
||||
return {
|
||||
|
||||
@@ -54,12 +54,16 @@ test('background account history settings are normalized independently from hotm
|
||||
extractFunction('normalizeHotmailLocalBaseUrl'),
|
||||
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
|
||||
extractFunction('normalizeVerificationResendCount'),
|
||||
extractFunction('normalizePlusPaymentMethod'),
|
||||
extractFunction('normalizePhoneSmsProvider'),
|
||||
extractFunction('normalizePhoneSmsProviderOrder'),
|
||||
extractFunction('normalizeSignupMethod'),
|
||||
extractFunction('normalizeFiveSimCountryCode'),
|
||||
extractFunction('normalizeFiveSimCountryOrder'),
|
||||
extractFunction('normalizeNexSmsCountryId'),
|
||||
extractFunction('normalizeNexSmsCountryOrder'),
|
||||
extractFunction('normalizeNexSmsServiceCode'),
|
||||
extractFunction('normalizePhonePreferredActivation'),
|
||||
extractFunction('normalizePhoneVerificationReplacementLimit'),
|
||||
extractFunction('normalizePhoneCodeWaitSeconds'),
|
||||
extractFunction('normalizePhoneCodeTimeoutWindows'),
|
||||
@@ -106,6 +110,14 @@ const HERO_SMS_COUNTRY_ID = 52;
|
||||
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
|
||||
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
|
||||
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
|
||||
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
|
||||
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms'];
|
||||
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
|
||||
const SIGNUP_METHOD_EMAIL = 'email';
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
|
||||
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
|
||||
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
|
||||
const FIVE_SIM_COUNTRY_ID = 'vietnam';
|
||||
const FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
|
||||
const FIVE_SIM_OPERATOR = 'any';
|
||||
@@ -155,9 +167,15 @@ return {
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
|
||||
assert.equal(api.normalizePersistentSettingValue('heroSmsPreferredPrice', '0.051234'), '0.0512');
|
||||
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'phone'), 'phone');
|
||||
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'unknown'), 'email');
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', '5SIM'), '5sim');
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'NEXSMS'), 'nexsms');
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'unknown'), 'hero-sms');
|
||||
assert.deepStrictEqual(api.normalizePersistentSettingValue('phoneSmsProviderOrder', ['nexsms', '5sim', 'nexsms']), ['nexsms', '5sim']);
|
||||
assert.equal(api.normalizePersistentSettingValue('fiveSimApiKey', ' demo-five '), ' demo-five ');
|
||||
assert.equal(api.normalizePersistentSettingValue('fiveSimProduct', ' OpenAI! '), 'openai');
|
||||
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ' England! '), 'vietnam');
|
||||
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ''), 'vietnam');
|
||||
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryLabel', ''), '越南 (Vietnam)');
|
||||
@@ -212,8 +230,36 @@ return {
|
||||
api.normalizePersistentSettingValue('fiveSimCountryOrder', []),
|
||||
[]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('fiveSimCountryOrder', [' Thailand ', 'vietnam', 'thailand']),
|
||||
['thailand', 'vietnam']
|
||||
);
|
||||
assert.equal(api.normalizePersistentSettingValue('nexSmsApiKey', ' demo-nex '), ' demo-nex ');
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('nexSmsCountryOrder', []),
|
||||
[]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('nexSmsCountryOrder', [1, '6', 1]),
|
||||
[1, 6]
|
||||
);
|
||||
assert.equal(api.normalizePersistentSettingValue('nexSmsServiceCode', ' OT! '), 'ot');
|
||||
assert.deepStrictEqual(
|
||||
api.normalizePersistentSettingValue('phonePreferredActivation', {
|
||||
provider: 'nexsms',
|
||||
activationId: 'abc',
|
||||
phoneNumber: '+6612345',
|
||||
successfulUses: 2,
|
||||
maxUses: 3,
|
||||
}),
|
||||
{
|
||||
provider: 'nexsms',
|
||||
activationId: 'abc',
|
||||
phoneNumber: '+6612345',
|
||||
successfulUses: 2,
|
||||
maxUses: 3,
|
||||
countryId: null,
|
||||
countryLabel: '',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -73,7 +73,10 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
);
|
||||
assert.deepStrictEqual(record, {
|
||||
recordId: 'latest@example.com',
|
||||
accountIdentifierType: 'email',
|
||||
accountIdentifier: 'latest@example.com',
|
||||
email: 'latest@example.com',
|
||||
phoneNumber: '',
|
||||
password: 'secret',
|
||||
finalStatus: 'failed',
|
||||
finishedAt: record.finishedAt,
|
||||
@@ -140,6 +143,61 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
assert.equal(normalizedStoppedRecord.failedStep, 7);
|
||||
});
|
||||
|
||||
test('account run history helper accepts phone-only records without forcing email or password', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
chrome: { storage: { local: { get: async () => ({}), set: async () => {} } } },
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
const record = helpers.buildAccountRunHistoryRecord({
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '+6612345',
|
||||
signupPhoneNumber: '+6612345',
|
||||
password: '',
|
||||
}, 'success');
|
||||
|
||||
assert.deepStrictEqual(record, {
|
||||
recordId: 'phone:+6612345',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '+6612345',
|
||||
email: '',
|
||||
phoneNumber: '+6612345',
|
||||
password: '',
|
||||
finalStatus: 'success',
|
||||
finishedAt: record.finishedAt,
|
||||
retryCount: 0,
|
||||
failureLabel: '流程完成',
|
||||
failureDetail: '',
|
||||
failedStep: null,
|
||||
source: 'manual',
|
||||
autoRunContext: null,
|
||||
plusModeEnabled: false,
|
||||
contributionMode: false,
|
||||
});
|
||||
|
||||
const normalized = helpers.normalizeAccountRunHistoryRecord({
|
||||
recordId: 'phone:+6612345',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '+6612345',
|
||||
phoneNumber: '+6612345',
|
||||
finalStatus: 'failed',
|
||||
failureDetail: '步骤 8:手机号验证码超时。',
|
||||
});
|
||||
|
||||
assert.equal(normalized.recordId, 'phone:+6612345');
|
||||
assert.equal(normalized.accountIdentifierType, 'phone');
|
||||
assert.equal(normalized.accountIdentifier, '+6612345');
|
||||
assert.equal(normalized.email, '');
|
||||
assert.equal(normalized.phoneNumber, '+6612345');
|
||||
assert.equal(normalized.password, '');
|
||||
assert.equal(normalized.finalStatus, 'failed');
|
||||
});
|
||||
|
||||
test('account run history records preserve Plus and contribution mode flags', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -143,7 +143,7 @@ test('message router skips step 3 when step 2 lands on verification page', async
|
||||
|
||||
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
|
||||
assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'skipped' }]);
|
||||
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。');
|
||||
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入验证码页,已自动跳过步骤 3。');
|
||||
});
|
||||
|
||||
test('message router does not overwrite a completed step 3 when step 2 is replayed', async () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => source.indexOf(marker))
|
||||
.find((index) => index >= 0);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') parenDepth += 1;
|
||||
if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) signatureEnded = true;
|
||||
}
|
||||
if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('signup method resolution freezes per run and falls back when phone signup is unavailable', async () => {
|
||||
const api = new Function(`
|
||||
const SIGNUP_METHOD_EMAIL = 'email';
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
|
||||
const logs = [];
|
||||
let state = {
|
||||
signupMethod: 'phone',
|
||||
phoneVerificationEnabled: true,
|
||||
plusModeEnabled: false,
|
||||
contributionMode: false,
|
||||
resolvedSignupMethod: null,
|
||||
};
|
||||
async function getState() { return { ...state }; }
|
||||
async function setState(updates) { state = { ...state, ...updates }; }
|
||||
async function addLog(message, level = 'info') { logs.push({ message, level }); }
|
||||
${extractFunction('normalizeSignupMethod')}
|
||||
${extractFunction('canUsePhoneSignup')}
|
||||
${extractFunction('resolveSignupMethod')}
|
||||
${extractFunction('ensureResolvedSignupMethodForRun')}
|
||||
return {
|
||||
logs,
|
||||
get state() { return state; },
|
||||
setState,
|
||||
resolveSignupMethod,
|
||||
ensureResolvedSignupMethodForRun,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true }), 'phone');
|
||||
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: false }), 'email');
|
||||
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true, plusModeEnabled: true }), 'email');
|
||||
assert.equal(api.resolveSignupMethod({ signupMethod: 'email', resolvedSignupMethod: 'phone', phoneVerificationEnabled: false }), 'phone');
|
||||
|
||||
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
|
||||
assert.equal(api.state.resolvedSignupMethod, 'phone');
|
||||
|
||||
await api.setState({ signupMethod: 'email', phoneVerificationEnabled: false });
|
||||
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
|
||||
assert.equal(api.state.resolvedSignupMethod, 'phone');
|
||||
|
||||
await api.setState({ resolvedSignupMethod: null, signupMethod: 'phone', phoneVerificationEnabled: false });
|
||||
assert.equal(await api.ensureResolvedSignupMethodForRun({ force: true }), 'email');
|
||||
assert.equal(api.state.resolvedSignupMethod, 'email');
|
||||
assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ test('step 2 completes with password step skipped when landing on email verifica
|
||||
step: 2,
|
||||
payload: {
|
||||
email: 'user@example.com',
|
||||
accountIdentifierType: 'email',
|
||||
accountIdentifier: 'user@example.com',
|
||||
nextSignupState: 'verification_page',
|
||||
nextSignupUrl: 'https://auth.openai.com/email-verification',
|
||||
skippedPasswordStep: true,
|
||||
@@ -76,6 +78,8 @@ test('step 2 keeps password flow when landing on password page', async () => {
|
||||
step: 2,
|
||||
payload: {
|
||||
email: 'user@example.com',
|
||||
accountIdentifierType: 'email',
|
||||
accountIdentifier: 'user@example.com',
|
||||
nextSignupState: 'password_page',
|
||||
nextSignupUrl: 'https://auth.openai.com/create-account/password',
|
||||
skippedPasswordStep: false,
|
||||
@@ -84,6 +88,80 @@ test('step 2 keeps password flow when landing on password page', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 2 uses phone activation when resolved signup method is phone', async () => {
|
||||
const completedPayloads = [];
|
||||
const sentPayloads = [];
|
||||
const activation = {
|
||||
activationId: 'signup-activation',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
};
|
||||
|
||||
const executor = step2Api.createStep2Executor({
|
||||
addLog: async () => {},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completedPayloads.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
ensureSignupEntryPageReady: async () => ({ tabId: 14 }),
|
||||
ensureSignupPostEmailPageReadyInTab: async () => {
|
||||
throw new Error('email landing helper should not be used for phone signup');
|
||||
},
|
||||
ensureSignupPostIdentityPageReadyInTab: async () => ({
|
||||
state: 'phone_verification_page',
|
||||
url: 'https://auth.openai.com/phone-verification',
|
||||
}),
|
||||
getTabId: async () => 14,
|
||||
isTabAlive: async () => true,
|
||||
phoneVerificationHelpers: {
|
||||
prepareSignupPhoneActivation: async () => activation,
|
||||
cancelSignupPhoneActivation: async () => {
|
||||
throw new Error('activation should not be cancelled on success');
|
||||
},
|
||||
},
|
||||
resolveSignupMethod: () => 'phone',
|
||||
resolveSignupEmailForFlow: async () => {
|
||||
throw new Error('email resolver should not run for phone signup');
|
||||
},
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
sentPayloads.push(message.payload);
|
||||
return { submitted: true };
|
||||
},
|
||||
SIGNUP_PAGE_INJECT_FILES: [],
|
||||
});
|
||||
|
||||
await executor.executeStep2({ signupMethod: 'phone' });
|
||||
|
||||
assert.deepStrictEqual(sentPayloads, [
|
||||
{
|
||||
signupMethod: 'phone',
|
||||
phoneNumber: '66959916439',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(completedPayloads, [
|
||||
{
|
||||
step: 2,
|
||||
payload: {
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
signupPhoneNumber: '66959916439',
|
||||
signupPhoneActivation: activation,
|
||||
nextSignupState: 'phone_verification_page',
|
||||
nextSignupUrl: 'https://auth.openai.com/phone-verification',
|
||||
skippedPasswordStep: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on chatgpt home', async () => {
|
||||
const completedPayloads = [];
|
||||
const logs = [];
|
||||
|
||||
@@ -44,8 +44,81 @@ test('step 3 reuses existing generated password when rerunning the same email fl
|
||||
source: 'background',
|
||||
payload: {
|
||||
email: 'keep@example.com',
|
||||
phoneNumber: '',
|
||||
accountIdentifierType: 'email',
|
||||
accountIdentifier: 'keep@example.com',
|
||||
password: 'Secret123!',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 3 supports phone-only signup identity when password page is present', async () => {
|
||||
const events = {
|
||||
passwordStates: [],
|
||||
messages: [],
|
||||
stateUpdates: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep3Executor({
|
||||
addLog: async (message) => {
|
||||
events.logs.push(message);
|
||||
},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
generatePassword: () => 'Generated123!',
|
||||
getTabId: async () => 88,
|
||||
isTabAlive: async () => true,
|
||||
sendToContentScript: async (_source, message) => {
|
||||
events.messages.push(message);
|
||||
},
|
||||
setPasswordState: async (password) => {
|
||||
events.passwordStates.push(password);
|
||||
},
|
||||
setState: async (updates) => {
|
||||
events.stateUpdates.push(updates);
|
||||
},
|
||||
SIGNUP_PAGE_INJECT_FILES: [],
|
||||
});
|
||||
|
||||
await executor.executeStep3({
|
||||
email: '',
|
||||
signupPhoneNumber: '66959916439',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
customPassword: 'PhoneSecret123!',
|
||||
accounts: [],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.passwordStates, ['PhoneSecret123!']);
|
||||
assert.equal(events.logs.some((message) => /注册手机号为 66959916439/.test(message)), true);
|
||||
assert.equal(events.stateUpdates.length, 1);
|
||||
assert.deepStrictEqual(events.stateUpdates[0].accounts.map((account) => ({
|
||||
email: account.email,
|
||||
phoneNumber: account.phoneNumber,
|
||||
accountIdentifierType: account.accountIdentifierType,
|
||||
accountIdentifier: account.accountIdentifier,
|
||||
})), [
|
||||
{
|
||||
email: '',
|
||||
phoneNumber: '66959916439',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(events.messages, [
|
||||
{
|
||||
type: 'EXECUTE_STEP',
|
||||
step: 3,
|
||||
source: 'background',
|
||||
payload: {
|
||||
email: '',
|
||||
phoneNumber: '66959916439',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
password: 'PhoneSecret123!',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -231,6 +231,82 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
|
||||
assert.equal(resolveCalls, 0);
|
||||
});
|
||||
|
||||
test('step 4 phone signup branch uses SMS helper and does not poll mailbox', async () => {
|
||||
const completions = [];
|
||||
const phoneCalls = [];
|
||||
let getMailConfigCalls = 0;
|
||||
let resolveCalls = 0;
|
||||
|
||||
const executor = api.createStep4Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completions.push({ step, payload });
|
||||
},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async () => {},
|
||||
getMailConfig: () => {
|
||||
getMailConfigCalls += 1;
|
||||
throw new Error('mail config should not be required for phone signup');
|
||||
},
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
phoneVerificationHelpers: {
|
||||
completeSignupPhoneVerificationFlow: async (tabId, options) => {
|
||||
phoneCalls.push({ tabId, options });
|
||||
return {
|
||||
success: true,
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveSignupMethod: () => 'phone',
|
||||
resolveVerificationStep: async () => {
|
||||
resolveCalls += 1;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => ({ ready: true }),
|
||||
sendToContentScriptResilient: async () => ({ ready: true }),
|
||||
isRetryableContentScriptTransportError: () => false,
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep4({
|
||||
resolvedSignupMethod: 'phone',
|
||||
accountIdentifierType: 'phone',
|
||||
signupPhoneNumber: '66959916439',
|
||||
signupPhoneActivation: { activationId: 'signup-123', phoneNumber: '66959916439' },
|
||||
});
|
||||
|
||||
assert.equal(getMailConfigCalls, 0);
|
||||
assert.equal(resolveCalls, 0);
|
||||
assert.equal(phoneCalls.length, 1);
|
||||
assert.equal(phoneCalls[0].tabId, 1);
|
||||
assert.equal(phoneCalls[0].options.state.accountIdentifierType, 'phone');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true);
|
||||
assert.deepStrictEqual(completions, [
|
||||
{
|
||||
step: 4,
|
||||
payload: {
|
||||
phoneVerification: true,
|
||||
code: '',
|
||||
skipProfileStep: true,
|
||||
skipProfileStepReason: 'combined_verification_profile',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 4 prepare retries transport by recovering retry page without replaying full prepare loop', async () => {
|
||||
let sendToContentScriptCalls = 0;
|
||||
let recoverCalls = 0;
|
||||
|
||||
@@ -227,6 +227,75 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 forwards phone login identity payload when account identifier is phone', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
|
||||
|
||||
const events = {
|
||||
payloads: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async () => {},
|
||||
completeStepFromBackground: async () => {},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
signupPhoneNumber: '66959916439',
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
},
|
||||
password: 'secret',
|
||||
}),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||||
events.payloads.push(message.payload);
|
||||
return {
|
||||
step6Outcome: 'success',
|
||||
state: 'phone_verification_page',
|
||||
loginVerificationRequestedAt: 123456,
|
||||
};
|
||||
},
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep7({
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '66959916439',
|
||||
signupPhoneNumber: '66959916439',
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
},
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.payloads, [
|
||||
{
|
||||
email: '',
|
||||
phoneNumber: '66959916439',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
accountIdentifier: '66959916439',
|
||||
loginIdentifierType: 'phone',
|
||||
password: 'secret',
|
||||
visibleStep: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is missing', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -90,6 +90,108 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
assert.equal(calls.resolveOptions.completionStep, 8);
|
||||
});
|
||||
|
||||
test('step 8 routes phone login verification through sms helper and skips mail provider setup', async () => {
|
||||
const calls = {
|
||||
getMailConfigCalls: 0,
|
||||
helperCalls: [],
|
||||
completions: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep8Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
calls.completions.push({ step, payload });
|
||||
},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => {
|
||||
throw new Error('phone login branch should not probe email verification page readiness');
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 5000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
|
||||
getMailConfig: () => {
|
||||
calls.getMailConfigCalls += 1;
|
||||
return {
|
||||
provider: 'qq',
|
||||
label: 'QQ 邮箱',
|
||||
};
|
||||
},
|
||||
getState: async () => ({
|
||||
accountIdentifierType: 'phone',
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
},
|
||||
}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
isVerificationMailPollingError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
phoneVerificationHelpers: {
|
||||
completeLoginPhoneVerificationFlow: async (tabId, options) => {
|
||||
calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state });
|
||||
return { code: '654321' };
|
||||
},
|
||||
},
|
||||
resolveSignupMethod: () => 'phone',
|
||||
resolveVerificationStep: async () => {
|
||||
throw new Error('phone login branch should not call email verification flow');
|
||||
},
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
throw new Error('phone login branch should not rerun step 7 in this test');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
setState: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep8({
|
||||
visibleStep: 8,
|
||||
accountIdentifierType: 'phone',
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
},
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
});
|
||||
|
||||
assert.equal(calls.getMailConfigCalls, 0);
|
||||
assert.deepStrictEqual(calls.helperCalls, [
|
||||
{
|
||||
tabId: 1,
|
||||
visibleStep: 8,
|
||||
state: {
|
||||
visibleStep: 8,
|
||||
accountIdentifierType: 'phone',
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
},
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(calls.completions, [
|
||||
{
|
||||
step: 8,
|
||||
payload: {
|
||||
phoneVerification: true,
|
||||
loginPhoneVerification: true,
|
||||
code: '654321',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => {
|
||||
let resolvedStep = null;
|
||||
let resolvedOptions = null;
|
||||
|
||||
@@ -83,6 +83,414 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
|
||||
assert.equal(requests[1].searchParams.get('api_key'), 'demo-key');
|
||||
});
|
||||
|
||||
test('signup phone helper persists signup runtime state without touching add-phone activation', async () => {
|
||||
const setStateCalls = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
currentPhoneActivation: {
|
||||
activationId: 'add-phone-activation',
|
||||
phoneNumber: '66880000000',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
};
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
addLog: async () => {},
|
||||
ensureStep8SignupPageReady: async () => {},
|
||||
fetchImpl: async (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const action = parsedUrl.searchParams.get('action');
|
||||
if (action === 'getPrices') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => buildHeroSmsPricesPayload(),
|
||||
};
|
||||
}
|
||||
if (action === 'getNumber') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'ACCESS_NUMBER:signup-123:66959916439',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected HeroSMS action: ${action}`);
|
||||
},
|
||||
getState: async () => currentState,
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
setState: async (updates) => {
|
||||
setStateCalls.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const activation = await helpers.prepareSignupPhoneActivation(currentState);
|
||||
|
||||
assert.equal(activation.activationId, 'signup-123');
|
||||
assert.equal(activation.phoneNumber, '66959916439');
|
||||
assert.equal(currentState.signupPhoneNumber, '66959916439');
|
||||
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
|
||||
assert.deepStrictEqual(currentState.signupPhoneActivation, activation);
|
||||
assert.equal(currentState.accountIdentifierType, 'phone');
|
||||
assert.equal(currentState.accountIdentifier, '66959916439');
|
||||
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
|
||||
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
|
||||
});
|
||||
|
||||
test('signup phone helper polls signup SMS code and keeps activation purpose isolated', async () => {
|
||||
const setStateCalls = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
phoneCodeWaitSeconds: 15,
|
||||
phoneCodeTimeoutWindows: 1,
|
||||
phoneCodePollIntervalSeconds: 1,
|
||||
phoneCodePollMaxRounds: 2,
|
||||
signupPhoneActivation: {
|
||||
activationId: 'signup-123',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
currentPhoneActivation: {
|
||||
activationId: 'add-phone-activation',
|
||||
phoneNumber: '66880000000',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
};
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
addLog: async () => {},
|
||||
ensureStep8SignupPageReady: async () => {},
|
||||
fetchImpl: async (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const action = parsedUrl.searchParams.get('action');
|
||||
if (action === 'getStatus') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'STATUS_OK:123456',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected HeroSMS action: ${action}`);
|
||||
},
|
||||
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
|
||||
getState: async () => currentState,
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
setState: async (updates) => {
|
||||
setStateCalls.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const code = await helpers.waitForSignupPhoneCode(currentState, currentState.signupPhoneActivation);
|
||||
|
||||
assert.equal(code, '123456');
|
||||
assert.equal(currentState.currentPhoneVerificationCode, '123456');
|
||||
assert.equal(currentState.signupPhoneNumber, '66959916439');
|
||||
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
|
||||
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
|
||||
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationRequestedAt));
|
||||
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
|
||||
});
|
||||
|
||||
test('signup phone helper finalizes or cancels signup activation without clearing add-phone activation', async () => {
|
||||
const setStateCalls = [];
|
||||
const statusActions = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
heroSmsReuseEnabled: false,
|
||||
signupPhoneActivation: {
|
||||
activationId: 'signup-123',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
currentPhoneActivation: {
|
||||
activationId: 'add-phone-activation',
|
||||
phoneNumber: '66880000000',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
};
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
addLog: async () => {},
|
||||
ensureStep8SignupPageReady: async () => {},
|
||||
fetchImpl: async (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const action = parsedUrl.searchParams.get('action');
|
||||
if (action === 'setStatus') {
|
||||
statusActions.push(parsedUrl.searchParams.get('status'));
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'ACCESS_READY',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected HeroSMS action: ${action}`);
|
||||
},
|
||||
getState: async () => currentState,
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
setState: async (updates) => {
|
||||
setStateCalls.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await helpers.finalizeSignupPhoneActivationAfterSuccess(currentState, currentState.signupPhoneActivation);
|
||||
|
||||
assert.deepStrictEqual(statusActions, ['6']);
|
||||
assert.equal(currentState.signupPhoneNumber, '66959916439');
|
||||
assert.equal(currentState.signupPhoneActivation, null);
|
||||
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
|
||||
activationId: 'signup-123',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 1,
|
||||
maxUses: 3,
|
||||
});
|
||||
assert.equal(currentState.signupPhoneVerificationPurpose, '');
|
||||
assert.equal(currentState.accountIdentifierType, 'phone');
|
||||
assert.equal(currentState.accountIdentifier, '66959916439');
|
||||
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
|
||||
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
|
||||
});
|
||||
|
||||
test('signup phone helper completes signup SMS verification without touching add-phone activation', async () => {
|
||||
const setStateCalls = [];
|
||||
const contentMessages = [];
|
||||
const statusActions = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
heroSmsReuseEnabled: false,
|
||||
phoneCodeWaitSeconds: 15,
|
||||
phoneCodeTimeoutWindows: 1,
|
||||
phoneCodePollIntervalSeconds: 1,
|
||||
phoneCodePollMaxRounds: 2,
|
||||
signupPhoneNumber: '66959916439',
|
||||
signupPhoneVerificationPurpose: 'signup',
|
||||
signupPhoneActivation: {
|
||||
activationId: 'signup-123',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
currentPhoneActivation: {
|
||||
activationId: 'add-phone-activation',
|
||||
phoneNumber: '66880000000',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
};
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
addLog: async () => {},
|
||||
ensureStep8SignupPageReady: async () => {},
|
||||
fetchImpl: async (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const action = parsedUrl.searchParams.get('action');
|
||||
if (action === 'getStatus') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'STATUS_OK:123456',
|
||||
};
|
||||
}
|
||||
if (action === 'setStatus') {
|
||||
statusActions.push(parsedUrl.searchParams.get('status'));
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'ACCESS_READY',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected HeroSMS action: ${action}`);
|
||||
},
|
||||
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
|
||||
getState: async () => currentState,
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
contentMessages.push(message);
|
||||
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(`Unexpected content-script message: ${message.type}`);
|
||||
},
|
||||
setState: async (updates) => {
|
||||
setStateCalls.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const result = await helpers.completeSignupPhoneVerificationFlow(77, {
|
||||
state: currentState,
|
||||
signupProfile: {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
year: 1995,
|
||||
month: 1,
|
||||
day: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(result, { success: true });
|
||||
assert.deepStrictEqual(statusActions, ['6']);
|
||||
assert.deepStrictEqual(contentMessages.map((message) => ({
|
||||
type: message.type,
|
||||
step: message.step,
|
||||
code: message.payload?.code,
|
||||
purpose: message.payload?.purpose,
|
||||
})), [
|
||||
{
|
||||
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
|
||||
step: 4,
|
||||
code: '123456',
|
||||
purpose: 'signup',
|
||||
},
|
||||
]);
|
||||
assert.equal(currentState.signupPhoneNumber, '66959916439');
|
||||
assert.equal(currentState.signupPhoneActivation, null);
|
||||
assert.equal(currentState.signupPhoneVerificationPurpose, '');
|
||||
assert.equal(currentState.currentPhoneVerificationCode, '');
|
||||
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
|
||||
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
|
||||
});
|
||||
|
||||
test('signup phone helper completes login SMS verification by reusing the completed signup activation', async () => {
|
||||
const setStateCalls = [];
|
||||
const contentMessages = [];
|
||||
const statusActions = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
phoneCodeWaitSeconds: 15,
|
||||
phoneCodeTimeoutWindows: 1,
|
||||
phoneCodePollIntervalSeconds: 1,
|
||||
phoneCodePollMaxRounds: 2,
|
||||
signupPhoneCompletedActivation: {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
successfulUses: 1,
|
||||
maxUses: 3,
|
||||
},
|
||||
currentPhoneActivation: {
|
||||
activationId: 'add-phone-activation',
|
||||
phoneNumber: '66880000000',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 0,
|
||||
maxUses: 3,
|
||||
},
|
||||
};
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
addLog: async () => {},
|
||||
ensureStep8SignupPageReady: async () => {},
|
||||
fetchImpl: async (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const action = parsedUrl.searchParams.get('action');
|
||||
const id = parsedUrl.searchParams.get('id');
|
||||
if (action === 'reactivate' && id === 'signup-done') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'ACCESS_READY',
|
||||
};
|
||||
}
|
||||
if (action === 'getStatus' && id === 'signup-done') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'STATUS_OK:654321',
|
||||
};
|
||||
}
|
||||
if (action === 'setStatus' && id === 'signup-done') {
|
||||
statusActions.push(parsedUrl.searchParams.get('status'));
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'ACCESS_READY',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected HeroSMS action: ${action}:${id}`);
|
||||
},
|
||||
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
|
||||
getState: async () => currentState,
|
||||
sendToContentScriptResilient: async (_source, message) => {
|
||||
contentMessages.push(message);
|
||||
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(`Unexpected content-script message: ${message.type}`);
|
||||
},
|
||||
setState: async (updates) => {
|
||||
setStateCalls.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const result = await helpers.completeLoginPhoneVerificationFlow(77, {
|
||||
state: currentState,
|
||||
visibleStep: 8,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(result, { success: true });
|
||||
assert.deepStrictEqual(statusActions, ['6']);
|
||||
assert.deepStrictEqual(contentMessages.map((message) => ({
|
||||
type: message.type,
|
||||
step: message.step,
|
||||
code: message.payload?.code,
|
||||
purpose: message.payload?.purpose,
|
||||
})), [
|
||||
{
|
||||
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
|
||||
step: 8,
|
||||
code: '654321',
|
||||
purpose: 'login',
|
||||
},
|
||||
]);
|
||||
assert.equal(currentState.signupPhoneActivation, null);
|
||||
assert.equal(currentState.signupPhoneVerificationPurpose, '');
|
||||
assert.equal(currentState.currentPhoneVerificationCode, '');
|
||||
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
|
||||
activationId: 'signup-done',
|
||||
phoneNumber: '66959916439',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
countryLabel: 'Thailand',
|
||||
successfulUses: 2,
|
||||
maxUses: 3,
|
||||
});
|
||||
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
|
||||
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationPurpose === 'login'));
|
||||
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
|
||||
});
|
||||
|
||||
test('phone verification helper ignores HeroSMS virtual-only stock when physicalCount is zero', async () => {
|
||||
const requests = [];
|
||||
const helpers = api.createPhoneVerificationHelpers({
|
||||
|
||||
@@ -336,7 +336,67 @@ test('account records manager supports filter chips and partial multi-select del
|
||||
assert.match(list.innerHTML, /stopped@example\.com/);
|
||||
assert.equal(btnDeleteSelectedAccountRecords.disabled, true);
|
||||
assert.deepStrictEqual(toasts.at(-1), {
|
||||
message: '已删除 1 条邮箱记录。',
|
||||
message: '已删除 1 条账号记录。',
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
test('account records manager displays phone-only records with account identifier fallback', () => {
|
||||
const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8');
|
||||
const windowObject = {};
|
||||
const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject);
|
||||
|
||||
const list = createNode();
|
||||
const meta = createNode();
|
||||
const manager = api.createAccountRecordsManager({
|
||||
state: {
|
||||
getLatestState: () => ({
|
||||
accountRunHistory: [
|
||||
{
|
||||
recordId: 'phone:+6612345',
|
||||
accountIdentifierType: 'phone',
|
||||
accountIdentifier: '+6612345',
|
||||
phoneNumber: '+6612345',
|
||||
email: '',
|
||||
password: '',
|
||||
finalStatus: 'success',
|
||||
finishedAt: '2026-04-17T04:31:00.000Z',
|
||||
retryCount: 0,
|
||||
failureLabel: '流程完成',
|
||||
},
|
||||
],
|
||||
}),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
accountRecordsList: list,
|
||||
accountRecordsMeta: meta,
|
||||
accountRecordsOverlay: createNode(),
|
||||
accountRecordsPageLabel: createNode(),
|
||||
accountRecordsStats: createNode(),
|
||||
btnAccountRecordsNext: createNode(),
|
||||
btnAccountRecordsPrev: createNode(),
|
||||
btnClearAccountRecords: createNode(),
|
||||
btnCloseAccountRecords: createNode(),
|
||||
btnDeleteSelectedAccountRecords: createNode(),
|
||||
btnOpenAccountRecords: createNode(),
|
||||
btnToggleAccountRecordsSelection: createNode(),
|
||||
},
|
||||
helpers: {
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({}),
|
||||
},
|
||||
constants: {
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
manager.render();
|
||||
|
||||
assert.match(meta.textContent, /共 1 条/);
|
||||
assert.match(list.innerHTML, /\+6612345/);
|
||||
assert.doesNotMatch(list.innerHTML, /\(空邮箱\)/);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,9 @@ test('sidepanel html exposes phone verification toggle and multi-provider SMS ro
|
||||
assert.match(html, /id="btn-toggle-phone-verification-section"/);
|
||||
assert.match(html, /id="row-phone-verification-fold"/);
|
||||
assert.match(html, /id="input-phone-verification-enabled"/);
|
||||
assert.match(html, /id="row-signup-method"/);
|
||||
assert.match(html, /data-signup-method="email"/);
|
||||
assert.match(html, /data-signup-method="phone"/);
|
||||
assert.match(html, /id="row-phone-sms-provider"/);
|
||||
assert.match(html, /id="select-phone-sms-provider"/);
|
||||
assert.match(html, /id="row-phone-sms-provider-order"/);
|
||||
@@ -124,6 +127,7 @@ let latestState = {};
|
||||
const inputPhoneVerificationEnabled = { checked: false };
|
||||
const rowPhoneVerificationEnabled = { style: { display: 'none' } };
|
||||
const rowPhoneVerificationFold = { style: { display: 'none' } };
|
||||
const rowSignupMethod = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProvider = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProviderOrder = { style: { display: 'none' } };
|
||||
const rowPhoneSmsProviderOrderActions = { style: { display: 'none' } };
|
||||
@@ -169,7 +173,15 @@ const rowHeroSmsCountryFallback = { style: { display: 'none' } };
|
||||
const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
|
||||
const rowHeroSmsApiKey = { style: { display: 'none' } };
|
||||
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
|
||||
const rowFiveSimApiKey = { style: { display: 'none' } };
|
||||
const rowFiveSimCountry = { style: { display: 'none' } };
|
||||
const rowFiveSimCountryFallback = { style: { display: 'none' } };
|
||||
const rowFiveSimOperator = { style: { display: 'none' } };
|
||||
const rowFiveSimProduct = { style: { display: 'none' } };
|
||||
const rowNexSmsApiKey = { style: { display: 'none' } };
|
||||
const rowNexSmsCountry = { style: { display: 'none' } };
|
||||
const rowNexSmsCountryFallback = { style: { display: 'none' } };
|
||||
const rowNexSmsServiceCode = { style: { display: 'none' } };
|
||||
const rowHeroSmsCurrentNumber = { style: { display: 'none' } };
|
||||
const rowHeroSmsCurrentCountdown = { style: { display: 'none' } };
|
||||
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
|
||||
@@ -183,10 +195,13 @@ const rowPhoneCodePollIntervalSeconds = { style: { display: 'none' } };
|
||||
const rowPhoneCodePollMaxRounds = { style: { display: 'none' } };
|
||||
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
|
||||
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
|
||||
let selectedPhoneSmsProvider = PHONE_SMS_PROVIDER_HERO_SMS;
|
||||
function getSelectedPhoneSmsProvider() { return selectedPhoneSmsProvider; }
|
||||
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
|
||||
function getSelectedPhoneSmsProvider() { return selectPhoneSmsProvider.value; }
|
||||
function isFiveSimProviderSelected() { return getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM; }
|
||||
function updateHeroSmsPlatformDisplay() {}
|
||||
function updateSignupMethodUI() {
|
||||
rowSignupMethod.style.display = inputPhoneVerificationEnabled.checked ? '' : 'none';
|
||||
}
|
||||
|
||||
${extractFunction('updatePhoneVerificationSettingsUI')}
|
||||
|
||||
@@ -194,6 +209,7 @@ return {
|
||||
setLatestState: (state) => { latestState = state || {}; },
|
||||
rowPhoneVerificationEnabled,
|
||||
rowPhoneVerificationFold,
|
||||
rowSignupMethod,
|
||||
rowPhoneSmsProvider,
|
||||
rowPhoneSmsProviderOrder,
|
||||
rowPhoneSmsProviderOrderActions,
|
||||
@@ -206,7 +222,15 @@ return {
|
||||
rowHeroSmsAcquirePriority,
|
||||
rowHeroSmsApiKey,
|
||||
rowHeroSmsMaxPrice,
|
||||
rowFiveSimApiKey,
|
||||
rowFiveSimCountry,
|
||||
rowFiveSimCountryFallback,
|
||||
rowFiveSimOperator,
|
||||
rowFiveSimProduct,
|
||||
rowNexSmsApiKey,
|
||||
rowNexSmsCountry,
|
||||
rowNexSmsCountryFallback,
|
||||
rowNexSmsServiceCode,
|
||||
rowHeroSmsCurrentNumber,
|
||||
rowHeroSmsCurrentCountdown,
|
||||
rowHeroSmsPriceTiers,
|
||||
@@ -218,7 +242,7 @@ return {
|
||||
rowPhoneCodeTimeoutWindows,
|
||||
rowPhoneCodePollIntervalSeconds,
|
||||
rowPhoneCodePollMaxRounds,
|
||||
setSelectedPhoneSmsProvider(value) { selectedPhoneSmsProvider = value; },
|
||||
setSelectedPhoneSmsProvider(value) { selectPhoneSmsProvider.value = value; },
|
||||
updatePhoneVerificationSettingsUI,
|
||||
};
|
||||
`)();
|
||||
@@ -226,6 +250,7 @@ return {
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowPhoneVerificationEnabled.style.display, '');
|
||||
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
|
||||
assert.equal(api.rowSignupMethod.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProvider.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProviderOrder.style.display, 'none');
|
||||
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, 'none');
|
||||
@@ -262,6 +287,7 @@ return {
|
||||
api.inputPhoneVerificationEnabled.checked = true;
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowPhoneVerificationFold.style.display, '');
|
||||
assert.equal(api.rowSignupMethod.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProvider.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProviderOrder.style.display, '');
|
||||
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, '');
|
||||
@@ -288,7 +314,18 @@ return {
|
||||
|
||||
api.setSelectedPhoneSmsProvider('5sim');
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowFiveSimApiKey.style.display, '');
|
||||
assert.equal(api.rowFiveSimCountry.style.display, '');
|
||||
assert.equal(api.rowFiveSimCountryFallback.style.display, '');
|
||||
assert.equal(api.rowFiveSimOperator.style.display, '');
|
||||
assert.equal(api.rowFiveSimProduct.style.display, '');
|
||||
|
||||
api.setSelectedPhoneSmsProvider('nexsms');
|
||||
api.updatePhoneVerificationSettingsUI();
|
||||
assert.equal(api.rowNexSmsApiKey.style.display, '');
|
||||
assert.equal(api.rowNexSmsCountry.style.display, '');
|
||||
assert.equal(api.rowNexSmsCountryFallback.style.display, '');
|
||||
assert.equal(api.rowNexSmsServiceCode.style.display, '');
|
||||
});
|
||||
|
||||
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
|
||||
@@ -345,6 +382,8 @@ const inputHeroSmsApiKey = { value: 'demo-key' };
|
||||
const inputFiveSimApiKey = { value: 'five-sim-key' };
|
||||
const inputFiveSimOperator = { value: 'any' };
|
||||
const inputFiveSimProduct = { value: 'openai' };
|
||||
const inputNexSmsApiKey = { value: 'nex-key' };
|
||||
const inputNexSmsServiceCode = { value: 'ot' };
|
||||
const inputHeroSmsReuseEnabled = { checked: true };
|
||||
const selectHeroSmsAcquirePriority = { value: 'price' };
|
||||
function getSelectedPhonePreferredActivation() {
|
||||
@@ -359,7 +398,7 @@ function getSelectedPhonePreferredActivation() {
|
||||
};
|
||||
}
|
||||
const inputHeroSmsMaxPrice = { value: '0.12' };
|
||||
const inputFiveSimOperator = { value: 'any' };
|
||||
const inputHeroSmsPreferredPrice = { value: '0.0512' };
|
||||
const inputPhoneReplacementLimit = { value: '5' };
|
||||
const inputPhoneCodeWaitSeconds = { value: '75' };
|
||||
const inputPhoneCodeTimeoutWindows = { value: '3' };
|
||||
@@ -390,10 +429,14 @@ const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
|
||||
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
|
||||
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
|
||||
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
|
||||
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
|
||||
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
|
||||
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
|
||||
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
|
||||
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
|
||||
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
|
||||
const DEFAULT_NEX_SMS_COUNTRY_ORDER = [1];
|
||||
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
|
||||
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
|
||||
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
|
||||
const selectHeroSmsCountry = {
|
||||
@@ -420,6 +463,13 @@ function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
|
||||
${extractFunction('normalizePhoneSmsProvider')}
|
||||
${extractFunction('normalizePhoneSmsProviderValue')}
|
||||
${extractFunction('normalizeFiveSimCountryCode')}
|
||||
${extractFunction('normalizeFiveSimCountryOrderValue')}
|
||||
${extractFunction('normalizeFiveSimProductValue')}
|
||||
${extractFunction('normalizeNexSmsCountryIdValue')}
|
||||
${extractFunction('normalizeNexSmsCountryOrderValue')}
|
||||
${extractFunction('normalizeNexSmsServiceCodeValue')}
|
||||
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
|
||||
${extractFunction('normalizeFiveSimCountryId')}
|
||||
${extractFunction('normalizeFiveSimCountryLabel')}
|
||||
@@ -441,6 +491,13 @@ ${extractFunction('getSelectedHeroSmsCountryOption')}
|
||||
function syncHeroSmsFallbackSelectionOrderFromSelect() {
|
||||
return [{ id: 52, label: 'Thailand' }, { id: 16, label: 'United Kingdom' }];
|
||||
}
|
||||
function getSelectedSignupMethod() { return 'phone'; }
|
||||
function getSelectedFiveSimCountries() {
|
||||
return [{ id: 'thailand', code: 'thailand', label: 'Thailand' }, { id: 'vietnam', code: 'vietnam', label: 'Vietnam' }];
|
||||
}
|
||||
function getSelectedNexSmsCountries() {
|
||||
return [{ id: 1, label: 'Country #1' }];
|
||||
}
|
||||
${extractFunction('collectSettingsPayload')}
|
||||
return { collectSettingsPayload };
|
||||
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
|
||||
@@ -448,17 +505,22 @@ return { collectSettingsPayload };
|
||||
const payload = api.collectSettingsPayload();
|
||||
|
||||
assert.equal(payload.phoneVerificationEnabled, true);
|
||||
assert.equal(payload.signupMethod, 'phone');
|
||||
assert.equal(payload.phoneSmsProvider, 'hero-sms');
|
||||
assert.equal(payload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
assert.equal(payload.heroSmsApiKey, 'demo-key');
|
||||
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
|
||||
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'england']);
|
||||
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'vietnam']);
|
||||
assert.equal(payload.fiveSimOperator, 'any');
|
||||
assert.equal(payload.fiveSimProduct, 'openai');
|
||||
assert.equal(payload.nexSmsApiKey, 'nex-key');
|
||||
assert.deepStrictEqual(payload.nexSmsCountryOrder, [1]);
|
||||
assert.equal(payload.nexSmsServiceCode, 'ot');
|
||||
assert.equal(payload.heroSmsReuseEnabled, true);
|
||||
assert.equal(payload.heroSmsAcquirePriority, 'price');
|
||||
assert.equal(payload.heroSmsMaxPrice, '0.12');
|
||||
assert.equal(payload.heroSmsPreferredPrice, '0.0512');
|
||||
assert.deepStrictEqual(payload.phonePreferredActivation, {
|
||||
provider: 'hero-sms',
|
||||
activationId: 'demo-activation',
|
||||
@@ -476,7 +538,7 @@ return { collectSettingsPayload };
|
||||
assert.equal(payload.heroSmsCountryId, 52);
|
||||
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
|
||||
assert.deepStrictEqual(payload.heroSmsCountryFallback, [{ id: 16, label: 'United Kingdom' }]);
|
||||
assert.equal(payload.fiveSimApiKey, '');
|
||||
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
|
||||
assert.equal(payload.fiveSimCountryId, 'vietnam');
|
||||
});
|
||||
|
||||
@@ -612,19 +674,27 @@ const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
|
||||
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
|
||||
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
|
||||
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
|
||||
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
|
||||
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
|
||||
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
|
||||
const inputHeroSmsMaxPrice = { value: '' };
|
||||
const inputHeroSmsApiKey = { value: '' };
|
||||
const inputFiveSimOperator = { value: 'any' };
|
||||
const inputFiveSimProduct = { value: 'openai' };
|
||||
const displayHeroSmsPriceTiers = { textContent: '' };
|
||||
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
|
||||
const fetchCalls = [];
|
||||
|
||||
${extractFunction('normalizePhoneSmsProvider')}
|
||||
${extractFunction('normalizePhoneSmsProviderValue')}
|
||||
${extractFunction('normalizePhoneSmsProviderOrderValue')}
|
||||
const phoneSmsProviderOrderSelection = [];
|
||||
function getSelectedPhoneSmsProvider() { return '5sim'; }
|
||||
function getSelectedPhoneSmsProviderOrder() { return ['5sim']; }
|
||||
${extractFunction('normalizeFiveSimCountryId')}
|
||||
${extractFunction('normalizeFiveSimCountryLabel')}
|
||||
${extractFunction('normalizeFiveSimCountryCode')}
|
||||
${extractFunction('normalizeFiveSimProductValue')}
|
||||
${extractFunction('normalizeFiveSimOperator')}
|
||||
${extractFunction('normalizePhoneSmsMaxPriceValue')}
|
||||
${extractFunction('normalizeFiveSimMaxPriceValue')}
|
||||
@@ -636,7 +706,13 @@ ${extractFunction('collectHeroSmsPriceEntriesForPreview')}
|
||||
${extractFunction('formatPhoneSmsPriceEntriesSummary')}
|
||||
${extractFunction('describeHeroSmsPreviewPayload')}
|
||||
${extractFunction('summarizeHeroSmsPreviewError')}
|
||||
${extractFunction('formatPriceTiersForPreview')}
|
||||
${extractFunction('formatPriceTiersWithZeroStockForPreview')}
|
||||
function normalizeHeroSmsFetchErrorMessage(error) { return error?.message || String(error); }
|
||||
function getFiveSimCountryLabelByCode() { return '越南 (Vietnam)'; }
|
||||
function getSelectedFiveSimCountries() {
|
||||
return [{ id: 'vietnam', code: 'vietnam', label: '越南 (Vietnam)' }];
|
||||
}
|
||||
function syncHeroSmsFallbackSelectionOrderFromSelect() {
|
||||
return [{ id: 'vietnam', label: '越南 (Vietnam)' }];
|
||||
}
|
||||
@@ -653,14 +729,14 @@ async function fetch(url, options = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ openai: { Category: 'activation', Qty: 4609, Price: 0.08 } }),
|
||||
json: async () => ({ openai: { Category: 'activation', Qty: 4609, Price: 0.08 } }),
|
||||
};
|
||||
}
|
||||
if (parsed.pathname === '/v1/guest/prices') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
json: async () => ({
|
||||
vietnam: {
|
||||
openai: {
|
||||
virtual21: { cost: 0.0769, count: 0 },
|
||||
@@ -673,6 +749,7 @@ async function fetch(url, options = {}) {
|
||||
throw new Error('unexpected ' + parsed.pathname);
|
||||
}
|
||||
|
||||
${extractFunction('buildFiveSimPricePreviewLines')}
|
||||
${extractFunction('previewHeroSmsPriceTiers')}
|
||||
|
||||
return {
|
||||
@@ -685,11 +762,14 @@ return {
|
||||
|
||||
await api.previewHeroSmsPriceTiers();
|
||||
|
||||
assert.equal(api.displayHeroSmsPriceTiers.textContent, '越南 (Vietnam): 最低 0.08');
|
||||
assert.equal(
|
||||
api.displayHeroSmsPriceTiers.textContent,
|
||||
'5sim:\n越南 (Vietnam): 最低 0.1282;档位:0.0769(x0), 0.1282(x4608)'
|
||||
);
|
||||
assert.equal(api.rowHeroSmsPriceTiers.style.display, '');
|
||||
assert.deepStrictEqual(
|
||||
api.fetchCalls.map((entry) => entry.url.pathname),
|
||||
['/v1/guest/products/vietnam/any', '/v1/guest/prices']
|
||||
['/v1/guest/prices']
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ ${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_PHONE_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_PHONE_PATTERN')}
|
||||
${extractConst('SIGNUP_MORE_OPTIONS_PATTERN')}
|
||||
${extractConst('SIGNUP_WORK_EMAIL_PATTERN')}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
@@ -196,6 +199,8 @@ async function sleep(ms) {
|
||||
${extractFunction('getSignupEmailInput')}
|
||||
${extractFunction('getSignupPhoneInput')}
|
||||
${extractFunction('findSignupUseEmailTrigger')}
|
||||
${extractFunction('findSignupUsePhoneTrigger')}
|
||||
${extractFunction('findSignupMoreOptionsTrigger')}
|
||||
${extractFunction('getSignupEmailContinueButton')}
|
||||
${extractFunction('inspectSignupEntryState')}
|
||||
${extractFunction('waitForSignupEntryState')}
|
||||
@@ -309,6 +314,9 @@ ${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_PHONE_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_PHONE_PATTERN')}
|
||||
${extractConst('SIGNUP_MORE_OPTIONS_PATTERN')}
|
||||
${extractConst('SIGNUP_WORK_EMAIL_PATTERN')}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
@@ -369,6 +377,8 @@ async function sleep(ms) {
|
||||
${extractFunction('getSignupEmailInput')}
|
||||
${extractFunction('getSignupPhoneInput')}
|
||||
${extractFunction('findSignupUseEmailTrigger')}
|
||||
${extractFunction('findSignupUsePhoneTrigger')}
|
||||
${extractFunction('findSignupMoreOptionsTrigger')}
|
||||
${extractFunction('getSignupEmailContinueButton')}
|
||||
${extractFunction('inspectSignupEntryState')}
|
||||
${extractFunction('waitForSignupEntryState')}
|
||||
@@ -429,3 +439,222 @@ return {
|
||||
|
||||
assert.equal(api.run()?.kind, 'localized-email');
|
||||
});
|
||||
|
||||
test('submitSignupPhoneNumberAndContinue switches from email mode to phone mode and submits local number', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const clicks = [];
|
||||
const filled = [];
|
||||
let phase = 'email';
|
||||
let now = 0;
|
||||
|
||||
const emailInput = {
|
||||
kind: 'email',
|
||||
value: '',
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'email';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
const phoneInput = {
|
||||
kind: 'phone',
|
||||
value: '',
|
||||
textContent: 'Thailand (+66)',
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'tel';
|
||||
return '';
|
||||
},
|
||||
closest() {
|
||||
return { textContent: 'Thailand (+66)' };
|
||||
},
|
||||
};
|
||||
|
||||
const switchButton = {
|
||||
textContent: 'Continue with phone number',
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'button';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 200, height: 48 };
|
||||
},
|
||||
};
|
||||
|
||||
const continueButton = {
|
||||
textContent: 'Continue',
|
||||
value: '',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 200, height: 48 };
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === SIGNUP_EMAIL_INPUT_SELECTOR) {
|
||||
return phase === 'email' ? emailInput : null;
|
||||
}
|
||||
if (selector === SIGNUP_PHONE_INPUT_SELECTOR) {
|
||||
return phase === 'phone' ? phoneInput : null;
|
||||
}
|
||||
if (selector === 'button[type="submit"], input[type="submit"]') {
|
||||
return phase === 'phone' ? continueButton : null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'button, a, [role="button"], [role="link"]') {
|
||||
return phase === 'email' ? [switchButton] : [];
|
||||
}
|
||||
if (selector === 'a, button, [role="button"], [role="link"]') {
|
||||
return [];
|
||||
}
|
||||
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
|
||||
return phase === 'phone' ? [continueButton] : [];
|
||||
}
|
||||
if (selector === 'input') {
|
||||
return phase === 'phone' ? [phoneInput] : [emailInput];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const location = {
|
||||
href: 'https://chatgpt.com/',
|
||||
};
|
||||
|
||||
const window = {
|
||||
setTimeout(fn) {
|
||||
fn();
|
||||
},
|
||||
};
|
||||
|
||||
const Date = {
|
||||
now() {
|
||||
return now;
|
||||
},
|
||||
};
|
||||
|
||||
${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')}
|
||||
${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')}
|
||||
${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_WORK_EMAIL_PATTERN')}
|
||||
${extractConst('SIGNUP_PHONE_ACTION_PATTERN')}
|
||||
${extractConst('SIGNUP_SWITCH_TO_PHONE_PATTERN')}
|
||||
${extractConst('SIGNUP_MORE_OPTIONS_PATTERN')}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
return Boolean(el);
|
||||
}
|
||||
|
||||
function isActionEnabled(el) {
|
||||
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSignupPasswordPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSignupEntryTrigger() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordDisplayedEmail() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getPageTextSnapshot() {
|
||||
return phase === 'phone' ? 'Thailand (+66)' : '';
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
function isStopError() { return false; }
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function humanPause() {}
|
||||
|
||||
function simulateClick(target) {
|
||||
clicks.push(getActionText(target));
|
||||
if (target === switchButton) {
|
||||
phase = 'phone';
|
||||
}
|
||||
}
|
||||
|
||||
function fillInput(target, value) {
|
||||
target.value = value;
|
||||
filled.push({ target: target.kind, value });
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
now += ms;
|
||||
}
|
||||
|
||||
${extractFunction('getSignupEmailInput')}
|
||||
${extractFunction('getSignupPhoneInput')}
|
||||
${extractFunction('findSignupUseEmailTrigger')}
|
||||
${extractFunction('findSignupUsePhoneTrigger')}
|
||||
${extractFunction('findSignupMoreOptionsTrigger')}
|
||||
${extractFunction('getSignupEmailContinueButton')}
|
||||
${extractFunction('inspectSignupEntryState')}
|
||||
${extractFunction('getSignupEntryStateSummary')}
|
||||
function getSignupEntryDiagnostics() { return {}; }
|
||||
${extractFunction('normalizePhoneDigits')}
|
||||
${extractFunction('extractDialCodeFromText')}
|
||||
${extractFunction('toNationalPhoneNumber')}
|
||||
${extractFunction('resolveSignupPhoneDialCode')}
|
||||
${extractFunction('waitForSignupPhoneEntryState')}
|
||||
${extractFunction('submitSignupPhoneNumberAndContinue')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return submitSignupPhoneNumberAndContinue({
|
||||
phoneNumber: '66959916439',
|
||||
countryLabel: 'Thailand',
|
||||
});
|
||||
},
|
||||
getClicks() {
|
||||
return clicks.slice();
|
||||
},
|
||||
getFilled() {
|
||||
return filled.slice();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.equal(result.submitted, true);
|
||||
assert.equal(result.phoneInputValue, '959916439');
|
||||
assert.deepEqual(api.getClicks(), ['Continue with phone number', 'Continue']);
|
||||
assert.deepEqual(api.getFilled(), [{ target: 'phone', value: '959916439' }]);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,26 @@ function inspectSignupEntryState() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function isPhoneVerificationPageReady() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPhoneVerificationDisplayedPhone() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getVerificationCodeTarget() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStep4PostVerificationState() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVerificationPageStillVisible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureSignupPasswordPageReady() {
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ function getLoginEmailInput() {
|
||||
return ${JSON.stringify(overrides.emailInput || null)};
|
||||
}
|
||||
|
||||
function getLoginPhoneInput() {
|
||||
return ${JSON.stringify(overrides.phoneInput || null)};
|
||||
}
|
||||
|
||||
function findOneTimeCodeLoginTrigger() {
|
||||
return ${JSON.stringify(overrides.switchTrigger || null)};
|
||||
}
|
||||
@@ -100,6 +104,14 @@ function findLoginEntryTrigger() {
|
||||
return ${JSON.stringify(overrides.loginEntryTrigger || null)};
|
||||
}
|
||||
|
||||
function findLoginPhoneEntryTrigger() {
|
||||
return ${JSON.stringify(overrides.phoneEntryTrigger || null)};
|
||||
}
|
||||
|
||||
function findLoginMoreOptionsTrigger() {
|
||||
return ${JSON.stringify(overrides.moreOptionsTrigger || null)};
|
||||
}
|
||||
|
||||
function getLoginSubmitButton() {
|
||||
return ${JSON.stringify(overrides.submitButton || null)};
|
||||
}
|
||||
@@ -239,9 +251,24 @@ return {
|
||||
assert.strictEqual(snapshot.state, 'entry_page');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createApi({
|
||||
phoneInput: { id: 'phone' },
|
||||
submitButton: { id: 'submit' },
|
||||
});
|
||||
|
||||
const snapshot = api.inspectLoginAuthState();
|
||||
assert.strictEqual(snapshot.state, 'phone_entry_page');
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"),
|
||||
'inspectLoginAuthState 应产出 oauth_consent_page 状态'
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
extractFunction('inspectLoginAuthState').includes("state: 'phone_entry_page'"),
|
||||
'inspectLoginAuthState 应产出 phone_entry_page 状态'
|
||||
);
|
||||
|
||||
console.log('step6 login state tests passed');
|
||||
|
||||
Reference in New Issue
Block a user