diff --git a/packages/w3up-client/README.md b/packages/w3up-client/README.md index b773d878b..9198e5559 100644 --- a/packages/w3up-client/README.md +++ b/packages/w3up-client/README.md @@ -129,13 +129,8 @@ const account = await client.login('zaphod@beeblebrox.galaxy') If your account doesn't have a payment plan yet, you'll be prompted to select one after verifying your email. A payment plan is required to provision a space. You can use the following loop to wait until a payment plan is selected: ```js -// wait for payment plan to be selected -while (true) { - const res = await account.plan.get() - if (res.ok) break - console.log('Waiting for payment plan to be selected...') - await new Promise((resolve) => setTimeout(resolve, 1000)) -} +// Wait for a payment plan with a 1-second polling interval and 15-minute timeout +await account.plan.wait() ``` Spaces can be created using the [`createSpace` client method][docs-client#createSpace]: diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 02eb50d56..c6e418202 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -235,6 +235,46 @@ export class AccountPlan { }) } + /** + * Waits for a payment plan to be selected. + * This method continuously checks the account's payment plan status + * at a specified interval until a valid plan is selected, or when the timeout is reached, + * or when the abort signal is aborted. + * + * @param {object} [options] + * @param {number} [options.interval=1000] - The polling interval in milliseconds (default is 1000ms). + * @param {number} [options.timeout=900000] - The maximum time to wait in milliseconds before throwing a timeout error (default is 15 minutes). + * @param {AbortSignal} [options.signal] - An optional AbortSignal to cancel the waiting process. + * @returns {Promise} - Resolves once a payment plan is selected within the timeout. + * @throws {Error} - Throws an error if there is an issue retrieving the payment plan or if the timeout is exceeded. + */ + async wait(options) { + const startTime = Date.now() + const interval = options?.interval || 1000 // 1 second + const timeout = options?.timeout || 60 * 15 * 1000 // 15 minutes + + // eslint-disable-next-line no-constant-condition + while (true) { + const res = await this.get() + if (res.ok) return res.ok + + if (res.error) { + throw new Error(`Error retrieving payment plan: ${res.error}`) + } + + if (Date.now() - startTime > timeout) { + throw new Error('Timeout: Payment plan selection took too long.') + } + + if (options?.signal?.aborted) { + throw new Error('Aborted: Payment plan selection was aborted.') + } + + console.log('Waiting for payment plan to be selected...') + await new Promise((resolve) => setTimeout(resolve, interval)) + } + } + /** * * @param {import('@web3-storage/access').AccountDID} accountDID diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index b9a1a2650..573907b6c 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -312,6 +312,91 @@ export const testAccount = Test.withContext({ assert.deepEqual(client.currentSpace()?.did(), space.did()) }, + + waitForPaymentPlan: { + 'should wait for a payment plan to be selected': async ( + assert, + { client, mail, grantAccess } + ) => { + const email = 'alice@web.mail' + const login = Account.login(client, email) + await grantAccess(await mail.take()) + const account = Result.try(await login) + + let callCount = 0 + // Mock the get method to simulate a plan being selected after some time + // @ts-expect-error + account.plan.get = async () => { + callCount++ + if (callCount > 2) { + return { ok: { product: 'did:web:example.com' } } + } + return { ok: false } + } + + const result = await account.plan.wait({ interval: 100, timeout: 1000 }) + assert.deepEqual(result.product, 'did:web:example.com') + }, + + 'should throw an error if there is an issue retrieving the payment plan': + async (assert, { client, mail, grantAccess }) => { + const email = 'alice@web.mail' + const login = Account.login(client, email) + await grantAccess(await mail.take()) + const account = Result.try(await login) + + // @ts-expect-error + account.plan.get = async () => Promise.resolve({ error: 'Some error' }) + + await assert.rejects( + account.plan.wait({ interval: 100, timeout: 1000 }), + { + message: 'Error retrieving payment plan: Some error', + } + ) + }, + + 'should throw a timeout error if the payment plan selection takes too long': + async (assert, { client, mail, grantAccess }) => { + const email = 'alice@web.mail' + const login = Account.login(client, email) + await grantAccess(await mail.take()) + const account = Result.try(await login) + + // @ts-expect-error + account.plan.get = async () => Promise.resolve({ ok: false }) + + await assert.rejects( + account.plan.wait({ interval: 100, timeout: 500 }), + { + message: 'Timeout: Payment plan selection took too long.', + } + ) + }, + + 'should throw an error when the abort signal is aborted': async ( + assert, + { client, mail, grantAccess } + ) => { + const abortController = new AbortController() + const signal = abortController.signal + + const email = 'alice@web.mail' + const login = Account.login(client, email) + await grantAccess(await mail.take()) + const account = Result.try(await login) + + // @ts-expect-error + account.plan.get = async () => Promise.resolve({ ok: false }) + + // Abort the signal after a short delay + setTimeout(() => abortController.abort(), 100) + + await assert.rejects(account.plan.wait({ signal }), { + message: 'Aborted: Payment plan selection was aborted.', + }) + }, + }, }) Test.test({ Account: testAccount })