Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions components/CreatorStoreSellerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
// 販売開始に必須の販売者情報を同じ場所で管理する。商品本文は一覧 API へ載せず、編集時だけ
// owner 限定 detail API から取得する。保存後は server の値を再取得し、楽観更新しない。

import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { deliveryFormatOf } from '@/lib/store/deliveryFormat';
import Link from 'next/link';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Store } from 'lucide-react';
import { env } from '@/lib/env';
import { useAccount } from 'wagmi';
Expand Down Expand Up @@ -176,6 +176,19 @@ function SignedInSellerPanel({
const t = useTranslations('CreatorStoreSeller');
const locale = useLocale();
const origin = useOrigin();
const queryClient = useQueryClient();
const activeAddress = useRef<string | null>(sessionAddress.toLowerCase());
useEffect(() => {
activeAddress.current = sessionAddress.toLowerCase();
// タブ移動や account 切替で unmount しても mutation は完了する。旧 observer の
// refetch が別 account の cookie で取得しないよう失効させ、同 account の active query だけ更新する。
return () => { activeAddress.current = null; };
}, [sessionAddress]);
const refreshMountedOwner = (kind: 'products' | 'seller', owner: string) => queryClient.invalidateQueries({
queryKey: ['creator-store', kind],
predicate: ({ queryKey }) => String(queryKey[2]).toLowerCase() === owner,
refetchType: 'active',
});
const [sellerDraft, setSellerDraft] = useState<SellerForm | null>(null);
const [sellerSaved, setSellerSaved] = useState(false);
// 新規商品の掲載先は「いま編集中のプロフ (prop handle)」を既定にする —
Expand Down Expand Up @@ -247,6 +260,7 @@ function SignedInSellerPanel({

const saveSeller = useMutation({
gcTime: 0,
onMutate: () => sessionAddress.toLowerCase(),
mutationFn: async (form: SellerForm) =>
requestJson<SellerResponse>('/api/store/seller', {
method: 'PUT',
Expand All @@ -257,7 +271,8 @@ function SignedInSellerPanel({
disclosure: form.disclosure.trim() || null,
}),
}),
onSuccess: async () => {
onSuccess: async (_data, _form, startedAddress) => {
if (activeAddress.current !== startedAddress) return refreshMountedOwner('seller', startedAddress);
const refreshed = await sellerQuery.refetch();
if (refreshed.isSuccess) {
setSellerDraft(null);
Expand Down Expand Up @@ -322,6 +337,7 @@ function SignedInSellerPanel({

const saveProduct = useMutation({
gcTime: 0,
onMutate: () => sessionAddress.toLowerCase(),
mutationFn: async ({
id,
form,
Expand Down Expand Up @@ -386,7 +402,8 @@ function SignedInSellerPanel({
}),
},
),
onSuccess: async () => {
onSuccess: async (_data, _variables, startedAddress) => {
if (activeAddress.current !== startedAddress) return refreshMountedOwner('products', startedAddress);
const refreshed = await productsQuery.refetch();
if (refreshed.isSuccess) {
setEditingId(null);
Expand All @@ -398,6 +415,7 @@ function SignedInSellerPanel({
});

const toggleSale = useMutation({
onMutate: () => sessionAddress.toLowerCase(),
mutationFn: async ({
id,
saleActive,
Expand All @@ -413,7 +431,8 @@ function SignedInSellerPanel({
body: JSON.stringify({ saleActive }),
},
),
onSuccess: async () => {
onSuccess: async (_data, _variables, startedAddress) => {
if (activeAddress.current !== startedAddress) return refreshMountedOwner('products', startedAddress);
await productsQuery.refetch();
},
});
Expand Down
7 changes: 4 additions & 3 deletions components/creator-store-seller/ProductEditorSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ type Props = {
updateProduct: (patch: Partial<ProductForm>) => void;
cancelEdit: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
saveProduct: Pick<MutationView<unknown>, 'isPending' | 'isError' | 'error'>;
loadProduct: Pick<MutationView<unknown>, 'isError' | 'error'>;
saveProduct: Pick<MutationView<unknown>, 'isPending' | 'isError' | 'error' | 'submittedAt'>;
loadProduct: Pick<MutationView<unknown>, 'isError' | 'error' | 'submittedAt'>;
};

export function ProductEditorSection({
Expand Down Expand Up @@ -530,7 +530,8 @@ export function ProductEditorSection({
{loadProduct.isError || saveProduct.isError ? (
<p className="text-sm text-red-600 sm:col-span-2">
{(() => {
const cause = loadProduct.error ?? saveProduct.error;
const cause = saveProduct.isError && (!loadProduct.isError || saveProduct.submittedAt >= loadProduct.submittedAt)
? saveProduct.error : loadProduct.error;
const detailKey = errorDetailKey(cause);
const messageKey = errorMessageKey(cause);
return detailKey
Expand Down
10 changes: 2 additions & 8 deletions components/creator-store-seller/SellerDisclosureSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,8 @@ export function SellerDisclosureSection({ seller, sellerForm, sellerSaved, isLic
{t('sellerIntro')}
</p>
</div>
<span
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
sellerComplete
? 'bg-emerald-100 text-emerald-800'
: 'bg-amber-100 text-amber-800'
}`}
>
{sellerComplete ? t('sellerRegistered') : t('sellerUnregistered')}
<span className="rounded-full px-2.5 py-1 text-xs font-semibold bg-amber-100 text-amber-800">
{t('sellerUnregistered')}
</span>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions components/creator-store-seller/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type MutationView<Variables> = {
isPending: boolean;
isError: boolean;
error: unknown;
submittedAt: number;
variables?: Variables;
mutate: (variables: Variables) => void;
};
251 changes: 251 additions & 0 deletions tests/components/CreatorStoreSellerPanel.mutations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CreatorStoreSellerPanel } from '@/components/CreatorStoreSellerPanel';
import { renderWithIntl } from '../_helpers/i18n';
import ja from '@/messages/ja.json';
import en from '@/messages/en.json';

const A = '0x00000000000000000000000000000000000000aa';
const B = '0x00000000000000000000000000000000000000bb';
const state = vi.hoisted(() => ({ wallet: '', session: null as string | null }));
vi.mock('wagmi', () => ({ useAccount: () => ({ isConnected: true, address: state.wallet }) }));
vi.mock('@/hooks/useSiweSession', () => ({
useSiweSession: () => ({
isSignedIn: state.session?.toLowerCase() === state.wallet.toLowerCase(), sessionAddress: state.session,
signIn: vi.fn(), isSigningIn: false, signInError: null,
}),
}));
vi.mock('@/components/ConnectButton', () => ({ ConnectButton: () => <button>Connect</button> }));
vi.mock('@/lib/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/env')>();
return { ...actual, env: { ...actual.env, enableCreatorStoreUi: true, enableLicenseNftUi: false } };
});
// useStoreCacheScope and QueryClient stay real: the stale callback survives its observer's unmount.

function response(body: unknown, status = 200) {
return { ok: status < 400, status, json: async () => body } as Response;
}

function product(owner: string) {
return {
id: owner === A ? 'h_a' : 'h_b', payTo: owner, title: owner === A ? 'Product A' : 'Product B',
priceJpyc: '1000', contentKind: 'text', label: 'prompt', saleActive: true,
contentAvailable: true, usdcEnabled: true,
};
}

function ownerResponse(url: string) {
const owner = state.session!;
if (url === '/api/store/seller') return response({ ok: true, seller: { name: `Seller ${owner}`, contact: 'seller@example.com', updatedAt: 1 } });
if (url === '/api/store/products') return response({ ok: true, products: [product(owner)], max: 12 });
return response({ ok: true, product: product(owner), content: { kind: 'text', value: `Private ${owner}` } });
}

function setup(locale: 'ja' | 'en' = 'ja') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } });
const tree = (showPanel = true) => <QueryClientProvider client={client}>{showPanel ? <CreatorStoreSellerPanel handle="alice" /> : null}</QueryClientProvider>;
const rendered = renderWithIntl(tree(), { locale });
return { client, refresh: (showPanel = true) => rendered.rerender(tree(showPanel)) };
}

function content() {
return document.getElementById('creator-store-product-content')!;
}

const targets = ['create', 'update', 'seller', 'toggle'] as const;
function openDisclosure() {
const details = document.querySelector('details')!;
if (!details.open) fireEvent.click(details.querySelector('summary')!);
}

async function startMutation(target: typeof targets[number]) {
const row = within(screen.getByText('Product A').closest('li')!);
if (target === 'toggle') {
fireEvent.click(row.getByRole('checkbox'));
return;
}
if (target === 'update') {
fireEvent.click(row.getByRole('button', { name: '編集' }));
await waitFor(() => expect(content()).toHaveValue(`Private ${A}`));
}
if (target === 'seller') openDisclosure();
const field = screen.getByLabelText(target === 'seller' ? '氏名・名称' : '商品名');
fireEvent.change(field, { target: { value: 'Draft A' } });
if (target === 'create') {
fireEvent.change(screen.getByLabelText('価格 (JPYC)'), { target: { value: '1000' } });
fireEvent.change(content(), { target: { value: 'https://example.com/private' } });
}
fireEvent.submit(field.closest('form')!);
}

beforeEach(() => {
state.wallet = A;
state.session = A;
vi.unstubAllGlobals();
});
afterEach(() => vi.restoreAllMocks());

describe('seller mutation completion stays in the starting account', () => {
it.each(targets.flatMap((target) => ['direct', 'sign-out', 'wallet-first', 'return-to-A', 'tab-remount'].map((transition) => ({ target, transition }))))(
'late $target refreshes only a mounted starting account after $transition', async ({ target, transition }) => {
let resolve!: (value: Response) => void;
const pending = new Promise<Response>((done) => { resolve = done; });
let committed = false;
const endpoint = target === 'seller' ? '/api/store/seller' : '/api/store/products';
// Like a session cookie, the owner is read when fetch starts, not from the old query key.
const requests: { url: string; method: string; owner: string | null }[] = [];
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => {
requests.push({ url, method: init?.method ?? 'GET', owner: state.session });
if (init?.method) return pending;
if (committed && state.session === A && url === endpoint) {
if (target === 'seller') return Promise.resolve(response({ ok: true, seller: { name: 'Saved seller', contact: 'seller@example.com', updatedAt: 2 } }));
const saved = target === 'toggle' ? { ...product(A), saleActive: false } : { ...product(A), title: 'Saved product' };
const products = target === 'create' ? [product(A), { ...saved, id: 'h_created' }] : [saved];
return Promise.resolve(response({ ok: true, products, max: 12 }));
}
return Promise.resolve(ownerResponse(url));
}));
const { client, refresh } = setup();
await screen.findByText('Product A');
await startMutation(target);
await waitFor(() => expect(requests.filter(({ method }) => method !== 'GET')).toHaveLength(1));
const mutation = client.getMutationCache().getAll().find((entry) => entry.state.status === 'pending')!;
if (transition === 'tab-remount') {
refresh(false);
expect(document.getElementById('creator-store-product-content')).toBeNull();
} else if (transition === 'sign-out') {
state.session = null;
} else if (transition === 'wallet-first') {
state.wallet = B;
} else {
state.wallet = B;
state.session = B;
}
refresh();
if (transition === 'tab-remount') await screen.findByText('Product A');
if (transition === 'direct' || transition === 'return-to-A') await screen.findByText('Product B');
if (transition === 'return-to-A') {
state.wallet = A;
state.session = A;
refresh();
await screen.findByText('Product A');
}
const returnedToA = transition === 'return-to-A' || transition === 'tab-remount';
const hasEditor = transition === 'direct' || returnedToA;
if (hasEditor) {
openDisclosure();
fireEvent.change(screen.getByLabelText('商品名'), { target: { value: 'Current draft' } });
fireEvent.change(content(), { target: { value: 'Current private content' } });
fireEvent.change(screen.getByLabelText('氏名・名称'), { target: { value: 'Current seller' } });
} else {
expect(document.getElementById('creator-store-product-content')).toBeNull();
}
const beforeCompletion = [...requests];
await act(async () => {
committed = true;
resolve(response({ ok: true, product: product(A) }));
});
await waitFor(() => expect(mutation.state.status).toBe('success'));
expect(requests).toEqual(returnedToA
? [...beforeCompletion, { url: endpoint, method: 'GET', owner: A }]
: beforeCompletion);
if (returnedToA) {
if (target === 'toggle') {
await waitFor(() => expect(within(screen.getByText('Product A').closest('li')!).getByRole('checkbox')).not.toBeChecked());
} else {
expect(await screen.findByText(target === 'seller' ? 'Saved seller' : 'Saved product')).toBeVisible();
}
}
// Feedback/draft isolation already comes from remounting; the request assertion above catches S1.
expect(screen.queryByText(ja.CreatorStoreSeller.productSaved)).not.toBeInTheDocument();
expect(screen.queryByText(ja.CreatorStoreSeller.sellerSaved)).not.toBeInTheDocument();
if (hasEditor) {
expect(screen.getByLabelText('商品名')).toHaveValue('Current draft');
expect(content()).toHaveValue('Current private content');
expect(screen.getByLabelText('氏名・名称')).toHaveValue('Current seller');
}
},
);

it.each(targets)('%s still refetches in the same account before announcing success', async (target) => {
const endpoint = target === 'seller' ? '/api/store/seller' : '/api/store/products';
let saved = false;
let resolve!: (value: Response) => void;
const pendingRefetch = new Promise<Response>((done) => { resolve = done; });
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method) { saved = true; return response({ ok: true }); }
return saved && url === endpoint ? pendingRefetch : ownerResponse(url);
});
vi.stubGlobal('fetch', fetchMock);
setup();
await screen.findByText('Product A');
await startMutation(target);
await waitFor(() => expect(fetchMock.mock.calls.filter(([url, init]) => url === endpoint && !init?.method)).toHaveLength(2));
expect(screen.queryByText(ja.CreatorStoreSeller.productSaved)).not.toBeInTheDocument();
expect(screen.queryByText(ja.CreatorStoreSeller.sellerSaved)).not.toBeInTheDocument();
await act(async () => { resolve(ownerResponse(endpoint)); });
if (target !== 'toggle') {
const savedMessage = target === 'seller' ? ja.CreatorStoreSeller.sellerSaved : ja.CreatorStoreSeller.productSaved;
expect(await screen.findByText(savedMessage)).toBeVisible();
fireEvent.change(screen.getByLabelText(target === 'seller' ? '氏名・名称' : '商品名'), { target: { value: 'Next draft' } });
expect(screen.queryByText(savedMessage)).not.toBeInTheDocument();
}
});
});

describe('seller editor shows the most recent failure', () => {
const cases = [
{ error: 'invalid_product', detail: 'invalid title', key: 'detailInvalidTitle' },
{ error: 'usdc_pay_to_contract_wallet', key: 'usdcContractWalletError' },
{ error: 'storage_unavailable', key: 'requestError' },
] as const;
it.each((['ja', 'en'] as const).flatMap((locale) => cases.map((failure) => ({ locale, ...failure }))))(
'$locale renders the save $error message', async ({ locale, key, ...failure }) => {
const messages = (locale === 'ja' ? ja : en).CreatorStoreSeller;
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method) return response({ ok: false, ...failure }, 400);
if (url.endsWith('/h_a')) return response({ ok: true, product: product(A), content: null });
return ownerResponse(url);
}));
setup(locale);
const row = within((await screen.findByText('Product A')).closest('li')!);
fireEvent.click(row.getByRole('button', { name: locale === 'ja' ? '編集' : 'Edit' }));
const loadMessage = messages.requestError.replace('{error}', 'content_unavailable');
await screen.findByText(loadMessage);
const title = document.getElementById('creator-store-product-title')!;
fireEvent.change(title, { target: { value: 'New draft' } });
fireEvent.change(document.getElementById('creator-store-product-price')!, { target: { value: '1000' } });
fireEvent.change(content(), { target: { value: 'https://example.com/private' } });
fireEvent.submit(title.closest('form')!);
await screen.findByText(messages[key].replace('{error}', failure.error));
expect(screen.queryByText(loadMessage)).not.toBeInTheDocument();
expect(title).toHaveValue('New draft');
expect(content()).toHaveValue('https://example.com/private');
},
);

it.each(['ja', 'en'] as const)('%s shows a newer load failure after a save failure', async (locale) => {
const messages = (locale === 'ja' ? ja : en).CreatorStoreSeller;
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method) return response({ ok: false, error: 'invalid_product', detail: 'invalid title' }, 400);
if (url.endsWith('/h_a')) return response({ ok: true, product: product(A), content: null });
return ownerResponse(url);
}));
setup(locale);
await screen.findByText('Product A');
const now = vi.spyOn(Date, 'now').mockReturnValue(1000);
const title = document.getElementById('creator-store-product-title')!;
fireEvent.change(title, { target: { value: 'New draft' } });
fireEvent.change(document.getElementById('creator-store-product-price')!, { target: { value: '1000' } });
fireEvent.change(content(), { target: { value: 'https://example.com/private' } });
fireEvent.submit(title.closest('form')!);
expect(await screen.findByText(messages.detailInvalidTitle)).toBeVisible();
now.mockReturnValue(2000);
fireEvent.click(within(screen.getByText('Product A').closest('li')!).getByRole('button', { name: locale === 'ja' ? '編集' : 'Edit' }));
expect(await screen.findByText(messages.requestError.replace('{error}', 'content_unavailable'))).toBeVisible();
expect(screen.queryByText(messages.detailInvalidTitle)).not.toBeInTheDocument();
expect(title).toHaveValue('New draft');
expect(content()).toHaveValue('https://example.com/private');
});
});
Loading