Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
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});