From baa53a80de2d20572ac61de12784daac3a933175 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 22:28:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(discovery):=20=E5=87=BA=E5=93=81=E8=80=85?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=AE=E4=B8=8B=E6=9B=B8=E3=81=8D=E3=83=BB?= =?UTF-8?q?=E5=8F=A4=E3=81=84=20wallet=20=E3=81=AE=E5=AE=8C=E4=BA=86?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=83=BBDELETE=20=E3=81=AE=E4=BE=8B=E5=A4=96?= =?UTF-8?q?=E3=83=BBUSDC=20=E6=A1=88=E5=86=85=E3=81=AE=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E7=9B=B4=E3=81=99=20(B-R10c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 6 回レビュー R10a で固定した AI ストア出品者画面の既存の不具合。(1) 編集中の下書きと editId が wallet 切替・サインアウト後も残る → 消す (完了カード・snippet・通知・USDC 案内・削除確認も)。 一時的な切断 (wallet のロック等で address が一瞬 undefined) では下書きと進行中の結果を保持し、 再接続後の本当のサインアウトで消す。(2) 切替前の wallet の PATCH/DELETE/POST が新しい wallet の 下で成功表示になる → 開始時の wallet を捕まえて画面更新だけ抑止 (catalog と送信元 wallet の owned の無効化は常に行い、A→B→A で掲載が出ない・再送で 409 になるのを防ぐ)。(3) DELETE の fetch 例外を既存のエラー UI に渡し、エラー時は登録欄を自動展開して見えるようにする。 (4) USDC 案内の判定を最新 render ではなく送信時の値で行う。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- components/X402DiscoveryView.tsx | 5 +- .../x402/DiscoveryRegistrationSection.tsx | 2 +- components/x402/useDiscoveryOwner.ts | 70 +++- .../X402DiscoveryView-pinning.test.tsx | 372 +++++++++++++++++- 4 files changed, 408 insertions(+), 41 deletions(-) diff --git a/components/X402DiscoveryView.tsx b/components/X402DiscoveryView.tsx index 8882159a..e3c962e0 100644 --- a/components/X402DiscoveryView.tsx +++ b/components/X402DiscoveryView.tsx @@ -6,8 +6,9 @@ // 本コンポーネントは env.enableX402Facilitator が ON のページからのみマウントされる。 // // 表示は components/x402/ の panel に分けてある (R10a)。状態 (コピー済み表示・展開・カタログの検索/絞り込み・ -// 出品者の下書きと mutation) はここで 1 回だけ持つ: サインイン前後で節の並びが変わり panel が再マウント -// されても状態を失わない。公開カタログ側の leaf は wagmi / SIWE / 出品者専用の部品に依存しない。 +// 出品者の下書きと mutation) はここで 1 回だけ持つ: 節の並び替えによる panel の再マウントと状態を分離する。 +// 出品者の下書き・結果表示は wallet / SIWE の切替で破棄し、接続の瞬断では保つ。 +// 公開カタログ側の leaf は wagmi / SIWE / 出品者専用の部品に依存しない。 import type { ReactNode } from 'react'; import { useAccount } from 'wagmi'; diff --git a/components/x402/DiscoveryRegistrationSection.tsx b/components/x402/DiscoveryRegistrationSection.tsx index 040b8ec4..e047fcdc 100644 --- a/components/x402/DiscoveryRegistrationSection.tsx +++ b/components/x402/DiscoveryRegistrationSection.tsx @@ -74,7 +74,7 @@ export function DiscoveryRegistrationSection({ if (legacyCategory && !formCategories.includes(legacyCategory)) { formCategories.push(legacyCategory); } - const autoOpen = owned.length === 0 || editId !== null || created !== null || notice !== null; + const autoOpen = owned.length === 0 || editId !== null || created !== null || notice !== null || error !== null; // owned>0 のときはフォーム全体を details に畳み、summary が見出しを兼ねる (内側の見出しは出さない)。 const collapsible = owned.length > 0; const registrationContent = ( diff --git a/components/x402/useDiscoveryOwner.ts b/components/x402/useDiscoveryOwner.ts index 3f9ea733..8d2ac263 100644 --- a/components/x402/useDiscoveryOwner.ts +++ b/components/x402/useDiscoveryOwner.ts @@ -2,9 +2,9 @@ // 出品者 (owner) の状態: 登録フォームの下書き・編集対象・結果/エラー表示と、自分の登録一覧 (SIWE 時のみ・ // wallet 単位の query key)・登録/編集/削除の mutation。成功時は公開カタログと owned の両方を invalidate する。 -// X402DiscoveryView が 1 回だけ呼ぶ: 節の並び替えや wallet 切替で下書き・進行中の mutation を失わない。 +// X402DiscoveryView が 1 回だけ呼ぶ: 節の並び替えで状態を失わず、wallet 切替・サインアウトでは下書きを破棄する。 -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { OwnedResource, RegisteredResource } from './discoveryTypes'; @@ -45,6 +45,14 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole const [snippetOpenId, setSnippetOpenId] = useState(null); // 出品の正当性表明 (新規登録のみ必須・編集では不要)。送信成功でリセット。 const [attested, setAttested] = useState(false); + // 旧 wallet の通信完了が現在の下書き・結果表示に波及するのを断つ。 + // 一度離れて同じ wallet に戻った場合も別の操作文脈として扱う。 + const walletScope = useRef({ address, isSignedIn }); + // lock/unlock の一時 disconnect → 自動再接続で下書き・送信結果を失わないため、未接続中は保留する。 + if (address !== undefined && (walletScope.current.address !== address || walletScope.current.isSignedIn !== isSignedIn)) { + walletScope.current = { address, isSignedIn }; + } + const scope = walletScope.current; const queryClient = useQueryClient(); @@ -97,12 +105,22 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole setErrorSnippet(''); }, []); + // 旧 wallet の編集対象・下書き・正当性表明・完了表示・削除確認を別の session に持ち越さない。 + useEffect(() => { + onCancelEdit(); + setAttested(false); + setCreated(null); + setNotice(null); + setUsdcReminder(false); + setConfirmDeleteId(null); + }, [scope, onCancelEdit]); + // 登録 (editId 無し → POST) / 編集 (editId 有り → PATCH) を出し分ける。成功後は catalog / owned を // invalidate して再取得する (従来の void loadCatalog(); void loadOwned(); の置換)。fetch/parse の // 例外・!ok・resource 欠落はいずれも {ok:false} を返し、従来と同じエラー文言 (error コード) を出す。 const submitMutation = useMutation({ mutationFn: async (): Promise< - | { ok: true; wasEdit: boolean; resource: RegisteredResource; paywallSnippet: string } + | { ok: true; wasEdit: boolean; usdcEnabled: boolean; resource: RegisteredResource; paywallSnippet: string } | { ok: false; error: string; paywallSnippet: string } > => { const payload = { @@ -158,6 +176,7 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole return { ok: true, wasEdit: Boolean(editId), + usdcEnabled: form.usdcEnabled, resource: body.resource, paywallSnippet: body.paywallSnippet ?? '', }; @@ -170,16 +189,22 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole setErrorSnippet(''); setNotice(null); setUsdcReminder(false); + return { wallet: walletScope.current }; }, - onSuccess: (result) => { + onSuccess: (result, _variables, onMutateResult) => { + // サーバーで成功した変更は切替後も反映する。別 wallet の owned を余分に取得しない。 + if (result.ok) { + void queryClient.invalidateQueries({ queryKey: ['x402', 'discovery'] }); + void queryClient.invalidateQueries({ queryKey: ['x402', 'owned', onMutateResult.wallet.address] }); + } + if (onMutateResult.wallet !== walletScope.current) return; if (!result.ok) { setError(result.error); setErrorSnippet(result.paywallSnippet); return; } - // 送信時点の form 状態で判定 (成功後は form がリセットされるため onSuccess 内で参照しない - // ように submit 前の値を使う — mutationFn closure の form は submit 時のもの)。 - setUsdcReminder(form.usdcEnabled); + // onSuccess の options は再描画で更新されるため、payload と同じ form から取得した値を使う。 + setUsdcReminder(result.usdcEnabled); if (result.wasEdit) { setNotice('updated'); setCreated(null); @@ -193,28 +218,37 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole setEditId(null); setAttested(false); setFormOpen(null); - void queryClient.invalidateQueries({ queryKey: ['x402', 'discovery'] }); - void queryClient.invalidateQueries({ queryKey: ['x402', 'owned'] }); }, }); - // 無効化 (DELETE)。!ok は {ok:false} を返しエラー文言を出す。fetch 例外は従来どおり握らず - // (エラー表示なし・確認 UI も維持)。成功後は catalog / owned を invalidate して再取得する。 + // 無効化 (DELETE)。通信例外も登録/編集と同じエラー表示に流し、確認 UI から再試行できるようにする。 + // 成功後は catalog / owned を invalidate して再取得する。 const deleteMutation = useMutation({ mutationFn: async (id: string): Promise<{ ok: boolean; error?: string }> => { - const res = await fetch(`/api/facilitator/resources/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string }; - return { ok: false, error: body.error ?? 'error' }; + try { + const res = await fetch(`/api/facilitator/resources/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? 'error' }; + } + return { ok: true }; + } catch { + return { ok: false, error: 'error' }; } - return { ok: true }; }, onMutate: () => { setError(null); setErrorSnippet(''); setNotice(null); + return { wallet: walletScope.current }; }, - onSuccess: (result, id) => { + onSuccess: (result, id, onMutateResult) => { + // 表示の抑止と一覧の鮮度を分離し、送信元 wallet に戻ったときも削除済みの掲載を残さない。 + if (result.ok) { + void queryClient.invalidateQueries({ queryKey: ['x402', 'discovery'] }); + void queryClient.invalidateQueries({ queryKey: ['x402', 'owned', onMutateResult.wallet.address] }); + } + if (onMutateResult.wallet !== walletScope.current) return; if (!result.ok) { setError(result.error ?? 'error'); return; @@ -222,8 +256,6 @@ export function useDiscoveryOwner(address: string | undefined, isSignedIn: boole setConfirmDeleteId(null); if (editId === id) onCancelEdit(); // 編集中の掲載を消したらフォームも閉じる setNotice('deleted'); - void queryClient.invalidateQueries({ queryKey: ['x402', 'discovery'] }); - void queryClient.invalidateQueries({ queryKey: ['x402', 'owned'] }); }, }); diff --git a/tests/components/X402DiscoveryView-pinning.test.tsx b/tests/components/X402DiscoveryView-pinning.test.tsx index a382511b..8c97671c 100644 --- a/tests/components/X402DiscoveryView-pinning.test.tsx +++ b/tests/components/X402DiscoveryView-pinning.test.tsx @@ -9,6 +9,8 @@ import { renderWithIntl } from '../_helpers/i18n'; // R10a (X402DiscoveryView の機械的分割) の網。分割前の component で全 test が通ることを確認してから // 分割する: 全 DOM の hash (サインイン前後・節の並び・編集中)・コピー済み表示と展開状態の共有・ // 認証/アカウント切替を跨ぐ下書きと進行中の mutation・owned/公開カタログ両方の invalidate を固定する。 +// B-R10c: 下書きの破棄と古い mutation の UI 反映抑止・送信元だけの owned 無効化は意図して変更。 +// DOM hash は R10a の値を保つ。 const auth = vi.hoisted(() => ({ address: undefined as string | undefined, @@ -319,7 +321,7 @@ describe('R10a pre-extraction pinning', () => { expect(within(catalogCard()).getByRole('button', { name: '閉じる' })).toHaveAttribute('aria-expanded', 'true'); }); - it('retains edit drafts across account/sign-in changes and resets only on cancel', async () => { + it.each(['wallet switch', 'sign-out'] as const)('clears edit drafts and editId on %s (B-R10c)', async (transition) => { owner(); const pendingB = deferred(); vi.stubGlobal('fetch', vi.fn(async (url: string) => { @@ -330,21 +332,31 @@ describe('R10a pre-extraction pinning', () => { fireEvent.click(await screen.findByRole('button', { name: '編集' })); fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/draft' } }); fireEvent.click(within(screen.getByText('Owned fixture').closest('li')!).getByRole('button', { name: '続きを読む' })); - owner(ADDRESS_B); - view.refresh(); - await waitFor(() => expect(view.qc.isFetching({ queryKey: ['x402', 'owned', ADDRESS_B] })).toBe(1)); - expect(screen.queryByText('Owned fixture')).not.toBeInTheDocument(); - expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue('https://example.com/draft'); - await act(async () => pendingB.resolve(reply({ resources: [OTHER] }))); - await screen.findByText('Other account fixture'); - auth.signedIn = false; - view.refresh(); - expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument(); + if (transition === 'wallet switch') { + owner(ADDRESS_B); + view.refresh(); + await waitFor(() => expect(view.qc.isFetching({ queryKey: ['x402', 'owned', ADDRESS_B] })).toBe(1)); + expect(screen.queryByText('Owned fixture')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); + await act(async () => pendingB.resolve(reply({ resources: [OTHER] }))); + await screen.findByText('Other account fixture'); + } else { + auth.signedIn = false; + view.refresh(); + expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument(); + expect(screen.queryByText('掲載を編集')).not.toBeInTheDocument(); + } owner(); view.refresh(); await screen.findByText('Owned fixture'); - expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue('https://example.com/draft'); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); + expect(screen.queryByRole('button', { name: '更新する' })).not.toBeInTheDocument(); + for (const input of registrationSection().querySelectorAll('input:not([type="checkbox"])')) { + expect(input).toHaveValue(''); + } + expect(screen.getByRole('checkbox', { name: 'USDC (Base) でも販売する — x402 Bazaar に掲載' })).not.toBeChecked(); expect(within(screen.getByText('Owned fixture').closest('li')!).getByRole('button', { name: '閉じる' })).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(screen.getByRole('button', { name: '編集' })); fireEvent.click(screen.getByRole('button', { name: 'キャンセル' })); expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); expect(screen.queryByRole('button', { name: '更新する' })).not.toBeInTheDocument(); @@ -368,7 +380,7 @@ describe('R10a pre-extraction pinning', () => { expect(view.qc.getQueryData(['x402', 'owned', ADDRESS_B])).toEqual([OTHER]); }); - it.each(['POST', 'PATCH', 'DELETE'] as const)('pins in-flight %s wire bytes, feedback and invalidation across wallets', async (method) => { + it.each(['POST', 'PATCH', 'DELETE'] as const)('pins in-flight %s wire bytes, feedback and invalidation for the submitting wallet', async (method) => { owner(); const pendingMutation = deferred(); const pendingCatalog = deferred(); @@ -377,7 +389,7 @@ describe('R10a pre-extraction pinning', () => { const fetchFn = vi.fn(async (url: string, init?: RequestInit) => { if (init?.method) return pendingMutation.promise; if (url === '/api/discovery') return completing ? pendingCatalog.promise : reply({ items: [ITEM] }); - return completing ? pendingOwned.promise : reply({ resources: auth.address === ADDRESS_A ? [OWNED] : [OTHER] }); + return completing ? pendingOwned.promise : reply({ resources: [OWNED] }); }); vi.stubGlobal('fetch', fetchFn); const view = mount(); @@ -404,22 +416,19 @@ describe('R10a pre-extraction pinning', () => { ? '{"url":"https://example.com/new","description":"","priceJpyc":"","category":"","attested":true}' : '{"url":"https://example.com/owned","description":"Owned description","priceJpyc":"101","category":"api","payTo":"0x1111111111111111111111111111111111111111","title":"Owned fixture","docsUrl":"https://example.com/docs","license":"Owned license","usdc":{"priceUsd":"0.02","payTo":"0x1111111111111111111111111111111111111111","serviceName":"Fixture"}}', }]); - owner(ADDRESS_B); - view.refresh(); - await screen.findByText('Other account fixture'); if (method !== 'DELETE') expect(screen.getByRole('button', { name: method === 'POST' ? '登録中…' : '更新中…' })).toBeDisabled(); const invalidate = vi.spyOn(view.qc, 'invalidateQueries'); completing = true; await act(async () => pendingMutation.resolve(reply({ resource: OWNED, paywallSnippet: 'created gate' }))); await waitFor(() => expect(invalidate.mock.calls).toEqual([ - [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned'] }], + [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned', ADDRESS_A] }], ])); expect(view.qc.getQueryState(['x402', 'owned', ADDRESS_A])?.isInvalidated).toBe(true); expect(screen.queryByText('Catalog fixture')).not.toBeInTheDocument(); expect(view.container.querySelectorAll('.animate-pulse')).toHaveLength(2); await act(async () => { pendingCatalog.resolve(reply({ items: [{ ...ITEM, title: 'Refetched catalog' }] })); - pendingOwned.resolve(reply({ resources: [OTHER] })); + pendingOwned.resolve(reply({ resources: [OWNED] })); }); await screen.findByText('Refetched catalog'); expect(screen.getByText(method === 'POST' ? '登録しました。' : method === 'PATCH' ? '更新しました。' : '削除しました。')).toBeInTheDocument(); @@ -740,3 +749,328 @@ describe('R10a pre-extraction pinning', () => { `); }); }); + +// B-R10c: 送信後の React 再描画を挟み、最新の hook options に置き換わっても送信時の文脈を保つ。 +describe('B-R10c owner mutation regressions', () => { + function startMutation(method: 'POST' | 'PATCH' | 'DELETE', usdc = false) { + if (method === 'POST') { + fireEvent.click(screen.getByText('新しい API を出品する')); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/new' } }); + if (usdc) { + fireEvent.click(screen.getByRole('checkbox', { name: 'USDC (Base) でも販売する — x402 Bazaar に掲載' })); + fireEvent.change(screen.getByPlaceholderText('0.005'), { target: { value: '0.02' } }); + } + fireEvent.click(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })); + fireEvent.click(screen.getByRole('button', { name: '登録する' })); + } else if (method === 'PATCH') { + fireEvent.click(screen.getByRole('button', { name: '編集' })); + fireEvent.click(screen.getByRole('button', { name: '更新する' })); + } else { + fireEvent.click(screen.getByRole('button', { name: '削除' })); + fireEvent.click(screen.getByRole('button', { name: '削除する' })); + } + } + + describe.each(['POST', 'PATCH', 'DELETE'] as const)('%s', (method) => { + it.each([ + ['wallet switch', true], ['wallet switch', false], + ['re-sign-in', true], ['re-sign-in', false], + ] as const)('ignores stale UI completion after %s (ok=%s), but refreshes successful writes', async (transition, ok) => { + owner(); + const pending = deferred(); + const fetchFn = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method) return pending.promise; + if (url === '/api/discovery') return reply({ items: [ITEM] }); + return reply({ resources: auth.address === ADDRESS_A ? [OWNED] : [OTHER] }); + }); + vi.stubGlobal('fetch', fetchFn); + const view = mount(); + await screen.findByText('Owned fixture'); + startMutation(method); + await waitFor(() => expect(fetchFn.mock.calls.some(([, init]) => init?.method === method)).toBe(true)); + if (transition === 'wallet switch') owner(ADDRESS_B); + else auth.signedIn = false; + view.refresh(); + if (transition === 're-sign-in') { + // 完了前に再ログインする。完了後だと clearing effect が古い error を消し、guard 欠落を見逃す。 + owner(); + view.refresh(); + } + await screen.findByText(transition === 'wallet switch' ? 'Other account fixture' : 'Owned fixture'); + await waitFor(() => expect(view.qc.isFetching()).toBe(0)); + fireEvent.click(screen.getByRole('button', { name: '編集' })); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/current-draft' } }); + const invalidate = vi.spyOn(view.qc, 'invalidateQueries'); + const callsBeforeCompletion = fetchFn.mock.calls.length; + await act(async () => pending.resolve(reply(ok + ? { resource: OWNED, paywallSnippet: 'old wallet success snippet' } + : { error: 'gate_not_openpay', paywallSnippet: 'old wallet error snippet' }, ok))); + await waitFor(() => expect(view.qc.isMutating()).toBe(0)); + await waitFor(() => expect(view.qc.isFetching()).toBe(0)); + expect(invalidate.mock.calls).toEqual(ok ? [ + [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned', ADDRESS_A] }], + ] : []); + // B の active owned は再取得しない。A に戻っていれば catalog と A の owned の両方を再取得する。 + expect(fetchFn.mock.calls.slice(callsBeforeCompletion).map(([url]) => url)).toEqual( + !ok ? [] : transition === 'wallet switch' ? ['/api/discovery'] : ['/api/discovery', '/api/facilitator/resources'], + ); + if (transition === 'wallet switch') { + expect(view.qc.getQueryState(['x402', 'owned', ADDRESS_A])?.isInvalidated).toBe(ok); + expect(view.qc.getQueryState(['x402', 'owned', ADDRESS_B])?.isInvalidated).toBe(false); + } + expect(screen.queryByText(/^(登録しました。|更新しました。|削除しました。)$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/操作に失敗しました/)).not.toBeInTheDocument(); + expect(screen.queryByText('old wallet success snippet')).not.toBeInTheDocument(); + expect(screen.queryByText('old wallet error snippet')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue('https://example.com/current-draft'); + expect(screen.getByRole('button', { name: '更新する' })).toBeEnabled(); + }); + }); + + it('shows the existing error UI for a rejected DELETE fetch and permits retry', async () => { + owner(); + let attempts = 0; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'DELETE') { + if (++attempts === 1) throw new TypeError('Failed to fetch'); + return reply({ ok: true }); + } + return reply(url === '/api/discovery' ? { items: [ITEM] } : { resources: [OWNED] }); + })); + const view = mount(); + await screen.findByText('Owned fixture'); + const invalidate = vi.spyOn(view.qc, 'invalidateQueries'); + startMutation('DELETE'); + expect(await screen.findByText('操作に失敗しました (error)。')).toBeVisible(); + expect(invalidate).not.toHaveBeenCalled(); + expect(screen.queryByText('削除しました。')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '削除する' })); + await screen.findByText('削除しました。'); + expect(screen.queryByText('操作に失敗しました (error)。')).not.toBeInTheDocument(); + expect(attempts).toBe(2); + expect(invalidate.mock.calls).toEqual([ + [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned', ADDRESS_A] }], + ]); + }); + + describe.each(['POST', 'PATCH', 'DELETE'] as const)('completed %s feedback', (method) => { + it.each(['wallet switch', 'sign-out'] as const)('clears success, snippet and reminder on %s', async (transition) => { + owner(); + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method) return reply({ resource: OWNED, paywallSnippet: 'completed A gate' }); + if (url === '/api/discovery') return reply({ items: [ITEM] }); + return reply({ resources: auth.address === ADDRESS_A ? [OWNED] : [OTHER] }); + })); + const view = mount(); + await screen.findByText('Owned fixture'); + startMutation(method, true); + const success = method === 'POST' ? '登録しました。' : method === 'PATCH' ? '更新しました。' : '削除しました。'; + expect(await screen.findByText(success)).toBeVisible(); + if (method === 'POST') expect(screen.getByText('completed A gate')).toBeVisible(); + if (method !== 'DELETE') expect(screen.getByText(/^USDC で販売するには、サーバーのゲートを/)).toBeVisible(); + await waitFor(() => expect(view.qc.isFetching()).toBe(0)); + + if (transition === 'wallet switch') owner(ADDRESS_B); + else auth.signedIn = false; + view.refresh(); + if (transition === 'wallet switch') await screen.findByText('Other account fixture'); + expect(screen.queryByText(success)).not.toBeInTheDocument(); + expect(screen.queryByText('completed A gate')).not.toBeInTheDocument(); + expect(screen.queryByText(/^USDC で販売するには、サーバーのゲートを/)).not.toBeInTheDocument(); + owner(); + view.refresh(); + await screen.findByText('Owned fixture'); + expect(screen.queryByText(success)).not.toBeInTheDocument(); + expect(screen.queryByText('completed A gate')).not.toBeInTheDocument(); + expect(screen.queryByText(/^USDC で販売するには、サーバーのゲートを/)).not.toBeInTheDocument(); + }); + }); + + it.each(['wallet switch', 'sign-out'] as const)('clears attestation, new draft and delete confirmation across %s and return', async (transition) => { + owner(); + vi.stubGlobal('fetch', vi.fn(async (url: string) => reply(url === '/api/discovery' + ? { items: [ITEM] } : { resources: auth.address === ADDRESS_A ? [OWNED] : [OTHER] }))); + const view = mount(); + await screen.findByText('Owned fixture'); + fireEvent.click(screen.getByText('新しい API を出品する')); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/unsubmitted' } }); + fireEvent.click(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })); + expect(screen.getByRole('button', { name: '登録する' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '削除' })); + expect(screen.getByRole('button', { name: '削除する' })).toBeVisible(); + if (transition === 'wallet switch') owner(ADDRESS_B); + else auth.signedIn = false; + view.refresh(); + if (transition === 'wallet switch') { + await screen.findByText('Other account fixture'); + expect(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })).not.toBeChecked(); + } + owner(); + view.refresh(); + await screen.findByText('Owned fixture'); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); + expect(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })).not.toBeChecked(); + expect(screen.getByRole('button', { name: '登録する' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '削除する' })).not.toBeInTheDocument(); + }); + + it('keeps the edit draft through wallet lock and unlock with the same SIWE session', async () => { + owner(); + const view = mount(); + fireEvent.click(await screen.findByRole('button', { name: '編集' })); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/locked-draft' } }); + Object.assign(auth, { address: undefined, connected: false, signedIn: false }); + view.refresh(); + expect(screen.getByRole('button', { name: 'Connect fixture' })).toBeVisible(); + expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument(); + owner(); + view.refresh(); + await screen.findByText('Owned fixture'); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue('https://example.com/locked-draft'); + expect(screen.getByRole('button', { name: '更新する' })).toBeVisible(); + expect(screen.getByRole('checkbox', { name: 'USDC (Base) でも販売する — x402 Bazaar に掲載' })).toBeChecked(); + }); + + it.each([ADDRESS_A, ADDRESS_B])('clears the draft when reconnecting %s without a matching SIWE session', async (address) => { + owner(); + const view = mount(); + fireEvent.click(await screen.findByRole('button', { name: '編集' })); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/signed-out-draft' } }); + Object.assign(auth, { address: undefined, connected: false, signedIn: false }); + view.refresh(); + Object.assign(auth, { address, connected: true, signedIn: false }); + view.refresh(); + expect(screen.queryByText('掲載を編集')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'ウォレットでサインイン' })).toBeInTheDocument(); + owner(); + view.refresh(); + await screen.findByText('Owned fixture'); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); + expect(screen.queryByRole('button', { name: '更新する' })).not.toBeInTheDocument(); + }); + + it.each([ + ['POST', 'while locked'], ['POST', 'after unlock'], + ['PATCH', 'while locked'], ['PATCH', 'after unlock'], + ] as const)('keeps the in-flight %s result completed %s', async (method, completion) => { + owner(); + const pending = deferred(); + const fetchFn = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method) return pending.promise; + return reply(url === '/api/discovery' ? { items: [ITEM] } : { resources: [OWNED] }); + }); + vi.stubGlobal('fetch', fetchFn); + const view = mount(); + await screen.findByText('Owned fixture'); + startMutation(method, true); + await waitFor(() => expect(fetchFn.mock.calls.some(([, init]) => init?.method === method)).toBe(true)); + Object.assign(auth, { address: undefined, connected: false, signedIn: false }); + view.refresh(); + expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument(); + if (completion === 'after unlock') { + owner(); + view.refresh(); + } + const invalidate = vi.spyOn(view.qc, 'invalidateQueries'); + await act(async () => pending.resolve(reply({ resource: OWNED, paywallSnippet: 'locked gate' }))); + await waitFor(() => expect(view.qc.isMutating()).toBe(0)); + if (completion === 'while locked') { + owner(); + view.refresh(); + } + expect(await screen.findByText(method === 'POST' ? '登録しました。' : '更新しました。')).toBeVisible(); + expect(screen.getByText(/^USDC で販売するには、サーバーのゲートを/)).toBeVisible(); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue(''); + if (method === 'POST') expect(screen.getByText('locked gate')).toBeVisible(); + expect(invalidate.mock.calls).toEqual([ + [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned', ADDRESS_A] }], + ]); + }); + + it('refreshes a completed POST after A→B→A without resetting a new draft, and resubmits the listing as PATCH without 409', async () => { + owner(); + const pending = deferred(); + const registered = { ...OWNED, id: 'registered-a', title: 'New A listing', url: 'https://example.com/new', hidden: false }; + const resources = [OWNED]; + const fetchFn = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'POST') { + const payload = JSON.parse(String(init.body)); + if (resources.some((resource) => resource.url === payload.url)) { + return new Response(JSON.stringify({ error: 'url_taken' }), { status: 409 }); + } + return pending.promise; + } + if (init?.method === 'PATCH') return reply({ resource: registered }); + if (url === '/api/discovery') return reply({ items: [ITEM] }); + return reply({ resources: auth.address === ADDRESS_A ? [...resources] : [OTHER] }); + }); + vi.stubGlobal('fetch', fetchFn); + const view = mount(); + await screen.findByText('Owned fixture'); + startMutation('POST', true); + await waitFor(() => expect(fetchFn.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true)); + owner(ADDRESS_B); + view.refresh(); + await screen.findByText('Other account fixture'); + owner(); + view.refresh(); + await screen.findByText('Owned fixture'); + await waitFor(() => expect(view.qc.isFetching()).toBe(0)); + fireEvent.click(screen.getByText('新しい API を出品する')); + fireEvent.change(screen.getByPlaceholderText(URL_PLACEHOLDER), { target: { value: 'https://example.com/next-draft' } }); + fireEvent.click(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })); + const invalidate = vi.spyOn(view.qc, 'invalidateQueries'); + resources.push(registered); + await act(async () => pending.resolve(reply({ resource: registered, paywallSnippet: 'old A result gate' }))); + await waitFor(() => expect(view.qc.isMutating()).toBe(0)); + await screen.findByText('New A listing'); + expect(screen.queryByText('登録しました。')).not.toBeInTheDocument(); + expect(screen.queryByText('old A result gate')).not.toBeInTheDocument(); + expect(screen.queryByText(/^USDC で販売するには、サーバーのゲートを/)).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toHaveValue('https://example.com/next-draft'); + expect(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })).toBeChecked(); + expect(invalidate.mock.calls).toEqual([ + [{ queryKey: ['x402', 'discovery'] }], [{ queryKey: ['x402', 'owned', ADDRESS_A] }], + ]); + // owned に反映された掲載を編集できるので、同じ URL を再 POST して url_taken にする必要がない。 + fireEvent.click(within(screen.getByText('New A listing').closest('li')!).getByRole('button', { name: '編集' })); + fireEvent.click(screen.getByRole('button', { name: '更新する' })); + await screen.findByText('更新しました。'); + expect(fetchFn.mock.calls.filter(([, init]) => init?.method).map(([url, init]) => [url, init?.method])).toEqual([ + ['/api/facilitator/resources', 'POST'], ['/api/facilitator/resources/registered-a', 'PATCH'], + ]); + expect(screen.queryByText(/操作に失敗しました/)).not.toBeInTheDocument(); + }); + + it.each([ + ['POST', true], ['POST', false], ['PATCH', true], ['PATCH', false], + ] as const)('uses submitted USDC enabled=%s/%s for the reminder after toggling during fetch', async (method, submittedUsdc) => { + owner(); + const pending = deferred(); + const fetchFn = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method) return pending.promise; + return reply(url === '/api/discovery' ? { items: [ITEM] } : { resources: [OWNED] }); + }); + vi.stubGlobal('fetch', fetchFn); + const view = mount(); + await screen.findByText('Owned fixture'); + if (method === 'PATCH') fireEvent.click(screen.getByRole('button', { name: '編集' })); + else { + fireEvent.click(screen.getByText('新しい API を出品する')); + fireEvent.click(screen.getByRole('checkbox', { name: '正当な権利と支払い制限を確認しました' })); + } + const checkbox = screen.getByRole('checkbox', { name: 'USDC (Base) でも販売する — x402 Bazaar に掲載' }); + if ((checkbox as HTMLInputElement).checked !== submittedUsdc) fireEvent.click(checkbox); + if (submittedUsdc) fireEvent.change(screen.getByPlaceholderText('0.005'), { target: { value: '0.02' } }); + fireEvent.click(screen.getByRole('button', { name: method === 'POST' ? '登録する' : '更新する' })); + await waitFor(() => expect(fetchFn.mock.calls.some(([, init]) => init?.method === method)).toBe(true)); + const body = JSON.parse(String(fetchFn.mock.calls.find(([, init]) => init?.method === method)![1]!.body)); + expect(Boolean(body.usdc)).toBe(submittedUsdc); + fireEvent.click(checkbox); + expect((checkbox as HTMLInputElement).checked).toBe(!submittedUsdc); + await act(async () => pending.resolve(reply({ resource: OWNED, paywallSnippet: 'gate' }))); + await screen.findByText(method === 'POST' ? '登録しました。' : '更新しました。'); + await waitFor(() => expect(view.qc.isMutating()).toBe(0)); + expect(Boolean(screen.queryByText(/^USDC で販売するには、サーバーのゲートを/))).toBe(submittedUsdc); + }); +});