diff --git a/hooks/useHostedStorePurchase.ts b/hooks/useHostedStorePurchase.ts index 2022f755..357a5734 100644 --- a/hooks/useHostedStorePurchase.ts +++ b/hooks/useHostedStorePurchase.ts @@ -38,6 +38,26 @@ import { normalizeHostedUsdcPaymentRequired, type HostedUsdcPurchaseQuote, } from '@/lib/x402/hostedUsdcPurchaseWire'; +import { + accessStatusForPhase, + contentReadBackFrom, + isRecord, + paymentStatusForPhase, + purchaseStatusFrom, + responseJson, + type HostedProvidedEnded, + type HostedPurchasedContent, + type HostedStorePurchasePhase, + type PurchaseStatusResponse, +} from '@/lib/x402/hostedPurchasePhases'; + +export type { + HostedProvidedEnded, + HostedPurchasedContent, + HostedStoreAccessStatus, + HostedStorePaymentStatus, + HostedStorePurchasePhase, +} from '@/lib/x402/hostedPurchasePhases'; // status route は 30 req/min/IP。初回即時 fetch を含めても limiter を踏まない余白を持たせる。 const STATUS_POLL_INTERVAL_MS = 3_000; @@ -50,52 +70,6 @@ export type HostedStorePurchaseQuote = | HostedPurchaseQuote | HostedUsdcPurchaseQuote; -export type HostedStorePurchasePhase = - | 'idle' - | 'loading-quote' - | 'review' - | 'signing' - | 'submitting' - | 'indeterminate' - | 'indeterminate-exhausted' - | 'provisioning' - | 'ready' - | 'needs-support' - | 'failed-prebroadcast' - | 'error'; - -export type HostedStorePaymentStatus = - | 'not-started' - | 'not-executed' - | 'unknown' - | 'confirmed'; - -export type HostedStoreAccessStatus = - | 'none' - | 'provisioning' - | 'ready' - | 'needs-support'; - -export type HostedPurchasedContent = { - ok: true; - state: 'ready'; - resourceId: string; - intentSalt: Hex; - title: string; - contentRevision: number; - kind: 'url' | 'text'; - value: string; -}; - -export type HostedProvidedEnded = { - ok: true; - state: 'provided-ended'; - resourceId: string; - intentSalt: Hex; - title: string; - contentRevision: number; -}; - export type HostedStoreNeedsSupportReason = | 'provided-ended' | 'ownership-readback-timeout' @@ -131,11 +105,6 @@ export class HostedStorePurchaseError extends Error { } } -type PurchaseStatusResponse = - | { ok: true; state: 'pending' } - | { ok: true; state: 'failed' } - | { ok: true; state: 'settled'; txHash: Hex }; - type ContentReadBack = | { state: 'pending' } | { state: 'timed-out' } @@ -190,119 +159,6 @@ function sameAddress( ); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -async function responseJson(response: Response): Promise { - try { - return await response.json(); - } catch { - return null; - } -} - -function purchaseStatusFrom(value: unknown): PurchaseStatusResponse { - if (!isRecord(value) || value.ok !== true) { - throw new Error('purchase_status_invalid'); - } - if (value.state === 'pending' || value.state === 'failed') { - return { ok: true, state: value.state }; - } - if ( - value.state === 'settled' && - typeof value.txHash === 'string' && - /^0x[0-9a-fA-F]{64}$/.test(value.txHash) - ) { - return { - ok: true, - state: 'settled', - txHash: value.txHash as Hex, - }; - } - throw new Error('purchase_status_invalid'); -} - -function contentReadBackFrom( - value: unknown, - resourceId: string, - intentSalt: Hex, -): HostedPurchasedContent | HostedProvidedEnded { - if ( - !isRecord(value) || - value.ok !== true || - value.resourceId !== resourceId || - typeof value.intentSalt !== 'string' || - value.intentSalt.toLowerCase() !== intentSalt.toLowerCase() || - typeof value.title !== 'string' || - !Number.isSafeInteger(value.contentRevision) || - (value.contentRevision as number) <= 0 - ) { - throw new Error('store_content_invalid'); - } - if (value.state === 'provided-ended') { - return { - ok: true, - state: 'provided-ended', - resourceId, - intentSalt, - title: value.title, - contentRevision: value.contentRevision as number, - }; - } - if ( - value.state === 'ready' && - (value.kind === 'url' || value.kind === 'text') && - typeof value.value === 'string' - ) { - return { - ok: true, - state: 'ready', - resourceId, - intentSalt, - title: value.title, - contentRevision: value.contentRevision as number, - kind: value.kind, - value: value.value, - }; - } - throw new Error('store_content_invalid'); -} - -function paymentStatusForPhase( - phase: HostedStorePurchasePhase, -): HostedStorePaymentStatus { - if ( - phase === 'provisioning' || - phase === 'ready' || - phase === 'needs-support' - ) { - return 'confirmed'; - } - if (phase === 'submitting' || phase === 'indeterminate') return 'unknown'; - if (phase === 'indeterminate-exhausted') return 'unknown'; - if (phase === 'failed-prebroadcast' || phase === 'error') { - return 'not-executed'; - } - return 'not-started'; -} - -function accessStatusForPhase( - phase: HostedStorePurchasePhase, -): HostedStoreAccessStatus { - if (phase === 'ready') return 'ready'; - if (phase === 'needs-support') return 'needs-support'; - if (phase === 'indeterminate-exhausted') return 'needs-support'; - if ( - phase === 'submitting' || - phase === 'indeterminate' || - phase === 'provisioning' - ) { - return 'provisioning'; - } - return 'none'; -} - export function useHostedStorePurchase({ resourceId, title, diff --git a/lib/x402/hostedPurchasePhases.ts b/lib/x402/hostedPurchasePhases.ts new file mode 100644 index 00000000..8fc06aa8 --- /dev/null +++ b/lib/x402/hostedPurchasePhases.ts @@ -0,0 +1,173 @@ +// Creator Store hosted 商品の buyer hook (useHostedStorePurchase) が使う純粋な応答 parser と +// phase 対応表 (R15b で hook から抽出)。React・wagmi・env に依存しない browser-safe module。 +// +// session/product scope・署名済み request の保持・古い完了の破棄は hook (単一 controller) に残す。 +// ここにあるのは入力だけで結果が決まる関数と、その戻り値の型だけ。 +// isRecord / responseJson は hosted 購入の応答解釈のための最小限の helper で、汎用 utility ではない +// (JSON の null 本文を非 JSON と同じに扱う等の癖をそのまま持つ)。他所からは使わない。 + +import type { Hex } from 'viem'; + +export type HostedStorePurchasePhase = + | 'idle' + | 'loading-quote' + | 'review' + | 'signing' + | 'submitting' + | 'indeterminate' + | 'indeterminate-exhausted' + | 'provisioning' + | 'ready' + | 'needs-support' + | 'failed-prebroadcast' + | 'error'; + +export type HostedStorePaymentStatus = + | 'not-started' + | 'not-executed' + | 'unknown' + | 'confirmed'; + +export type HostedStoreAccessStatus = + | 'none' + | 'provisioning' + | 'ready' + | 'needs-support'; + +export type HostedPurchasedContent = { + ok: true; + state: 'ready'; + resourceId: string; + intentSalt: Hex; + title: string; + contentRevision: number; + kind: 'url' | 'text'; + value: string; +}; + +export type HostedProvidedEnded = { + ok: true; + state: 'provided-ended'; + resourceId: string; + intentSalt: Hex; + title: string; + contentRevision: number; +}; + +export type PurchaseStatusResponse = + | { ok: true; state: 'pending' } + | { ok: true; state: 'failed' } + | { ok: true; state: 'settled'; txHash: Hex }; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export async function responseJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +export function purchaseStatusFrom(value: unknown): PurchaseStatusResponse { + if (!isRecord(value) || value.ok !== true) { + throw new Error('purchase_status_invalid'); + } + if (value.state === 'pending' || value.state === 'failed') { + return { ok: true, state: value.state }; + } + if ( + value.state === 'settled' && + typeof value.txHash === 'string' && + /^0x[0-9a-fA-F]{64}$/.test(value.txHash) + ) { + return { + ok: true, + state: 'settled', + txHash: value.txHash as Hex, + }; + } + throw new Error('purchase_status_invalid'); +} + +export function contentReadBackFrom( + value: unknown, + resourceId: string, + intentSalt: Hex, +): HostedPurchasedContent | HostedProvidedEnded { + if ( + !isRecord(value) || + value.ok !== true || + value.resourceId !== resourceId || + typeof value.intentSalt !== 'string' || + value.intentSalt.toLowerCase() !== intentSalt.toLowerCase() || + typeof value.title !== 'string' || + !Number.isSafeInteger(value.contentRevision) || + (value.contentRevision as number) <= 0 + ) { + throw new Error('store_content_invalid'); + } + if (value.state === 'provided-ended') { + return { + ok: true, + state: 'provided-ended', + resourceId, + intentSalt, + title: value.title, + contentRevision: value.contentRevision as number, + }; + } + if ( + value.state === 'ready' && + (value.kind === 'url' || value.kind === 'text') && + typeof value.value === 'string' + ) { + return { + ok: true, + state: 'ready', + resourceId, + intentSalt, + title: value.title, + contentRevision: value.contentRevision as number, + kind: value.kind, + value: value.value, + }; + } + throw new Error('store_content_invalid'); +} + +export function paymentStatusForPhase( + phase: HostedStorePurchasePhase, +): HostedStorePaymentStatus { + if ( + phase === 'provisioning' || + phase === 'ready' || + phase === 'needs-support' + ) { + return 'confirmed'; + } + if (phase === 'submitting' || phase === 'indeterminate') return 'unknown'; + if (phase === 'indeterminate-exhausted') return 'unknown'; + if (phase === 'failed-prebroadcast' || phase === 'error') { + return 'not-executed'; + } + return 'not-started'; +} + +export function accessStatusForPhase( + phase: HostedStorePurchasePhase, +): HostedStoreAccessStatus { + if (phase === 'ready') return 'ready'; + if (phase === 'needs-support') return 'needs-support'; + if (phase === 'indeterminate-exhausted') return 'needs-support'; + if ( + phase === 'submitting' || + phase === 'indeterminate' || + phase === 'provisioning' + ) { + return 'provisioning'; + } + return 'none'; +} diff --git a/tests/_helpers/hostedPurchaseResponseTables.ts b/tests/_helpers/hostedPurchaseResponseTables.ts new file mode 100644 index 00000000..4934fbe4 --- /dev/null +++ b/tests/_helpers/hostedPurchaseResponseTables.ts @@ -0,0 +1,316 @@ +// useHostedStorePurchase の応答 parser / phase 対応表の固定用テーブル (R15b)。 +// 純粋 module の単体テストと、hook 経由のテスト (抽出前の旧コードでも走る) の両方が +// 同じ入力集合を使うことで、抽出前後で同じ事例を検査していることを保証する。 +import type { Hex } from 'viem'; + +export const TABLE_RESOURCE_ID = 'h_fixture'; +export const TABLE_INTENT_SALT = `0x${'ab'.repeat(32)}` as Hex; +export const TABLE_TX_HASH = `0x${'ef'.repeat(32)}` as Hex; +const UPPER_TX_HASH = `0x${'EF'.repeat(32)}` as Hex; + +export type PurchaseStatusCase = { + label: string; + body: unknown; + /** 正常時の戻り値。null なら 'purchase_status_invalid' を throw する。 */ + expected: + | { ok: true; state: 'pending' } + | { ok: true; state: 'failed' } + | { ok: true; state: 'settled'; txHash: Hex } + | null; +}; + +export const PURCHASE_STATUS_CASES: PurchaseStatusCase[] = [ + { + label: 'pending', + body: { ok: true, state: 'pending' }, + expected: { ok: true, state: 'pending' }, + }, + { + label: 'pending (余分な field は落とす)', + body: { ok: true, state: 'pending', txHash: TABLE_TX_HASH, extra: 1 }, + expected: { ok: true, state: 'pending' }, + }, + { + label: 'failed', + body: { ok: true, state: 'failed' }, + expected: { ok: true, state: 'failed' }, + }, + { + label: 'settled', + body: { ok: true, state: 'settled', txHash: TABLE_TX_HASH }, + expected: { ok: true, state: 'settled', txHash: TABLE_TX_HASH }, + }, + { + label: 'settled (大文字 hex も受理・そのまま返す)', + body: { ok: true, state: 'settled', txHash: UPPER_TX_HASH, extra: 'x' }, + expected: { ok: true, state: 'settled', txHash: UPPER_TX_HASH }, + }, + { + label: 'settled で txHash 欠落', + body: { ok: true, state: 'settled' }, + expected: null, + }, + { + label: 'settled で txHash が短い', + body: { ok: true, state: 'settled', txHash: `0x${'ef'.repeat(31)}` }, + expected: null, + }, + { + label: 'settled で txHash が 0x なし', + body: { ok: true, state: 'settled', txHash: 'ef'.repeat(32) }, + expected: null, + }, + { + label: 'settled で txHash が非 hex', + body: { ok: true, state: 'settled', txHash: `0x${'zz'.repeat(32)}` }, + expected: null, + }, + { + label: 'ok:false', + body: { ok: false, state: 'settled', txHash: TABLE_TX_HASH }, + expected: null, + }, + { + label: "ok:'true' (文字列)", + body: { ok: 'true', state: 'pending' }, + expected: null, + }, + { + label: '未知の state', + body: { ok: true, state: 'unexpected' }, + expected: null, + }, + { + label: 'state 欠落', + body: { ok: true }, + expected: null, + }, + { label: 'null', body: null, expected: null }, + { label: '配列', body: [{ ok: true, state: 'pending' }], expected: null }, + { label: '文字列', body: 'pending', expected: null }, +]; + +type ContentReady = { + ok: true; + state: 'ready'; + resourceId: string; + intentSalt: Hex; + title: string; + contentRevision: number; + kind: 'url' | 'text'; + value: string; +}; + +type ContentEnded = { + ok: true; + state: 'provided-ended'; + resourceId: string; + intentSalt: Hex; + title: string; + contentRevision: number; +}; + +export type ContentReadBackCase = { + label: string; + body: unknown; + /** 正常時の戻り値。null なら 'store_content_invalid' を throw する。 */ + expected: ContentReady | ContentEnded | null; +}; + +function readyBody( + overrides: Record = {}, +): Record { + return { + ok: true, + state: 'ready', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + kind: 'text', + value: 'paid content', + ...overrides, + }; +} + +function withoutKey( + body: Record, + key: string, +): Record { + const copy = { ...body }; + delete copy[key]; + return copy; +} + +export const CONTENT_READBACK_CASES: ContentReadBackCase[] = [ + { + label: 'ready text', + body: readyBody(), + expected: { + ok: true, + state: 'ready', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + kind: 'text', + value: 'paid content', + }, + }, + { + label: 'ready url (余分な field は落とす)', + body: readyBody({ kind: 'url', value: 'https://example.com/a', extra: 1 }), + expected: { + ok: true, + state: 'ready', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + kind: 'url', + value: 'https://example.com/a', + }, + }, + { + label: 'intentSalt は大文字小文字を無視して照合し、引数側の値を返す', + body: readyBody({ intentSalt: `0x${'AB'.repeat(32)}` }), + expected: { + ok: true, + state: 'ready', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + kind: 'text', + value: 'paid content', + }, + }, + { + label: 'ready で value 空文字は受理', + body: readyBody({ value: '' }), + expected: { + ok: true, + state: 'ready', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + kind: 'text', + value: '', + }, + }, + { + label: 'provided-ended', + body: withoutKey( + withoutKey(readyBody({ state: 'provided-ended' }), 'kind'), + 'value', + ), + expected: { + ok: true, + state: 'provided-ended', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + }, + }, + { + label: 'provided-ended は kind/value があっても落とす', + body: readyBody({ state: 'provided-ended', kind: 'html', value: 1 }), + expected: { + ok: true, + state: 'provided-ended', + resourceId: TABLE_RESOURCE_ID, + intentSalt: TABLE_INTENT_SALT, + title: 'Fixture product', + contentRevision: 3, + }, + }, + { + label: '別商品の resourceId', + body: readyBody({ resourceId: 'h_other' }), + expected: null, + }, + { + label: '過去購入の intentSalt', + body: readyBody({ intentSalt: `0x${'12'.repeat(32)}` }), + expected: null, + }, + { + label: 'intentSalt 非文字列', + body: readyBody({ intentSalt: 1 }), + expected: null, + }, + { + label: 'title 欠落', + body: withoutKey(readyBody(), 'title'), + expected: null, + }, + { + label: 'contentRevision 0', + body: readyBody({ contentRevision: 0 }), + expected: null, + }, + { + label: 'contentRevision 負', + body: readyBody({ contentRevision: -1 }), + expected: null, + }, + { + label: 'contentRevision 小数', + body: readyBody({ contentRevision: 1.5 }), + expected: null, + }, + { + label: 'contentRevision 文字列', + body: readyBody({ contentRevision: '1' }), + expected: null, + }, + { + label: 'contentRevision が safe integer 超え', + body: readyBody({ contentRevision: 2 ** 53 }), + expected: null, + }, + { + label: 'ready で未知の kind', + body: readyBody({ kind: 'html' }), + expected: null, + }, + { + label: 'ready で value 非文字列', + body: readyBody({ value: 1 }), + expected: null, + }, + { + label: 'ready で value 欠落', + body: withoutKey(readyBody(), 'value'), + expected: null, + }, + { + label: '未知の state', + body: readyBody({ state: 'pending' }), + expected: null, + }, + { + label: 'ok:false', + body: readyBody({ ok: false }), + expected: null, + }, + { label: 'null', body: null, expected: null }, + { label: '配列', body: [readyBody()], expected: null }, +]; + +export const PHASE_STATUS_TABLE = [ + ['idle', 'not-started', 'none'], + ['loading-quote', 'not-started', 'none'], + ['review', 'not-started', 'none'], + ['signing', 'not-started', 'none'], + ['submitting', 'unknown', 'provisioning'], + ['indeterminate', 'unknown', 'provisioning'], + ['indeterminate-exhausted', 'unknown', 'needs-support'], + ['provisioning', 'confirmed', 'provisioning'], + ['ready', 'confirmed', 'ready'], + ['needs-support', 'confirmed', 'needs-support'], + ['failed-prebroadcast', 'not-executed', 'none'], + ['error', 'not-executed', 'none'], +] as const; diff --git a/tests/hooks/useHostedStorePurchase.pins.test.tsx b/tests/hooks/useHostedStorePurchase.pins.test.tsx new file mode 100644 index 00000000..dfff1125 --- /dev/null +++ b/tests/hooks/useHostedStorePurchase.pins.test.tsx @@ -0,0 +1,797 @@ +import type { ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Address, Hex } from 'viem'; +import { + CONTENT_READBACK_CASES, + PHASE_STATUS_TABLE, + PURCHASE_STATUS_CASES, + TABLE_INTENT_SALT, + TABLE_RESOURCE_ID, + TABLE_TX_HASH, +} from '../_helpers/hostedPurchaseResponseTables'; + +// R15b (parser / phase 対応表の抽出) の前後で hook の観測挙動が変わらないことを固定する。 +// 旧コード (抽出前) でもそのまま走るよう、hook の公開 API だけを経由して検査する。 + +const FORWARDER = + '0x752B7AaD0089286EB7b553d84D05233d80c9FCB4' as Address; +const MERCHANT = + '0x2222222222222222222222222222222222222222' as Address; +const FEE_RECEIVER = + '0x3333333333333333333333333333333333333333' as Address; +const PAYER = + '0x1111111111111111111111111111111111111111' as Address; +const OTHER = + '0x4444444444444444444444444444444444444444' as Address; +// 大文字小文字の差だけで scope が切り替わらないことを見るため、英字を含む checksum address。 +const MIXED_MERCHANT = + '0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD' as Address; +const MIXED_PAYER = + '0xfeDcbaFEdcBaFEDcbAfedcBAfeDCBAFeDCBafEdc' as Address; +const JPYC = + '0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29' as Address; +const SIGNATURE = `0x${'cd'.repeat(65)}` as Hex; +const RESOURCE_ID = TABLE_RESOURCE_ID; +const INTENT_SALT = TABLE_INTENT_SALT; +const TX_HASH = TABLE_TX_HASH; +const TITLE = 'Fixture product'; +const PRICE_JPYC = '1200'; +const PRICE = 1_200n * 10n ** 18n; +const FEE = 12n * 10n ** 18n; + +const account = vi.hoisted(() => ({ + address: '0x1111111111111111111111111111111111111111' as + | Address + | undefined, + chainId: 80002 as number | undefined, +})); +const signTypedData = vi.hoisted(() => vi.fn()); + +vi.mock('wagmi', () => ({ + useAccount: () => ({ + address: account.address, + chainId: account.chainId, + }), + useWalletClient: () => ({ + data: { + signTypedData, + }, + }), +})); + +vi.mock('@/lib/env', () => ({ + env: { + networkEnv: 'testnet', + enableLicenseNftUi: false, + feeReceiver: + '0x3333333333333333333333333333333333333333', + feeReceiverConfigured: true, + }, +})); + +vi.mock('@/lib/relay/forwarderConfig', () => ({ + configuredJpycForwarderFor: () => + '0x752B7AaD0089286EB7b553d84D05233d80c9FCB4', +})); + +import { FORWARDER_COMMIT_VERSION } from '@/lib/relay/forwarderIntent'; +import { + HostedStorePurchaseError, + useHostedStorePurchase, +} from '@/hooks/useHostedStorePurchase'; + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function paymentRequired( + resourceId = RESOURCE_ID, + merchant: Address = MERCHANT, + payer: Address = PAYER, +): Record { + return { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: 'eip155:80002', + maxAmountRequired: (PRICE + FEE).toString(), + resource: `https://open-pay.jp/api/paid/hosted/${resourceId}?payer=${payer}`, + description: TITLE, + mimeType: 'application/json', + payTo: FORWARDER, + maxTimeoutSeconds: 600, + asset: JPYC, + extra: { + name: 'JPY Coin', + version: '1', + decimals: 18, + assetTransferMethod: 'eip3009', + openpay: { + mode: 'forwarder-split', + forwarder: FORWARDER, + merchant, + merchantValue: PRICE.toString(), + feeReceiver: FEE_RECEIVER, + feeValue: FEE.toString(), + commitVersion: FORWARDER_COMMIT_VERSION, + intentSalt: INTENT_SALT, + authorizationValidBeforeMax: '2000000000', + }, + }, + }, + ], + error: 'payment_required', + }; +} + +function settledPaidBody(resourceId = RESOURCE_ID): Record { + return { + ok: true, + state: 'settled', + resourceId, + contentRevision: 1, + title: TITLE, + kind: 'text', + value: 'paid content', + txHash: TX_HASH, + }; +} + +function readyContentBody(): Record { + return { + ok: true, + state: 'ready', + resourceId: RESOURCE_ID, + intentSalt: INTENT_SALT, + title: TITLE, + contentRevision: 1, + kind: 'text', + value: 'paid content', + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type Handler = () => Response | Promise; +type Routes = { + quote?: Handler; + paid?: Handler; + status?: Handler; + content?: Handler; +}; + +const HOLD: Handler = () => new Promise(() => undefined); + +function routeFetch(routes: Routes) { + return vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.startsWith('/api/paid/hosted/')) { + if (new Headers(init?.headers).has('X-PAYMENT')) { + return (routes.paid ?? HOLD)(); + } + return (routes.quote ?? (() => jsonResponse(paymentRequired(), 402)))(); + } + if (url.startsWith('/api/store/purchase/status')) { + return (routes.status ?? HOLD)(); + } + if (url.startsWith('/api/store/content/')) { + return (routes.content ?? HOLD)(); + } + throw new Error(`unexpected fetch: ${url}`); + }); +} + +type HookProps = Parameters[0]; + +function baseProps(overrides: Partial = {}): HookProps { + return { + resourceId: RESOURCE_ID, + title: TITLE, + merchant: MERCHANT, + priceJpyc: PRICE_JPYC, + sessionAddress: PAYER, + rail: 'jpyc', + ...overrides, + }; +} + +function renderPurchase(initial: HookProps = baseProps()) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + const rendered = renderHook( + (props: HookProps) => useHostedStorePurchase(props), + { initialProps: initial, wrapper: Wrapper }, + ); + return { ...rendered, queryClient }; +} + +type Rendered = ReturnType; + +async function prepareToReview(rendered: Rendered) { + await act(async () => { + await rendered.result.current.prepare(); + }); + expect(rendered.result.current.phase).toBe('review'); +} + +/** 非同期の state 反映を数 tick 待つ (応答を処理済みにしてから「変化しない」ことを見る)。 */ +async function settle(ms = 30) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +function calledWith( + fetchMock: ReturnType, + prefix: string, +): boolean { + return fetchMock.mock.calls.some(([url]) => + String(url).startsWith(prefix), + ); +} + +function switchWallet(rendered: Rendered, props: HookProps, to: Address) { + account.address = to; + rendered.rerender({ ...props, sessionAddress: to }); +} + +beforeEach(() => { + vi.restoreAllMocks(); + account.address = PAYER; + account.chainId = 80002; + signTypedData.mockReset(); + signTypedData.mockResolvedValue(SIGNATURE); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('R15b pin: phase → paymentStatus / accessStatus (hook 経由で全 phase)', () => { + type Reach = (rendered: Rendered) => Promise; + const reach: Record = { + idle: { routes: {}, go: async () => undefined }, + 'loading-quote': { + routes: { quote: HOLD }, + go: async (rendered) => { + act(() => { + void rendered.result.current.prepare(); + }); + }, + }, + review: { routes: {}, go: prepareToReview }, + signing: { + routes: {}, + go: async (rendered) => { + await prepareToReview(rendered); + signTypedData.mockImplementation( + () => new Promise(() => undefined), + ); + act(() => { + void rendered.result.current.purchase(); + }); + }, + }, + submitting: { + routes: { paid: HOLD }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + void rendered.result.current.purchase(); + }); + }, + }, + indeterminate: { + routes: { paid: () => jsonResponse({ ok: true, state: 'pending' }, 202) }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + }, + }, + 'indeterminate-exhausted': { + fakeTimers: true, + routes: { paid: () => jsonResponse({ ok: true, state: 'pending' }, 202) }, + go: async (rendered) => { + await act(async () => { + await rendered.result.current.prepare(); + }); + await act(async () => { + await rendered.result.current.purchase(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(90_000); + }); + }, + }, + provisioning: { + routes: { + paid: () => jsonResponse(settledPaidBody(), 200), + content: () => jsonResponse({ ok: false, error: 'not_found' }, 404), + }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + }, + }, + ready: { + routes: { + paid: () => jsonResponse(settledPaidBody(), 200), + content: () => jsonResponse(readyContentBody(), 200), + }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => expect(rendered.result.current.phase).toBe('ready')); + }, + }, + 'needs-support': { + routes: { + paid: () => jsonResponse(settledPaidBody(), 200), + content: () => + jsonResponse({ ...readyContentBody(), state: 'provided-ended' }, 200), + }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(rendered.result.current.phase).toBe('needs-support'), + ); + }, + }, + 'failed-prebroadcast': { + routes: { + paid: () => + jsonResponse({ ok: false, error: 'purchase_intent_failed' }, 409), + }, + go: async (rendered) => { + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + }, + }, + error: { + routes: { quote: () => jsonResponse({ error: 'boom' }, 500) }, + go: async (rendered) => { + await act(async () => { + await rendered.result.current.prepare().catch(() => undefined); + }); + }, + }, + }; + + const busy = new Set(['loading-quote', 'signing', 'submitting']); + const retryable = new Set(['indeterminate', 'indeterminate-exhausted']); + + it.each(PHASE_STATUS_TABLE)( + '%s → payment=%s / access=%s', + async (phase, paymentStatus, accessStatus) => { + const step = reach[phase]; + if (step.fakeTimers) vi.useFakeTimers(); + routeFetch(step.routes); + const rendered = renderPurchase(); + await step.go(rendered); + const current = rendered.result.current; + expect(current.phase).toBe(phase); + expect(current.paymentStatus).toBe(paymentStatus); + expect(current.accessStatus).toBe(accessStatus); + expect(current.isBusy).toBe(busy.has(phase)); + expect(current.canRetrySignedPayment).toBe(retryable.has(phase)); + }, + ); + + it('表は hook の phase union 12 種をすべて網羅する', () => { + expect(PHASE_STATUS_TABLE.map(([phase]) => phase).sort()).toEqual( + Object.keys(reach).sort(), + ); + expect(PHASE_STATUS_TABLE).toHaveLength(12); + }); +}); + +describe('R15b pin: status 応答 parser (hook 経由)', () => { + it.each(PURCHASE_STATUS_CASES)('$label', async ({ body, expected }) => { + const fetchMock = routeFetch({ + paid: () => jsonResponse({ ok: true, state: 'pending' }, 202), + status: () => jsonResponse(body, 200), + content: () => jsonResponse({ ok: false, error: 'not_found' }, 404), + }); + const rendered = renderPurchase(); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(calledWith(fetchMock, '/api/store/purchase/status')).toBe(true), + ); + + if (expected?.state === 'settled') { + await waitFor(() => + expect(rendered.result.current.phase).toBe('provisioning'), + ); + expect(rendered.result.current.txHash).toBe(expected.txHash); + expect(rendered.result.current.paymentStatus).toBe('confirmed'); + return; + } + if (expected?.state === 'failed') { + await waitFor(() => + expect(rendered.result.current.phase).toBe('failed-prebroadcast'), + ); + expect(rendered.result.current.error?.message).toBe( + 'purchase_intent_failed', + ); + return; + } + // pending / 不正応答はどちらも確認中のまま (不正応答を成立・未実行へ倒さない)。 + await settle(); + expect(rendered.result.current.phase).toBe('indeterminate'); + expect(rendered.result.current.txHash).toBeNull(); + expect(rendered.result.current.paymentStatus).toBe('unknown'); + }); + + it('status route の HTTP error は確認中のまま据え置く', async () => { + const fetchMock = routeFetch({ + paid: () => jsonResponse({ ok: true, state: 'pending' }, 202), + status: () => jsonResponse({ error: 'rate_limited' }, 429), + }); + const rendered = renderPurchase(); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(calledWith(fetchMock, '/api/store/purchase/status')).toBe(true), + ); + await settle(); + expect(rendered.result.current.phase).toBe('indeterminate'); + }); +}); + +describe('R15b pin: own content read-back parser (hook 経由)', () => { + it.each(CONTENT_READBACK_CASES)('$label', async ({ body, expected }) => { + const fetchMock = routeFetch({ + paid: () => jsonResponse(settledPaidBody(), 200), + status: () => + jsonResponse({ ok: true, state: 'settled', txHash: TX_HASH }, 200), + content: () => jsonResponse(body, 200), + }); + const rendered = renderPurchase(); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(calledWith(fetchMock, '/api/store/content/')).toBe(true), + ); + + if (expected?.state === 'ready') { + await waitFor(() => expect(rendered.result.current.phase).toBe('ready')); + expect(rendered.result.current.content).toEqual(expected); + expect(rendered.result.current.needsSupportReason).toBeNull(); + return; + } + if (expected?.state === 'provided-ended') { + await waitFor(() => + expect(rendered.result.current.phase).toBe('needs-support'), + ); + expect(rendered.result.current.content).toBeNull(); + expect(rendered.result.current.needsSupportReason).toBe('provided-ended'); + return; + } + await settle(); + expect(rendered.result.current.phase).toBe('provisioning'); + expect(rendered.result.current.content).toBeNull(); + expect(rendered.result.current.error).toBeNull(); + }); +}); + +describe('R15b pin: session / product scope と古い完了の扱い', () => { + it('初回 mount は既存の store cache を消さず、wallet 切替で store cache を全消去する', async () => { + routeFetch({}); + const queryClient = new QueryClient(); + queryClient.setQueryData(['store', 'library', PAYER], ['kept']); + queryClient.setQueryData(['other', 'x'], 'untouched'); + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + const props = baseProps(); + const rendered = renderHook( + (p: HookProps) => useHostedStorePurchase(p), + { initialProps: props, wrapper: Wrapper }, + ); + expect(queryClient.getQueryData(['store', 'library', PAYER])).toEqual([ + 'kept', + ]); + + account.address = OTHER; + rendered.rerender({ ...props, sessionAddress: OTHER }); + expect(queryClient.getQueryData(['store', 'library', PAYER])).toBeUndefined(); + expect(queryClient.getQueryData(['other', 'x'])).toBe('untouched'); + }); + + it('address / merchant の大文字小文字だけの変化では scope を切り替えない', async () => { + routeFetch({ + quote: () => + jsonResponse( + paymentRequired(RESOURCE_ID, MIXED_MERCHANT, MIXED_PAYER), + 402, + ), + }); + account.address = MIXED_PAYER; + const props = baseProps({ + merchant: MIXED_MERCHANT, + sessionAddress: MIXED_PAYER, + }); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + + account.address = MIXED_PAYER.toLowerCase() as Address; + rendered.rerender({ + ...props, + sessionAddress: MIXED_PAYER.toLowerCase() as Address, + merchant: MIXED_MERCHANT.toLowerCase() as Address, + }); + expect(rendered.result.current.phase).toBe('review'); + expect(rendered.result.current.quote).toMatchObject({ + merchant: MIXED_MERCHANT, + }); + }); + + it.each([ + ['resourceId', { resourceId: 'h_other' }], + ['title', { title: 'Other title' }], + ['merchant', { merchant: OTHER }], + ['priceJpyc', { priceJpyc: '1300' }], + ['rail', { rail: 'usdc' as const }], + ])('product scope (%s) が変わると review を破棄して idle に戻す', async (_label, change) => { + routeFetch({}); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + + rendered.rerender({ ...props, ...change }); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.quote).toBeNull(); + expect(rendered.result.current.error).toBeNull(); + }); + + it('quote 取得中に wallet が切り替わると、遅れて届いた 402 を review に出さず wallet_changed', async () => { + const quote = deferred(); + routeFetch({ quote: () => quote.promise }); + const props = baseProps(); + const rendered = renderPurchase(props); + let pending!: Promise; + act(() => { + pending = rendered.result.current.prepare(); + }); + expect(rendered.result.current.phase).toBe('loading-quote'); + + switchWallet(rendered, props, OTHER); + expect(rendered.result.current.phase).toBe('idle'); + + let thrown: unknown; + await act(async () => { + quote.resolve(jsonResponse(paymentRequired(), 402)); + thrown = await pending.catch((error: unknown) => error); + }); + expect(thrown).toBeInstanceOf(HostedStorePurchaseError); + expect((thrown as HostedStorePurchaseError).code).toBe('wallet_changed'); + expect(rendered.result.current.phase).toBe('error'); + expect(rendered.result.current.quote).toBeNull(); + expect(signTypedData).not.toHaveBeenCalled(); + }); + + it('署名済み request の送信中に wallet が切り替わると、遅れて届いた 200 settled を無視する', async () => { + const paid = deferred(); + const fetchMock = routeFetch({ paid: () => paid.promise }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + let pending!: Promise; + await act(async () => { + pending = rendered.result.current.purchase(); + }); + expect(rendered.result.current.phase).toBe('submitting'); + + switchWallet(rendered, props, OTHER); + expect(rendered.result.current.phase).toBe('idle'); + + await act(async () => { + paid.resolve(jsonResponse(settledPaidBody(), 200)); + await pending; + }); + await settle(); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.txHash).toBeNull(); + expect(rendered.result.current.content).toBeNull(); + expect(rendered.result.current.error).toBeNull(); + expect(rendered.result.current.canRetrySignedPayment).toBe(false); + expect(calledWith(fetchMock, '/api/store/')).toBe(false); + }); + + it('署名済み request の送信中に wallet が切り替わると、遅れて届いた通信断も無視する', async () => { + const paid = deferred(); + routeFetch({ paid: () => paid.promise }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + let pending!: Promise; + await act(async () => { + pending = rendered.result.current.purchase(); + }); + + switchWallet(rendered, props, OTHER); + await act(async () => { + paid.reject(new TypeError('network lost')); + await pending; + }); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.error).toBeNull(); + }); + + it('status 確認中に wallet が切り替わると、遅れて届いた settled を反映しない', async () => { + const status = deferred(); + const fetchMock = routeFetch({ + paid: () => jsonResponse({ ok: true, state: 'pending' }, 202), + status: () => status.promise, + }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(calledWith(fetchMock, '/api/store/purchase/status')).toBe(true), + ); + + switchWallet(rendered, props, OTHER); + expect(rendered.result.current.phase).toBe('idle'); + await act(async () => { + status.resolve( + jsonResponse({ ok: true, state: 'settled', txHash: TX_HASH }, 200), + ); + }); + await settle(); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.txHash).toBeNull(); + }); + + it('own read-back 中に wallet が切り替わると、遅れて届いた content を表示しない', async () => { + const content = deferred(); + const fetchMock = routeFetch({ + paid: () => jsonResponse(settledPaidBody(), 200), + status: HOLD, + content: () => content.promise, + }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + await waitFor(() => + expect(calledWith(fetchMock, '/api/store/content/')).toBe(true), + ); + + switchWallet(rendered, props, OTHER); + await act(async () => { + content.resolve(jsonResponse(readyContentBody(), 200)); + }); + await settle(); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.content).toBeNull(); + expect(rendered.result.current.accessStatus).toBe('none'); + }); + + it('送信中/確認中の reset は署名済み request を保持したまま拒否し、wallet 切替だけが破棄する', async () => { + routeFetch({ + paid: () => jsonResponse({ ok: true, state: 'pending' }, 202), + }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + await act(async () => { + await rendered.result.current.purchase(); + }); + act(() => rendered.result.current.reset()); + expect(rendered.result.current.phase).toBe('indeterminate'); + expect(rendered.result.current.canRetrySignedPayment).toBe(true); + + switchWallet(rendered, props, OTHER); + expect(rendered.result.current.phase).toBe('idle'); + expect(rendered.result.current.canRetrySignedPayment).toBe(false); + await expect(rendered.result.current.retry()).rejects.toMatchObject({ + code: 'signed_payment_unavailable', + }); + }); + + // 以下 2 件は「現状挙動の固定」。product scope の切替は wallet 切替と違い、 + // 進行中の request の完了を捨てない (UI 側は quote 取得中の rail 切替を disabled にしている)。 + // 抽出 PR では変えない — 挙動変更は別 PR (掟 12/15) で扱う。 + it('[現状固定] quote 取得中の product scope 切替では、遅れて届いた旧 quote が review に出る', async () => { + const quote = deferred(); + routeFetch({ quote: () => quote.promise }); + const props = baseProps(); + const rendered = renderPurchase(props); + let pending!: Promise; + act(() => { + pending = rendered.result.current.prepare(); + }); + rendered.rerender({ ...props, priceJpyc: '1300' }); + expect(rendered.result.current.phase).toBe('idle'); + + await act(async () => { + quote.resolve(jsonResponse(paymentRequired(), 402)); + await pending; + }); + expect(rendered.result.current.phase).toBe('review'); + expect(rendered.result.current.quote).toMatchObject({ + merchantValueJpyc: '1200', + }); + }); + + it('[現状固定] 送信中の product scope 切替では、遅れて届いた旧商品の 200 settled で provisioning になる', async () => { + const paid = deferred(); + routeFetch({ paid: () => paid.promise }); + const props = baseProps(); + const rendered = renderPurchase(props); + await prepareToReview(rendered); + let pending!: Promise; + await act(async () => { + pending = rendered.result.current.purchase(); + }); + rendered.rerender({ ...props, resourceId: 'h_other' }); + expect(rendered.result.current.phase).toBe('idle'); + + await act(async () => { + paid.resolve(jsonResponse(settledPaidBody(RESOURCE_ID), 200)); + await pending; + }); + expect(rendered.result.current.phase).toBe('provisioning'); + expect(rendered.result.current.quote).toBeNull(); + expect(rendered.result.current.txHash).toBe(TX_HASH); + expect(rendered.result.current.canRetrySignedPayment).toBe(false); + }); +}); diff --git a/tests/lib/x402/hostedPurchasePhases.test.ts b/tests/lib/x402/hostedPurchasePhases.test.ts new file mode 100644 index 00000000..0895a9b0 --- /dev/null +++ b/tests/lib/x402/hostedPurchasePhases.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + accessStatusForPhase, + contentReadBackFrom, + isRecord, + paymentStatusForPhase, + purchaseStatusFrom, + responseJson, +} from '@/lib/x402/hostedPurchasePhases'; +import { + CONTENT_READBACK_CASES, + PHASE_STATUS_TABLE, + PURCHASE_STATUS_CASES, + TABLE_INTENT_SALT, + TABLE_RESOURCE_ID, +} from '../../_helpers/hostedPurchaseResponseTables'; + +// 同じテーブルを tests/hooks/useHostedStorePurchase.pins.test.tsx が hook 経由でも検査する +// (抽出前のコードでも走らせて同一挙動を確認済み)。ここでは戻り値そのものを厳密に固定する。 + +describe('purchaseStatusFrom', () => { + it.each(PURCHASE_STATUS_CASES)('$label', ({ body, expected }) => { + if (expected === null) { + expect(() => purchaseStatusFrom(body)).toThrow( + new Error('purchase_status_invalid'), + ); + return; + } + expect(purchaseStatusFrom(body)).toStrictEqual(expected); + }); +}); + +describe('contentReadBackFrom', () => { + it.each(CONTENT_READBACK_CASES)('$label', ({ body, expected }) => { + if (expected === null) { + expect(() => + contentReadBackFrom(body, TABLE_RESOURCE_ID, TABLE_INTENT_SALT), + ).toThrow(new Error('store_content_invalid')); + return; + } + expect( + contentReadBackFrom(body, TABLE_RESOURCE_ID, TABLE_INTENT_SALT), + ).toStrictEqual(expected); + }); +}); + +describe('paymentStatusForPhase / accessStatusForPhase', () => { + it.each(PHASE_STATUS_TABLE)( + '%s → payment=%s / access=%s', + (phase, paymentStatus, accessStatus) => { + expect(paymentStatusForPhase(phase)).toBe(paymentStatus); + expect(accessStatusForPhase(phase)).toBe(accessStatus); + }, + ); +}); + +describe('isRecord', () => { + it.each([ + [{}, true], + [{ ok: true }, true], + [Object.create(null), true], + [null, false], + [[], false], + ['x', false], + [1, false], + [undefined, false], + ])('%j → %s', (value, expected) => { + expect(isRecord(value)).toBe(expected); + }); +}); + +describe('responseJson', () => { + it('JSON body を parse する', async () => { + await expect( + responseJson(new Response('{"ok":true,"state":"pending"}')), + ).resolves.toStrictEqual({ ok: true, state: 'pending' }); + }); + + it.each([ + ['非 JSON', 'proxy error'], + ['空 body', ''], + ])('%s は throw せず null', async (_label, text) => { + await expect(responseJson(new Response(text))).resolves.toBeNull(); + }); + + it('JSON の null literal も null (呼び出し側は非 JSON と区別しない)', async () => { + await expect(responseJson(new Response('null'))).resolves.toBeNull(); + }); +}); + +describe('module 境界', () => { + it('React / wagmi / env / server 専用 module に依存しない (browser-safe の純粋 module)', () => { + const source = readFileSync( + join(process.cwd(), 'lib/x402/hostedPurchasePhases.ts'), + 'utf8', + ); + const imports = [...source.matchAll(/^import .*$/gm)].map((m) => m[0]); + expect(imports).toEqual(["import type { Hex } from 'viem';"]); + expect(source).not.toMatch(/^'use client'/m); + }); +});