test(knowledges): cover knowledge-form save/navigation + partial-update mapping

The knowledges feature had zero coverage while 01fa02d reworked its save/guard
logic. Add Vitest tests for the exported pure mappers (create/update inputs —
including the dirty-gated "" vs undefined distinction — and the zod
docType->subtype superRefine) and the component wiring (create -> navigate to
redirect, update -> no navigate, save-disabled-until-dirty, scoped
anonymize-disabled). The useBlocker dialog path stays covered by manual/live
testing (it needs a data router the component tests intentionally stub out).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-27 22:19:51 +07:00
co-authored by Claude Opus 4.8
parent 08a24c9abd
commit 4664cf8f53
2 changed files with 405 additions and 0 deletions
@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
import type { DirtyFlags, FormValues } from './knowledge-form';
import { documentToFormValues, formSchema, formValuesToCreateInput, formValuesToUpdateInput } from './knowledge-form';
const baseValues: FormValues = {
answerType: KnowledgeAnswerType.Other,
codeLang: '',
content: 'some content',
description: '',
docType: KnowledgeDocType.Answer,
guideType: undefined,
question: 'a question',
};
const baseFragment: KnowledgeDocumentFragmentFragment = {
answerType: KnowledgeAnswerType.Other,
codeLang: null,
content: 'frag content',
description: null,
docType: KnowledgeDocType.Answer,
flowId: null,
guideType: null,
id: '42',
manual: true,
partSize: 1,
question: 'frag question',
subtaskId: null,
taskId: null,
totalSize: 1,
userId: '7',
};
describe('formValuesToCreateInput', () => {
it('sends all required fields', () => {
const input = formValuesToCreateInput({
...baseValues,
codeLang: 'python',
description: 'desc',
docType: KnowledgeDocType.Code,
});
expect(input).toEqual({
answerType: KnowledgeAnswerType.Other,
codeLang: 'python',
content: 'some content',
description: 'desc',
docType: KnowledgeDocType.Code,
guideType: undefined,
question: 'a question',
});
});
it('maps empty optional fields to undefined, not ""', () => {
const input = formValuesToCreateInput({ ...baseValues, codeLang: '', description: '' });
expect(input.codeLang).toBeUndefined();
expect(input.description).toBeUndefined();
});
});
describe('formValuesToUpdateInput', () => {
it('always sends content and omits untouched fields', () => {
const input = formValuesToUpdateInput(baseValues, {});
expect(input).toEqual({ content: 'some content' });
expect('description' in input).toBe(false);
expect('question' in input).toBe(false);
expect('docType' in input).toBe(false);
});
it('emits a dirty+cleared description as "" (not undefined)', () => {
const input = formValuesToUpdateInput({ ...baseValues, description: '' }, { description: true });
expect(input.description).toBe('');
});
it('emits a dirty+cleared codeLang as ""', () => {
const input = formValuesToUpdateInput({ ...baseValues, codeLang: '' }, { codeLang: true });
expect(input.codeLang).toBe('');
});
it.each<[keyof DirtyFlags, Partial<FormValues>, unknown]>([
['docType', { docType: KnowledgeDocType.Guide }, KnowledgeDocType.Guide],
['question', { question: 'new q' }, 'new q'],
['guideType', { guideType: KnowledgeGuideType.Pentest }, KnowledgeGuideType.Pentest],
['answerType', { answerType: KnowledgeAnswerType.Tool }, KnowledgeAnswerType.Tool],
])('includes %s only when dirty', (field, overrides, expected) => {
const values = { ...baseValues, ...overrides };
expect(formValuesToUpdateInput(values, {})[field]).toBeUndefined();
expect(formValuesToUpdateInput(values, { [field]: true })[field]).toBe(expected);
});
});
describe('documentToFormValues', () => {
it('coerces nullish subtypes to undefined and nullish text to ""', () => {
expect(documentToFormValues(baseFragment)).toEqual({
answerType: KnowledgeAnswerType.Other,
codeLang: '',
content: 'frag content',
description: '',
docType: KnowledgeDocType.Answer,
guideType: undefined,
question: 'frag question',
});
});
it('preserves present values', () => {
const values = documentToFormValues({
...baseFragment,
codeLang: 'go',
description: 'a desc',
guideType: KnowledgeGuideType.Install,
});
expect(values.codeLang).toBe('go');
expect(values.description).toBe('a desc');
expect(values.guideType).toBe(KnowledgeGuideType.Install);
});
});
describe('formSchema', () => {
const valid: FormValues = {
answerType: KnowledgeAnswerType.Other,
codeLang: '',
content: 'content',
description: '',
docType: KnowledgeDocType.Answer,
guideType: undefined,
question: 'question',
};
it('accepts a valid Answer document', () => {
expect(formSchema.safeParse(valid).success).toBe(true);
});
it('requires content', () => {
const result = formSchema.safeParse({ ...valid, content: '' });
expect(result.success).toBe(false);
expect(result.error?.issues.find((i) => i.path[0] === 'content')?.message).toBe('Content is required');
});
it('requires question', () => {
const result = formSchema.safeParse({ ...valid, question: '' });
expect(result.success).toBe(false);
expect(result.error?.issues.find((i) => i.path[0] === 'question')?.message).toBe('Question is required');
});
it('enforces the question max-length message', () => {
const result = formSchema.safeParse({ ...valid, question: 'x'.repeat(2049) });
expect(result.success).toBe(false);
expect(result.error?.issues.find((i) => i.path[0] === 'question')?.message).toBe(
'Question must be 2048 characters or fewer',
);
});
it('enforces the description max-length message', () => {
const result = formSchema.safeParse({ ...valid, description: 'x'.repeat(1001) });
expect(result.success).toBe(false);
expect(result.error?.issues.find((i) => i.path[0] === 'description')?.message).toBe(
'Description must be 1000 characters or fewer',
);
});
it.each<[KnowledgeDocType, keyof FormValues, string]>([
[KnowledgeDocType.Answer, 'answerType', 'Answer type is required'],
[KnowledgeDocType.Code, 'codeLang', 'Code language is required'],
[KnowledgeDocType.Guide, 'guideType', 'Guide type is required'],
])('requires the %s subtype field', (docType, field, message) => {
const result = formSchema.safeParse({
...valid,
answerType: undefined,
codeLang: '',
docType,
guideType: undefined,
});
expect(result.success).toBe(false);
const issue = result.error?.issues.find((i) => i.path[0] === field);
expect(issue?.message).toBe(message);
});
it.each<[KnowledgeDocType, Partial<FormValues>]>([
[KnowledgeDocType.Answer, { answerType: KnowledgeAnswerType.Tool }],
[KnowledgeDocType.Code, { codeLang: 'rust' }],
[KnowledgeDocType.Guide, { guideType: KnowledgeGuideType.Use }],
])('passes the superRefine for %s when its subtype is present', (docType, overrides) => {
const result = formSchema.safeParse({
...valid,
answerType: undefined,
codeLang: '',
docType,
guideType: undefined,
...overrides,
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,195 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type Control, Controller } from 'react-hook-form';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KnowledgeAnswerType, KnowledgeDocType } from '@/graphql/types';
import type { FormValues, SubmitResult } from './knowledge-form';
const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() }));
// `useBlocker` throws outside a data router; the guard only needs a stable
// inert blocker for these save/navigation tests.
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return {
...actual,
useBlocker: () => ({ proceed: undefined, reset: undefined, state: 'unblocked' }),
useNavigate: () => navigate,
};
});
vi.mock('@/hooks/use-breakpoint', () => ({
useBreakpoint: () => ({ isDesktop: true, isMobile: false }),
}));
vi.mock('@apollo/client/react', () => ({ useMutation: () => [vi.fn(), {}] }));
vi.mock('@/providers/user-provider', () => ({
useUser: () => ({ authInfo: { privileges: ['anonymize.call'] } }),
}));
vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } }));
vi.mock('./knowledge-form-layout', () => {
const Layout = ({ control }: { control: Control<FormValues> }) => (
<>
<Controller
control={control}
name="content"
render={({ field }) => <textarea aria-label="content" {...field} />}
/>
<Controller
control={control}
name="question"
render={({ field }) => <input aria-label="question" {...field} />}
/>
</>
);
return { KnowledgeFormLayoutDesktop: Layout, KnowledgeFormLayoutMobile: Layout };
});
vi.mock('./knowledge-header', () => ({
KnowledgeHeader: ({
isAnonymizeDisabled,
saveButton,
}: {
isAnonymizeDisabled?: boolean;
saveButton?: React.ReactNode;
}) => (
<div>
{saveButton}
<button
disabled={isAnonymizeDisabled}
type="button"
>
Anonymize
</button>
</div>
),
}));
import { KnowledgeForm } from './knowledge-form';
const newValues: FormValues = {
answerType: KnowledgeAnswerType.Other,
codeLang: '',
content: '',
description: '',
docType: KnowledgeDocType.Answer,
guideType: undefined,
question: '',
};
const editValues: FormValues = {
answerType: KnowledgeAnswerType.Other,
codeLang: '',
content: 'existing content',
description: '',
docType: KnowledgeDocType.Answer,
guideType: undefined,
question: 'existing question',
};
beforeEach(() => {
navigate.mockClear();
});
describe('KnowledgeForm — create', () => {
it('submits values and navigates to the redirect target', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn<(values: FormValues, dirty: unknown) => Promise<SubmitResult>>().mockResolvedValue({
redirectTo: '/knowledges/123',
});
render(
<KnowledgeForm
initialValues={newValues}
isNew
onSubmit={onSubmit}
/>,
);
await user.type(screen.getByLabelText('content'), 'hello world');
await user.type(screen.getByLabelText('question'), 'why');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
const [values, dirty] = onSubmit.mock.lastCall!;
expect(values.content).toBe('hello world');
expect(values.question).toBe('why');
expect(dirty).toMatchObject({ content: true, question: true });
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/knowledges/123'));
});
});
describe('KnowledgeForm — update', () => {
it('submits the edited field and does NOT navigate without a redirect', async () => {
const user = userEvent.setup();
const onSubmit = vi
.fn<(values: FormValues, dirty: unknown) => Promise<SubmitResult>>()
.mockResolvedValue({ document: undefined });
render(
<KnowledgeForm
initialValues={editValues}
isNew={false}
onSubmit={onSubmit}
/>,
);
const question = screen.getByLabelText('question');
await user.clear(question);
await user.type(question, 'updated question');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
const [values, dirty] = onSubmit.mock.lastCall!;
expect(values.question).toBe('updated question');
expect(dirty).toMatchObject({ question: true });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(navigate).not.toHaveBeenCalled();
});
it('keeps Save disabled until the form is dirty', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue({});
render(
<KnowledgeForm
initialValues={editValues}
isNew={false}
onSubmit={onSubmit}
/>,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
await user.type(screen.getByLabelText('question'), '!');
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
});
});
describe('KnowledgeForm — anonymize wiring', () => {
it('disables the anonymize control until content is non-empty', async () => {
const user = userEvent.setup();
render(
<KnowledgeForm
initialValues={newValues}
isNew
onSubmit={vi.fn().mockResolvedValue({})}
/>,
);
expect(screen.getByRole('button', { name: 'Anonymize' })).toBeDisabled();
await user.type(screen.getByLabelText('content'), 'secret data');
await waitFor(() => expect(screen.getByRole('button', { name: 'Anonymize' })).toBeEnabled());
});
});