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
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user