Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 173 lines 5.5 kB view raw
1import { describe, it, expect, vi, beforeEach } from 'vitest'; 2import { render, screen } from '@testing-library/react'; 3import userEvent from '@testing-library/user-event'; 4import { EditableSection } from '@/components/profile-editor/editable-section'; 5import type { FieldDef } from '@/components/profile-editor/edit-dialog'; 6 7const mockAddItem = vi.fn(); 8const mockUpdateItem = vi.fn(); 9const mockRemoveItem = vi.fn(); 10 11vi.mock('@/components/profile-edit-provider', () => ({ 12 useProfileEdit: () => ({ 13 profile: { 14 did: 'did:plc:test', 15 handle: 'test.bsky.social', 16 claimed: true, 17 followersCount: 0, 18 followingCount: 0, 19 connectionsCount: 0, 20 positions: [], 21 education: [], 22 skills: [ 23 { rkey: 'sk1', name: 'TypeScript', category: 'Frontend' }, 24 { rkey: 'sk2', name: 'Rust', category: 'Backend' }, 25 ], 26 }, 27 addItem: mockAddItem, 28 updateItem: mockUpdateItem, 29 removeItem: mockRemoveItem, 30 updateProfile: vi.fn(), 31 editRequest: null, 32 requestEdit: vi.fn(), 33 clearEditRequest: vi.fn(), 34 }), 35})); 36 37vi.mock('@/lib/profile-api', () => ({ 38 createSkill: vi.fn().mockResolvedValue({ success: true, rkey: 'sk-new' }), 39 updateSkill: vi.fn().mockResolvedValue({ success: true }), 40 deleteSkill: vi.fn().mockResolvedValue({ success: true }), 41 createRecord: vi.fn().mockResolvedValue({ success: true, rkey: 'r-new' }), 42 updateRecord: vi.fn().mockResolvedValue({ success: true }), 43 deleteRecord: vi.fn().mockResolvedValue({ success: true }), 44 createPosition: vi.fn(), 45 updatePosition: vi.fn(), 46 deletePosition: vi.fn(), 47 createEducation: vi.fn(), 48 updateEducation: vi.fn(), 49 deleteEducation: vi.fn(), 50 createExternalAccount: vi.fn(), 51 updateExternalAccount: vi.fn(), 52 deleteExternalAccount: vi.fn(), 53})); 54 55interface TestSkill { 56 rkey: string; 57 name: string; 58 category: string; 59} 60 61const SKILL_FIELDS: FieldDef[] = [ 62 { name: 'name', label: 'Skill Name', required: true }, 63 { name: 'category', label: 'Category' }, 64]; 65 66const toValues = (item: TestSkill): Record<string, string | boolean> => ({ 67 name: item.name, 68 category: item.category ?? '', 69}); 70 71const fromValues = (values: Record<string, string | boolean>): Omit<TestSkill, 'rkey'> => ({ 72 name: values.name as string, 73 category: (values.category as string) || '', 74}); 75 76const renderEntry = (item: TestSkill, controls?: { onEdit: () => void; onDelete: () => void }) => ( 77 <div> 78 <span>{item.name}</span> 79 {controls && ( 80 <> 81 <button onClick={controls.onEdit} aria-label={`Edit ${item.name}`}> 82 Edit 83 </button> 84 <button onClick={controls.onDelete} aria-label={`Delete ${item.name}`}> 85 Delete 86 </button> 87 </> 88 )} 89 </div> 90); 91 92function renderSection(isOwnProfile = false) { 93 return render( 94 <EditableSection<TestSkill> 95 sectionTitle="Skills" 96 profileKey="skills" 97 isOwnProfile={isOwnProfile} 98 fields={SKILL_FIELDS} 99 toValues={toValues} 100 fromValues={fromValues} 101 collection="id.sifa.profile.skill" 102 renderEntry={renderEntry} 103 />, 104 ); 105} 106 107beforeEach(() => { 108 vi.clearAllMocks(); 109}); 110 111describe('EditableSection', () => { 112 it('renders entries without edit controls when isOwnProfile is false', () => { 113 renderSection(false); 114 expect(screen.getByText('TypeScript')).toBeDefined(); 115 expect(screen.getByText('Rust')).toBeDefined(); 116 expect(screen.queryByRole('button', { name: /edit/i })).toBeNull(); 117 expect(screen.queryByRole('button', { name: /delete/i })).toBeNull(); 118 }); 119 120 it('shows Add button when isOwnProfile is true', () => { 121 renderSection(true); 122 expect(screen.getByRole('button', { name: 'Add Skills' })).toBeDefined(); 123 }); 124 125 it('opens dialog on Add click', async () => { 126 const user = userEvent.setup(); 127 renderSection(true); 128 await user.click(screen.getByRole('button', { name: 'Add Skills' })); 129 expect(screen.getByRole('dialog', { name: 'Add Skills' })).toBeDefined(); 130 }); 131 132 it('opens dialog on edit click with initial values', async () => { 133 const user = userEvent.setup(); 134 renderSection(true); 135 await user.click(screen.getByRole('button', { name: 'Edit TypeScript' })); 136 expect(screen.getByRole('dialog', { name: 'Edit Skills' })).toBeDefined(); 137 expect((screen.getByLabelText(/Skill Name/) as HTMLInputElement).value).toBe('TypeScript'); 138 }); 139 140 it('delete calls removeItem on context', async () => { 141 const user = userEvent.setup(); 142 renderSection(true); 143 await user.click(screen.getByRole('button', { name: 'Delete TypeScript' })); 144 // Wait for async delete to complete 145 await vi.waitFor(() => { 146 expect(mockRemoveItem).toHaveBeenCalledWith('skills', 'sk1'); 147 }); 148 }); 149 150 it('calls onPostSave after successful create', async () => { 151 const user = userEvent.setup(); 152 const onPostSave = vi.fn(); 153 render( 154 <EditableSection<TestSkill> 155 sectionTitle="Skills" 156 profileKey="skills" 157 isOwnProfile 158 fields={SKILL_FIELDS} 159 toValues={toValues} 160 fromValues={fromValues} 161 collection="id.sifa.profile.skill" 162 renderEntry={renderEntry} 163 onPostSave={onPostSave} 164 />, 165 ); 166 await user.click(screen.getByRole('button', { name: 'Add Skills' })); 167 await user.type(screen.getByLabelText(/Skill Name/), 'Go'); 168 await user.click(screen.getByRole('button', { name: 'Save' })); 169 await vi.waitFor(() => { 170 expect(onPostSave).toHaveBeenCalled(); 171 }); 172 }); 173});