diff --git a/src/components/EditProfileForm.test.tsx b/src/components/EditProfileForm.test.tsx new file mode 100644 index 00000000..51f8e322 --- /dev/null +++ b/src/components/EditProfileForm.test.tsx @@ -0,0 +1,155 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NostrMetadata } from '@nostrify/nostrify'; +import { EditProfileForm } from './EditProfileForm'; + +const mockPublish = vi.fn().mockResolvedValue({}); + +const mockToast = vi.fn(); +const mockRefetch = vi.fn().mockResolvedValue(undefined); + +vi.mock('@/hooks/useToast', () => ({ + useToast: () => ({ toast: mockToast }), +})); + +vi.mock('@/hooks/useUploadFile', () => ({ + useUploadFile: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +const ctx = vi.hoisted(() => ({ + pubkey: 'a'.repeat(64), + author: { + isSuccess: false, + isPending: true, + isError: false, + data: undefined as { metadata?: NostrMetadata } | undefined, + }, + metadata: undefined as NostrMetadata | undefined, +})); + +vi.mock('@/hooks/useCurrentUser', () => ({ + useCurrentUser: () => ({ + user: { pubkey: ctx.pubkey }, + metadata: ctx.metadata, + }), +})); + +vi.mock('@/hooks/useAuthor', () => ({ + useAuthor: () => ({ + isSuccess: ctx.author.isSuccess, + isPending: ctx.author.isPending, + isError: ctx.author.isError, + data: ctx.author.data, + refetch: mockRefetch, + }), +})); + +vi.mock('@/hooks/useNostrPublish', () => ({ + useNostrPublish: () => ({ + mutateAsync: mockPublish, + isPending: false, + }), +})); + +function renderForm() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe('EditProfileForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + ctx.author = { + isSuccess: false, + isPending: true, + isError: false, + data: undefined, + }; + ctx.metadata = undefined; + }); + + it('shows retry UI when loading existing profile fails', async () => { + const user = userEvent.setup(); + + ctx.author = { + isSuccess: false, + isPending: false, + isError: true, + data: undefined, + }; + + renderForm(); + + expect(screen.getByText('editProfileForm.loadingErrorDescription')).toBeInTheDocument(); + const retryBtn = screen.getByRole('button', { name: 'editProfileForm.retryLoadingProfile' }); + expect(retryBtn).not.toBeDisabled(); + + await user.click(retryBtn); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('disables save until author profile query succeeds', () => { + renderForm(); + const saveBtn = screen.getByRole('button', { name: 'editProfileForm.saveButton' }); + expect(saveBtn).toBeDisabled(); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it('enables save after success when there is no prior kind 0', () => { + ctx.author = { + isSuccess: true, + isPending: false, + isError: false, + data: {}, + }; + ctx.metadata = undefined; + + renderForm(); + + expect(screen.getByRole('button', { name: 'editProfileForm.saveButton' })).not.toBeDisabled(); + }); + + it('keeps lud16 from loaded metadata when saving', async () => { + const user = userEvent.setup(); + const lud16 = + 'lnurl1dp68gurn8ghj7mr0vdskc6r0wd6z7mrww4exctthd96xserjv9mnzumfwv9kkZ4MP5K'; + + ctx.author = { + isSuccess: true, + isPending: false, + isError: false, + data: { + metadata: { + name: 'Alice', + lud16, + }, + }, + }; + ctx.metadata = ctx.author.data!.metadata; + + renderForm(); + + const saveBtn = screen.getByRole('button', { name: 'editProfileForm.saveButton' }); + expect(saveBtn).not.toBeDisabled(); + + await user.click(saveBtn); + + expect(mockPublish).toHaveBeenCalledTimes(1); + const arg = mockPublish.mock.calls[0][0] as { kind: number; content: string }; + expect(arg.kind).toBe(0); + const parsed = JSON.parse(arg.content) as NostrMetadata; + expect(parsed.lud16).toBe(lud16); + expect(parsed.client).toBe('divine.video'); + }); +}); diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index 04650f6a..5a5a7934 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'react-i18next'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { Button } from '@/components/ui/button'; @@ -31,6 +32,7 @@ export const EditProfileForm: React.FC = ({ onSuccess }) = const queryClient = useQueryClient(); const { user, metadata } = useCurrentUser(); + const authorQuery = useAuthor(user?.pubkey); const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); @@ -98,6 +100,15 @@ export const EditProfileForm: React.FC = ({ onSuccess }) = return; } + if (!authorQuery.isSuccess) { + toast({ + title: t('editProfileForm.loadingErrorTitle'), + description: t('editProfileForm.loadingErrorDescription'), + variant: 'destructive', + }); + return; + } + try { // Combine existing metadata with new values const data = { ...metadata, ...values }; @@ -250,13 +261,29 @@ export const EditProfileForm: React.FC = ({ onSuccess }) = type="submit" variant="sticker" className="w-full md:w-auto" - disabled={isPending || isUploading} + disabled={isPending || isUploading || !authorQuery.isSuccess} > - {(isPending || isUploading) && ( + {(isPending || isUploading || authorQuery.isPending) && ( )} {t('editProfileForm.saveButton')} + {authorQuery.isError && ( +
+

+ {t('editProfileForm.loadingErrorDescription')} +

+ +
+ )} ); diff --git a/src/lib/i18n/locales/en/common.json b/src/lib/i18n/locales/en/common.json index 127fc010..0934c37c 100644 --- a/src/lib/i18n/locales/en/common.json +++ b/src/lib/i18n/locales/en/common.json @@ -761,6 +761,9 @@ "uploadErrorBannerDescription": "Couldn't send your banner. Try again?", "loginRequiredTitle": "Log in first.", "loginRequiredDescription": "You need to be signed in to update your profile.", + "loadingErrorTitle": "Hang tight.", + "loadingErrorDescription": "Still loading your profile. Try again in a sec.", + "retryLoadingProfile": "Retry loading profile", "saveSuccessTitle": "Profile saved.", "saveSuccessDescription": "New look, locked in.", "saveErrorTitle": "Save snagged.",