feat(graph-rag): UI fuer Wissens-Graph mit BFS-Traversierung — Modul 11/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-09-15 08:25:21 +02:00
parent 4eba9eb1d9
commit 0404c8f5dc
8 changed files with 876 additions and 5 deletions
@@ -0,0 +1,257 @@
/**
* GraphRAG page tests — knowledge graph traversal and relationship
* management UI (UI-Backlog module 11/16).
*
* Covers: rendering, permission gating, traverse flow (payload with
* max_hops clamp + relationship_types parsing), traverse results grouped
* by depth, relationships list, create modal validation, delete flow.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GraphRagPage } from '@/pages/GraphRag';
import type { GraphRelationship, GraphTraverseResult } from '@/api/knowledge';
const { createMut, deleteMut, traverseMut } = vi.hoisted(() => ({
createMut: vi.fn().mockResolvedValue({}),
deleteMut: vi.fn().mockResolvedValue({}),
traverseMut: vi.fn().mockResolvedValue({}),
}));
const makeRel = (overrides: Partial<GraphRelationship> = {}): GraphRelationship => ({
id: '11111111-1111-1111-1111-111111111111',
source_type: 'contact',
source_id: '22222222-2222-2222-2222-222222222222',
target_type: 'file',
target_id: '33333333-3333-3333-3333-333333333333',
relationship_type: 'has_document',
metadata: null,
owner_id: null,
created_at: '2026-09-01T10:00:00Z',
...overrides,
});
const makeTraverseResult = (): GraphTraverseResult => ({
nodes: [
{ entity_type: 'contact', entity_id: '22222222-2222-2222-2222-222222222222', depth: 0, path: [] },
{ entity_type: 'file', entity_id: '33333333-3333-3333-3333-333333333333', depth: 1, path: ['22222222'] },
],
edges: [
{
source_type: 'contact',
source_id: '22222222-2222-2222-2222-222222222222',
target_type: 'file',
target_id: '33333333-3333-3333-3333-333333333333',
relationship_type: 'has_document',
metadata: null,
},
],
total_nodes: 2,
total_edges: 1,
});
let mockItems: GraphRelationship[] = [];
let mockTotal = 0;
let mockCanRead = true;
let mockCanWrite = true;
vi.mock('@/api/knowledge', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/knowledge')>();
return {
...actual,
fetchGraphRelationships: vi.fn().mockImplementation(async (params) => {
const page = params?.page ?? 1;
const pageSize = params?.page_size ?? 20;
const start = (page - 1) * pageSize;
return {
items: mockItems.slice(start, start + pageSize),
total: mockTotal,
};
}),
createGraphRelationship: createMut,
deleteGraphRelationship: deleteMut,
traverseGraph: traverseMut,
};
});
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) =>
(mockCanRead || perm !== 'graph:read') &&
(mockCanWrite || perm !== 'graph:write'),
}),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<GraphRagPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
// NOTE: do NOT use vi.clearAllMocks() — it strips the mockImplementation
// from fetchGraphRelationships. Reset call history only.
createMut.mockClear();
deleteMut.mockClear();
traverseMut.mockClear();
mockItems = [];
mockTotal = 0;
mockCanRead = true;
mockCanWrite = true;
// Direct override: spyOn(window, 'confirm') is unreliable in jsdom after
// mock resets — a plain function assignment always returns true.
window.confirm = () => true;
});
describe('GraphRagPage', () => {
it('renders page with traverse panel and relationships section', async () => {
renderPage();
expect(screen.getByTestId('graph-rag-page')).toBeInTheDocument();
expect(screen.getByTestId('graph-traverse-panel')).toBeInTheDocument();
// empty state appears after the async relationships query resolves
expect(await screen.findByTestId('graph-empty')).toBeInTheDocument();
});
it('shows no-permission card when graph:read is missing', () => {
mockCanRead = false;
renderPage();
expect(screen.getByTestId('graph-no-permission')).toBeInTheDocument();
});
it('hides create button without graph:write', () => {
mockCanWrite = false;
renderPage();
expect(screen.queryByTestId('graph-create-btn')).not.toBeInTheDocument();
});
it('renders relationships with source → type → target display', async () => {
mockItems = [makeRel()];
mockTotal = 1;
renderPage();
// fetchGraphRelationships (queryFn) resolves async — wait for the card
expect(await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111')).toBeInTheDocument();
expect(screen.getByText('has_document')).toBeInTheDocument();
});
it('hides delete button without graph:write', async () => {
mockItems = [makeRel()];
mockTotal = 1;
mockCanWrite = false;
renderPage();
await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111');
expect(
screen.queryByTestId('graph-rel-delete-11111111-1111-1111-1111-111111111111'),
).not.toBeInTheDocument();
});
it('traverses with clamped max_hops and parsed relationship_types', async () => {
traverseMut.mockResolvedValue(makeTraverseResult());
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-type'), {
target: { value: 'contact' },
});
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
// out-of-range value clamps to 10
fireEvent.change(screen.getByTestId('graph-traverse-max-hops'), {
target: { value: '99' },
});
fireEvent.change(screen.getByTestId('graph-traverse-type-filter'), {
target: { value: 'has_document, works_for' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => expect(traverseMut).toHaveBeenCalled());
const payload = traverseMut.mock.calls[0][0];
expect(payload.source_type).toBe('contact');
expect(payload.source_id).toBe('22222222-2222-2222-2222-222222222222');
expect(payload.max_hops).toBe(10);
expect(payload.relationship_types).toEqual(['has_document', 'works_for']);
});
it('sends null relationship_types when filter is empty', async () => {
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => expect(traverseMut).toHaveBeenCalled());
expect(traverseMut.mock.calls[0][0].relationship_types).toBeNull();
});
it('displays traverse results grouped by depth with stats', async () => {
traverseMut.mockResolvedValue(makeTraverseResult());
renderPage();
fireEvent.change(screen.getByTestId('graph-traverse-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.click(screen.getByTestId('graph-traverse-submit'));
await waitFor(() => {
expect(screen.getByTestId('graph-traverse-results')).toBeInTheDocument();
});
expect(screen.getByTestId('graph-traverse-stats')).toHaveTextContent('2');
// depth 0 node and depth 1 node rendered
expect(screen.getByTestId('graph-node-contact-22222222')).toBeInTheDocument();
expect(screen.getByTestId('graph-node-file-33333333')).toBeInTheDocument();
});
it('opens create modal, validates required fields, and submits payload', async () => {
renderPage();
fireEvent.click(screen.getByTestId('graph-create-btn'));
// submit disabled while fields empty
expect(screen.getByTestId('graph-form-submit')).toBeDisabled();
fireEvent.change(screen.getByTestId('graph-form-source-id'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
fireEvent.change(screen.getByTestId('graph-form-target-id'), {
target: { value: '33333333-3333-3333-3333-333333333333' },
});
fireEvent.change(screen.getByTestId('graph-form-rel-type'), {
target: { value: 'has_document' },
});
fireEvent.click(screen.getByTestId('graph-form-submit'));
await waitFor(() => expect(createMut).toHaveBeenCalled());
const payload = createMut.mock.calls[0][0];
expect(payload.source_type).toBe('contact');
expect(payload.source_id).toBe('22222222-2222-2222-2222-222222222222');
expect(payload.target_type).toBe('file');
expect(payload.target_id).toBe('33333333-3333-3333-3333-333333333333');
expect(payload.relationship_type).toBe('has_document');
});
it('deletes a relationship after confirm', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockItems = [makeRel()];
mockTotal = 1;
renderPage();
await screen.findByTestId('graph-rel-11111111-1111-1111-1111-111111111111');
fireEvent.click(screen.getByTestId('graph-rel-delete-11111111-1111-1111-1111-111111111111'));
// TanStack Query v5 calls mutationFn(id, context) — assert the first arg.
await waitFor(() => expect(deleteMut).toHaveBeenCalled(), { timeout: 3000 });
expect(deleteMut.mock.calls[0][0]).toBe('11111111-1111-1111-1111-111111111111');
expect(confirmSpy).toHaveBeenCalled();
confirmSpy.mockRestore();
});
it('traverse submit disabled without source id', () => {
renderPage();
expect(screen.getByTestId('graph-traverse-submit')).toBeDisabled();
});
});