mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-09-28 13:15:34 +00:00
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:
co-authored by
Claude Opus 4.8
parent
08a24c9abd
commit
4664cf8f53
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user