fix(agent-world): re-enable Buy after a purchase in Tiny Place trading (#4217)

Co-authored-by: M3gA-Mind <elvin@mahadao.com>
This commit is contained in:
Mega Mind
2026-06-30 18:17:15 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 34bc6906f1
commit 4255fa2b29
2 changed files with 101 additions and 5 deletions
@@ -1046,6 +1046,77 @@ describe('Trading tab — buy identity (x402)', () => {
});
});
test('Buy buttons re-enable after a purchase and Dismiss clears the banner (#4196)', async () => {
vi.mocked(apiClient.marketplace.buyIdentity)
.mockResolvedValueOnce({
challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' },
walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' },
walletAddress: 'WalletXyz12345678',
})
.mockResolvedValueOnce({ result: { saleId: 's1' }, payment: { onChainTx: 'TxId1' } });
render(<IdentitiesSection />);
await gotoTab('Trading');
await userEvent.click(await screen.findByRole('button', { name: 'Buy' }));
await userEvent.click(await screen.findByTestId('x402-confirm'));
// Purchase completed.
await screen.findByTestId('buy-identity-success');
// Regression: the Buy button must NOT stay permanently disabled after a
// completed purchase (the old `disabled={buying !== null}` never reset
// because `closeBuy` was only reachable from the now-unmounted dialog).
expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled();
// Dismiss clears the terminal banner and keeps Buy usable.
await userEvent.click(screen.getByTestId('buy-identity-dismiss'));
expect(screen.queryByTestId('buy-identity-success')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled();
});
test('a failed purchase shows the error banner and Dismiss resets it (#4196)', async () => {
vi.mocked(apiClient.marketplace.buyIdentity)
.mockResolvedValueOnce({
challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' },
walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' },
walletAddress: 'WalletXyz12345678',
})
.mockRejectedValueOnce(new Error('settlement boom'));
render(<IdentitiesSection />);
await gotoTab('Trading');
await userEvent.click(await screen.findByRole('button', { name: 'Buy' }));
await userEvent.click(await screen.findByTestId('x402-confirm'));
const errorBanner = await screen.findByTestId('buy-identity-error');
expect(errorBanner).toHaveTextContent('Purchase failed.');
// Buy must re-enable after a failed attempt, and Dismiss clears the banner.
expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled();
await userEvent.click(screen.getByTestId('buy-identity-dismiss'));
expect(screen.queryByTestId('buy-identity-error')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled();
});
test('a post-payment failure surfaces the broadcast tx in the buy error banner (#4196)', async () => {
// The settlement broadcast went out but the purchase did not finalize: the
// error carries an `onChainTx=`, so the banner must report "Payment sent…"
// (the truthy branch of the buy error message) and re-enable Buy.
vi.mocked(apiClient.marketplace.buyIdentity)
.mockResolvedValueOnce({
challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' },
walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' },
walletAddress: 'WalletXyz12345678',
})
.mockRejectedValueOnce(new Error('paid but not confirmed (onChainTx=BuyTx99)'));
render(<IdentitiesSection />);
await gotoTab('Trading');
await userEvent.click(await screen.findByRole('button', { name: 'Buy' }));
await userEvent.click(await screen.findByTestId('x402-confirm'));
const errorBanner = await screen.findByTestId('buy-identity-error');
expect(errorBanner).toHaveTextContent('Payment sent but purchase did not complete.');
expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled();
});
test('auction listings do not show a Buy button', async () => {
vi.mocked(apiClient.marketplace.listIdentities).mockResolvedValue({
identities: [{ ...fixedListing, listingType: 'auction' as const }],
+30 -5
View File
@@ -707,6 +707,13 @@ function TradingTab() {
const [buying, setBuying] = useState<IdentityListing | null>(null);
const buy = useX402Buy((id, opts) => apiClient.marketplace.buyIdentity(id, opts));
const bs = buy.state;
// A buy is "in flight" only while the probe/confirm/pay round-trip is active.
// Once it reaches a terminal banner (success/error) the Buy buttons must
// re-enable so the user can buy again. Otherwise `buying` stays set forever —
// `closeBuy` is only reachable from the dialog, which unmounts on success/error
// — and `disabled={buying !== null}` leaves every Buy button permanently
// greyed out after the first attempt (#4196).
const buyInFlight = buying !== null && bs.phase !== 'success' && bs.phase !== 'error';
function startBuy(listing: IdentityListing) {
setBuying(listing);
buy.begin(listing.listingId);
@@ -810,7 +817,7 @@ function TradingTab() {
variant="primary"
size="xs"
className="flex-1"
disabled={buying !== null}
disabled={buyInFlight}
onClick={() => startBuy(listing)}>
Buy
</Button>
@@ -876,7 +883,16 @@ function TradingTab() {
<div
className="mt-3 rounded-md border border-green-500/30 bg-green-500/10 p-3"
data-testid="buy-identity-success">
<p className="text-xs font-medium text-green-500">Purchased {buying.name}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-medium text-green-500">Purchased {buying.name}</p>
<Button
variant="secondary"
size="xs"
onClick={closeBuy}
data-testid="buy-identity-dismiss">
Dismiss
</Button>
</div>
{bs.onChainTx && (
<a
href={buyExplorerTxUrl(bs.onChainTx, bs.network)}
@@ -892,9 +908,18 @@ function TradingTab() {
<div
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 p-3"
data-testid="buy-identity-error">
<p className="text-xs font-medium text-red-500">
{bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-medium text-red-500">
{bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'}
</p>
<Button
variant="secondary"
size="xs"
onClick={closeBuy}
data-testid="buy-identity-dismiss">
Dismiss
</Button>
</div>
<p className="mt-1 text-xs text-content-muted">{bs.message}</p>
</div>
)}