datajunction-ui
Version:
DataJunction UI
658 lines (556 loc) • 19.8 kB
JSX
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import NodePreAggregationsTab from '../NodePreAggregationsTab';
import DJClientContext from '../../../providers/djclient';
// Mock the CSS import
vi.mock('../../../../styles/preaggregations.css', () => ({}));
// Mock cronstrue - it's imported via require() in the component
vi.mock('cronstrue', () => {
const mock = {
toString: cron => {
if (cron === '0 0 * * *') return 'At 12:00 AM';
if (cron === '0 * * * *') return 'Every hour';
return cron || 'Not scheduled';
},
};
return { ...mock, default: mock };
});
// Mock labelize from utils/form
vi.mock('../../../../utils/form', () => ({
labelize: str => {
if (!str) return '';
// Convert snake_case/SCREAMING_SNAKE to Title Case
return str
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
},
}));
const mockNode = {
name: 'default.orders_fact',
version: 'v1.0',
type: 'transform',
};
const mockPreaggs = {
items: [
{
id: 1,
node_revision_id: 1,
node_name: 'default.orders_fact',
node_version: 'v1.0',
grain_columns: [
'default.date_dim.date_id',
'default.customer_dim.customer_id',
],
measures: [
{
name: 'total_revenue',
expression: 'revenue',
aggregation: 'SUM',
merge: 'SUM',
rule: { type: 'full' },
used_by_metrics: [
{ name: 'default.revenue_metric', display_name: 'Revenue' },
],
},
{
name: 'order_count',
expression: '*',
aggregation: 'COUNT',
merge: 'SUM',
rule: { type: 'full' },
used_by_metrics: [
{ name: 'default.order_count_metric', display_name: 'Order Count' },
],
},
],
sql: 'SELECT date_id, customer_id, SUM(revenue), COUNT(*) FROM orders GROUP BY 1, 2',
grain_group_hash: 'abc123',
strategy: 'full',
schedule: '0 0 * * *',
lookback_window: null,
workflow_urls: [
{ label: 'scheduled', url: 'http://scheduler/workflow/123.main' },
],
workflow_status: 'active',
status: 'active',
materialized_table_ref: 'analytics.preaggs.orders_fact_abc123',
max_partition: ['2024', '01', '15'],
related_metrics: ['default.revenue_metric', 'default.order_count_metric'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z',
},
{
id: 2,
node_revision_id: 1,
node_name: 'default.orders_fact',
node_version: 'v1.0',
grain_columns: ['default.product_dim.category'],
measures: [
{
name: 'total_quantity',
expression: 'quantity',
aggregation: 'SUM',
merge: 'SUM',
rule: { type: 'full' },
used_by_metrics: null,
},
],
sql: 'SELECT category, SUM(quantity) FROM orders GROUP BY 1',
grain_group_hash: 'def456',
strategy: 'incremental_time',
schedule: '0 * * * *',
lookback_window: '3 days',
workflow_urls: null,
workflow_status: null,
status: 'pending',
materialized_table_ref: null,
max_partition: null,
related_metrics: null,
created_at: '2024-01-10T00:00:00Z',
updated_at: null,
},
],
total: 2,
limit: 50,
offset: 0,
};
const mockPreaggsWithStale = {
items: [
...mockPreaggs.items,
{
id: 3,
node_revision_id: 0,
node_name: 'default.orders_fact',
node_version: 'v0.9', // Stale version
grain_columns: ['default.date_dim.date_id'],
measures: [
{
name: 'old_revenue',
expression: 'revenue',
aggregation: 'SUM',
merge: 'SUM',
rule: { type: 'full' },
used_by_metrics: null,
},
],
sql: 'SELECT date_id, SUM(revenue) FROM orders GROUP BY 1',
grain_group_hash: 'old123',
strategy: 'full',
schedule: '0 0 * * *',
workflow_urls: [
{ label: 'scheduled', url: 'http://scheduler/workflow/old.main' },
],
workflow_status: 'active',
status: 'active',
materialized_table_ref: 'analytics.preaggs.orders_fact_old',
max_partition: ['2024', '01', '10'],
related_metrics: null,
created_at: '2023-12-01T00:00:00Z',
updated_at: '2024-01-10T00:00:00Z',
},
],
total: 3,
limit: 50,
offset: 0,
};
const createMockDjClient = (preaggs = mockPreaggs) => ({
DataJunctionAPI: {
listPreaggs: vi.fn().mockResolvedValue(preaggs),
deactivatePreaggWorkflow: vi.fn().mockResolvedValue({ status: 'none' }),
bulkDeactivatePreaggWorkflows: vi.fn().mockResolvedValue({
deactivated_count: 1,
deactivated: [{ id: 3 }],
}),
},
});
const renderWithContext = (component, djClient) => {
return render(
<MemoryRouter>
<DJClientContext.Provider value={djClient}>
{component}
</DJClientContext.Provider>
</MemoryRouter>,
);
};
describe('<NodePreAggregationsTab />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Loading and Empty States', () => {
it('shows loading state initially', () => {
const djClient = createMockDjClient();
// Make the promise never resolve to keep loading state
djClient.DataJunctionAPI.listPreaggs.mockReturnValue(
new Promise(() => {}),
);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
expect(
screen.getByText('Loading pre-aggregations...'),
).toBeInTheDocument();
});
it('shows empty state when no pre-aggregations exist', async () => {
const djClient = createMockDjClient({
items: [],
total: 0,
limit: 50,
offset: 0,
});
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(
screen.getByText('No pre-aggregations found for this node.'),
).toBeInTheDocument();
});
});
it('shows error state when API fails', async () => {
const djClient = createMockDjClient();
djClient.DataJunctionAPI.listPreaggs.mockResolvedValue({
_error: true,
message: 'Failed to fetch',
});
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(
screen.getByText(/Error loading pre-aggregations/),
).toBeInTheDocument();
});
});
});
describe('Section Headers', () => {
it('renders "Current Pre-Aggregations" section header with version', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(
screen.getByText('Current Pre-Aggregations (v1.0)'),
).toBeInTheDocument();
});
});
it('renders "Stale Pre-Aggregations" section when stale preaggs exist', async () => {
const djClient = createMockDjClient(mockPreaggsWithStale);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(
screen.getByText('Stale Pre-Aggregations (1)'),
).toBeInTheDocument();
});
});
it('does not render stale section when no stale preaggs exist', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(
screen.getByText('Current Pre-Aggregations (v1.0)'),
).toBeInTheDocument();
});
expect(
screen.queryByText(/Stale Pre-Aggregations/),
).not.toBeInTheDocument();
});
});
describe('Pre-agg Row Header', () => {
it('renders grain columns as chips', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Should show short names from grain columns
expect(screen.getByText('date_id')).toBeInTheDocument();
expect(screen.getByText('customer_id')).toBeInTheDocument();
});
});
it('renders measure count', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('2 measures')).toBeInTheDocument();
expect(screen.getByText('1 measure')).toBeInTheDocument();
});
});
it('renders metric count badge when related metrics exist', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('2 metrics')).toBeInTheDocument();
});
});
it('renders status badges', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
});
it('renders schedule in human-readable format', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Schedule appears in both header and config section, so use getAllByText
const scheduleElements = screen.getAllByText(/at 12:00 am/i);
expect(scheduleElements.length).toBeGreaterThan(0);
});
});
});
describe('Expanded Details', () => {
it('expands first pre-agg by default', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Config section should be visible for the first expanded preagg
expect(screen.getByText('Config')).toBeInTheDocument();
expect(screen.getByText('Grain')).toBeInTheDocument();
expect(screen.getByText('Measures')).toBeInTheDocument();
});
});
it('shows Config section with strategy and schedule', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('Strategy')).toBeInTheDocument();
expect(screen.getByText('Full')).toBeInTheDocument();
expect(screen.getByText('Schedule')).toBeInTheDocument();
});
});
it('shows Grain section with dimension links', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Full grain column names should appear in expanded section
const grainBadges = screen.getAllByText(
/default\.(date_dim|customer_dim)\./,
);
expect(grainBadges.length).toBeGreaterThan(0);
});
});
it('shows Measures table with aggregation and merge info', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('total_revenue')).toBeInTheDocument();
expect(screen.getByText('SUM(revenue)')).toBeInTheDocument();
});
});
it('shows metrics that use each measure', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Display names should appear
expect(screen.getByText('Revenue')).toBeInTheDocument();
expect(screen.getByText('Order Count')).toBeInTheDocument();
});
});
it('toggles expansion when clicking row header', async () => {
// Use single preagg to simplify test
const singlePreagg = {
items: [mockPreaggs.items[0]],
total: 1,
limit: 50,
offset: 0,
};
const djClient = createMockDjClient(singlePreagg);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
// Wait for initial render with expanded state
await waitFor(() => {
expect(screen.getByText('Config')).toBeInTheDocument();
});
// Find and click the row header to collapse it
const measureText = screen.getByText('2 measures');
fireEvent.click(measureText.closest('.preagg-row-header'));
// After collapse, Config should no longer be visible
await waitFor(
() => {
expect(screen.queryByText('Config')).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
});
describe('Workflow Actions', () => {
it('renders workflow button with capitalized label', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
const scheduledBtn = screen.getByText('Scheduled');
expect(scheduledBtn).toBeInTheDocument();
expect(scheduledBtn.closest('a')).toHaveAttribute(
'href',
'http://scheduler/workflow/123.main',
);
});
});
it('renders deactivate button for active workflows', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('Deactivate')).toBeInTheDocument();
});
});
it('calls deactivatePreaggWorkflow when deactivate is clicked', async () => {
const djClient = createMockDjClient();
window.confirm = vi.fn(() => true);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(
() => {
expect(screen.getByText('Deactivate')).toBeInTheDocument();
},
{ timeout: 2000 },
);
fireEvent.click(screen.getByText('Deactivate'));
await waitFor(
() => {
expect(
djClient.DataJunctionAPI.deactivatePreaggWorkflow,
).toHaveBeenCalledWith(1);
},
{ timeout: 2000 },
);
});
});
describe('Stale Pre-aggregations', () => {
it('shows stale version warning in row header', async () => {
const djClient = createMockDjClient(mockPreaggsWithStale);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('was v0.9')).toBeInTheDocument();
});
});
it('shows "Deactivate All Stale" button when active stale workflows exist', async () => {
const djClient = createMockDjClient(mockPreaggsWithStale);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(screen.getByText('Deactivate All Stale')).toBeInTheDocument();
});
});
it('calls bulkDeactivatePreaggWorkflows when "Deactivate All Stale" is clicked', async () => {
const djClient = createMockDjClient(mockPreaggsWithStale);
window.confirm = vi.fn(() => true);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(
() => {
expect(screen.getByText('Deactivate All Stale')).toBeInTheDocument();
},
{ timeout: 2000 },
);
fireEvent.click(screen.getByText('Deactivate All Stale'));
await waitFor(
() => {
expect(
djClient.DataJunctionAPI.bulkDeactivatePreaggWorkflows,
).toHaveBeenCalledWith('default.orders_fact', true);
},
{ timeout: 2000 },
);
});
});
describe('Grain Truncation', () => {
it('shows "+N more" button when grain has more than MAX_VISIBLE_GRAIN columns', async () => {
const manyGrainPreaggs = {
items: [
{
...mockPreaggs.items[0],
grain_columns: [
'default.dim1.col1',
'default.dim2.col2',
'default.dim3.col3',
'default.dim4.col4',
'default.dim5.col5',
'default.dim6.col6',
'default.dim7.col7',
'default.dim8.col8',
'default.dim9.col9',
'default.dim10.col10',
'default.dim11.col11',
'default.dim12.col12',
],
},
],
total: 1,
limit: 50,
offset: 0,
};
const djClient = createMockDjClient(manyGrainPreaggs);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
// Should show "+N more" since there are 12 columns and MAX_VISIBLE_GRAIN is 10
expect(screen.getByText('+2 more')).toBeInTheDocument();
});
});
it('shows "Show less" after expanding grain list', async () => {
const manyGrainPreaggs = {
items: [
{
...mockPreaggs.items[0],
grain_columns: [
'default.dim1.col1',
'default.dim2.col2',
'default.dim3.col3',
'default.dim4.col4',
'default.dim5.col5',
'default.dim6.col6',
'default.dim7.col7',
'default.dim8.col8',
'default.dim9.col9',
'default.dim10.col10',
'default.dim11.col11',
'default.dim12.col12',
],
},
],
total: 1,
limit: 50,
offset: 0,
};
const djClient = createMockDjClient(manyGrainPreaggs);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(
() => {
expect(screen.getByText('+2 more')).toBeInTheDocument();
},
{ timeout: 2000 },
);
// Click to expand
fireEvent.click(screen.getByText('+2 more'));
await waitFor(
() => {
expect(screen.getByText('Show less')).toBeInTheDocument();
// All columns should now be visible
expect(screen.getByText('default.dim12.col12')).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
});
describe('API Integration', () => {
it('calls listPreaggs with include_stale=true', async () => {
const djClient = createMockDjClient();
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(() => {
expect(djClient.DataJunctionAPI.listPreaggs).toHaveBeenCalledWith({
node_name: 'default.orders_fact',
include_stale: true,
});
});
});
it('refreshes list after deactivate action', async () => {
const djClient = createMockDjClient();
window.confirm = vi.fn(() => true);
renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
await waitFor(
() => {
expect(screen.getByText('Deactivate')).toBeInTheDocument();
},
{ timeout: 2000 },
);
fireEvent.click(screen.getByText('Deactivate'));
await waitFor(
() => {
// Should be called twice: initial load + refresh after deactivate
expect(djClient.DataJunctionAPI.listPreaggs).toHaveBeenCalledTimes(2);
},
{ timeout: 2000 },
);
});
});
});