Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
544 lines
15 KiB
TypeScript
544 lines
15 KiB
TypeScript
/**
|
|
* RowBindingV2 Tests
|
|
* ADR-024: Chat & Message Architecture
|
|
*
|
|
* Tests for the simplified RowBindingV2 using useAllTables hook
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { RowBindingV2 } from '../RowBindingV2';
|
|
|
|
// Mock useAllTables hook
|
|
vi.mock('@/features/tables/hooks/useAllTables', () => ({
|
|
useAllTables: vi.fn(() => ({
|
|
data: {
|
|
spacesWithTables: [
|
|
{
|
|
id: 1,
|
|
name: 'Main Space',
|
|
icon: '🏠',
|
|
projects: [
|
|
{
|
|
id: 1,
|
|
name: 'Project Alpha',
|
|
icon: '📂',
|
|
tables: [
|
|
{ id: '1', name: 'tasks', displayName: 'Tasks', icon: '📋', displayField: 'title', projectId: 1, spaceId: 1 },
|
|
{ id: '2', name: 'contacts', displayName: 'Contacts', icon: '👤', displayField: 'name', projectId: 1, spaceId: 1 }
|
|
]
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Project Beta',
|
|
icon: '📁',
|
|
tables: [
|
|
{ id: '3', name: 'orders', displayName: 'Orders', icon: '🛒', displayField: 'number', projectId: 2, spaceId: 1 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
flat: [
|
|
{ id: '1', name: 'tasks', displayName: 'Tasks', icon: '📋', displayField: 'title', projectId: 1, projectName: 'Project Alpha', spaceId: 1 },
|
|
{ id: '2', name: 'contacts', displayName: 'Contacts', icon: '👤', displayField: 'name', projectId: 1, projectName: 'Project Alpha', spaceId: 1 },
|
|
{ id: '3', name: 'orders', displayName: 'Orders', icon: '🛒', displayField: 'number', projectId: 2, projectName: 'Project Beta', spaceId: 1 }
|
|
]
|
|
},
|
|
isLoading: false,
|
|
error: null
|
|
}))
|
|
}));
|
|
|
|
// Mock apiClient for rows
|
|
vi.mock('@/shared/utils/apiClient', () => ({
|
|
apiClient: {
|
|
get: vi.fn().mockImplementation((url: string) => {
|
|
if (url.includes('/rows')) {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: {
|
|
rows: [
|
|
{ id: 1, table_id: 1, data: { title: 'Task One', status: 'open' } },
|
|
{ id: 2, table_id: 1, data: { title: 'Task Two', status: 'done' } },
|
|
{ id: 3, table_id: 1, data: { title: 'Task Three', status: 'open' } }
|
|
]
|
|
}
|
|
});
|
|
}
|
|
return Promise.resolve({ success: false });
|
|
})
|
|
}
|
|
}));
|
|
|
|
function createTestQueryClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false }
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderWithProviders(ui: React.ReactElement) {
|
|
const queryClient = createTestQueryClient();
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
{ui}
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
describe('RowBindingV2', () => {
|
|
const mockOnBind = vi.fn();
|
|
const mockOnUnbind = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('renders header with "Привязать к записи" text', () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Привязать к записи')).toBeInTheDocument();
|
|
});
|
|
|
|
it('expands when clicked', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Проект')).toBeInTheDocument();
|
|
expect(screen.getByText('Таблица')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows bound rows count badge', () => {
|
|
const boundRows = [
|
|
{ table_id: 1, row_id: 1, row_title: 'Task One', table_name: 'Tasks' }
|
|
];
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
boundRows={boundRows}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows compact bound rows with badges', () => {
|
|
const boundRows = [
|
|
{ table_id: 1, row_id: 1, row_title: 'Task One' },
|
|
{ table_id: 1, row_id: 2, row_title: 'Task Two' }
|
|
];
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
boundRows={boundRows}
|
|
compact={true}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
expect(screen.getByText('Task Two')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows project selector with optgroups after expanding', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
// Check for optgroup labels and options
|
|
expect(screen.getByText('— Выберите проект —')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('enables table selector after project selection', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Проект')).toBeInTheDocument();
|
|
});
|
|
|
|
// Find project selector by placeholder option
|
|
const selects = screen.getAllByRole('combobox');
|
|
const projectSelect = selects[0]; // First select is project
|
|
fireEvent.change(projectSelect, { target: { value: '1' } });
|
|
|
|
await waitFor(() => {
|
|
const tableSelect = selects[1];
|
|
expect(tableSelect).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it('shows row search after table selection', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
defaultTableId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText('Поиск записей...')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows rows for selected table', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
defaultTableId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
expect(screen.getByText('Task Two')).toBeInTheDocument();
|
|
expect(screen.getByText('Task Three')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls onBind when selecting a row', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
defaultTableId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Task One'));
|
|
|
|
expect(mockOnBind).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
table_id: 1,
|
|
row_id: 1,
|
|
row_title: 'Task One'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('shows collapse button in expanded mode', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Свернуть')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('respects maxBindings limit', async () => {
|
|
const boundRows = [
|
|
{ table_id: 1, row_id: 1, row_title: 'Task One' },
|
|
{ table_id: 1, row_id: 2, row_title: 'Task Two' }
|
|
];
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
boundRows={boundRows}
|
|
maxBindings={2}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Достигнут лимит привязок/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows bound rows list when expanded', async () => {
|
|
const boundRows = [
|
|
{ table_id: 1, row_id: 1, row_title: 'Task One', table_name: 'Tasks', table_icon: '📋' }
|
|
];
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
boundRows={boundRows}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Привязанные записи (1/10)')).toBeInTheDocument();
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls onUnbind when clicking delete on bound row', async () => {
|
|
const boundRows = [
|
|
{ table_id: 1, row_id: 1, row_title: 'Task One', table_name: 'Tasks' }
|
|
];
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
boundRows={boundRows}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
});
|
|
|
|
// Find trash button
|
|
const trashButtons = document.querySelectorAll('button');
|
|
const trashButton = Array.from(trashButtons).find(btn =>
|
|
btn.querySelector('svg.lucide-trash-2')
|
|
);
|
|
|
|
if (trashButton) {
|
|
fireEvent.click(trashButton);
|
|
expect(mockOnUnbind).toHaveBeenCalledWith(1, 1);
|
|
}
|
|
});
|
|
|
|
it('shows green indicator when table is selected', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
defaultTableId={1}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
// Should show green indicator with table info
|
|
const greenIndicator = document.querySelector('.bg-green-500\\/10');
|
|
expect(greenIndicator).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ADR-024: Auto-mapping to Tasks table
|
|
describe('Tasks Auto-Mapping', () => {
|
|
const tasksSource = {
|
|
tableId: 1,
|
|
tableName: 'Задачи',
|
|
tableIcon: '📋',
|
|
displayColumn: 'title'
|
|
};
|
|
|
|
it('shows tasks list immediately when tasksSource is provided', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
tasksSource={tasksSource}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
// Should show "Quick Tasks" section (emoji and text are separate spans)
|
|
expect(screen.getByText('Задачи')).toBeInTheDocument();
|
|
expect(screen.getByText('📋')).toBeInTheDocument();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
// Tasks from the table should be visible
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
expect(screen.getByText('Task Two')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('allows selecting a task from quick tasks section', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
tasksSource={tasksSource}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Task One')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Task One'));
|
|
|
|
expect(mockOnBind).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
table_id: 1,
|
|
row_id: 1,
|
|
table_name: 'Задачи',
|
|
table_icon: '📋'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('still shows "Other tables" option when tasksSource is provided', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
tasksSource={tasksSource}
|
|
allowOtherTables={true}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Другая таблица')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ADR-024: Space Files Binding
|
|
describe('Space Files Binding', () => {
|
|
it('shows files section when spaceFilesTableId is provided', async () => {
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
spaceFilesTableId={5}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
// Emoji and text are separate spans
|
|
expect(screen.getByText('Файлы пространства')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('allows binding a file from space files table', async () => {
|
|
// Mock file rows
|
|
const { apiClient } = await import('@/shared/utils/apiClient');
|
|
vi.mocked(apiClient.get).mockImplementation((url: string) => {
|
|
if (url.includes('/tables/5/rows')) {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: {
|
|
rows: [
|
|
{ id: 101, table_id: 5, data: { name: 'document.pdf', type: 'application/pdf' } },
|
|
{ id: 102, table_id: 5, data: { name: 'image.png', type: 'image/png' } }
|
|
]
|
|
}
|
|
});
|
|
}
|
|
// Default tasks response
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: {
|
|
rows: [
|
|
{ id: 1, table_id: 1, data: { title: 'Task One' } }
|
|
]
|
|
}
|
|
});
|
|
});
|
|
|
|
renderWithProviders(
|
|
<RowBindingV2
|
|
defaultSpaceId={1}
|
|
spaceFilesTableId={5}
|
|
onBind={mockOnBind}
|
|
onUnbind={mockOnUnbind}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Привязать к записи'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Файлы пространства')).toBeInTheDocument();
|
|
});
|
|
|
|
// Click on files section to expand
|
|
fireEvent.click(screen.getByText('Файлы пространства'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('document.pdf')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('document.pdf'));
|
|
|
|
expect(mockOnBind).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
table_id: 5,
|
|
row_id: 101,
|
|
row_title: 'document.pdf'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|