Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export const DetailsAccordion = <T extends Record<string, unknown>>({
className='bg-neutral-light-2 dark:bg-neutral-dark-2 border-t dark:border-neutral-dark-4 first:border-none'
disabled={isDisabled}
key={key}
value={String(idx)}>
value={String(idx)}
data-testid={key}>
<AccordionHeader
className={clsx(
'h-16',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,73 @@
// SPDX-License-Identifier: Apache-2.0

import { Card, CardContent } from '@bloodhoundenterprise/doodleui';
import { Alert, AlertTitle } from '@mui/material';
import type { FileIngestCompletedTask, FileIngestJob } from 'js-client-library';
import { useFileUploadQuery } from '../../hooks';
import { IndicatorType } from '../../types';
import { DetailsAccordion } from '../DetailsAccordion';
import { StatusIndicator } from '../StatusIndicator';

/** Header for an individual file result */
const FileHeader: React.FC<FileIngestCompletedTask> = ({ file_name, errors }) => {
const isSuccess = errors.length === 0;
const FileHeader: React.FC<FileIngestCompletedTask> = ({ file_name, errors, warnings }) => {
const status: IndicatorType = (() => {
if (errors.length === 0 && warnings.length === 0) {
return 'good';
} else if (errors.length === 0) {
return 'pending';
}
return 'bad';
})();
const label = (() => {
if (errors.length === 0 && warnings.length === 0) {
return 'Success';
} else if (errors.length === 0) {
return 'Partial Success';
}
return 'Failure';
})();
return (
<div className='flex-grow text-left text-xs font-normal ml-4'>
<div className='text-base font-bold'>{file_name}</div>
<StatusIndicator status={isSuccess ? 'good' : 'bad'} label={isSuccess ? 'Success' : 'Failure'} />
<StatusIndicator status={status} label={label} />
</div>
);
};

/** Only displays content if ingest had errors */
/** Only displays content if ingest had errors or warnings */
const FileContent: React.FC<FileIngestCompletedTask> = (ingest) =>
ingest.errors.length > 0 ? <FileErrors {...ingest} /> : null;
ingest.errors.length > 0 || ingest.warnings.length > 0 ? <FileErrors {...ingest} /> : null;

/** Displays file ingest errors */
const FileErrors: React.FC<FileIngestCompletedTask> = ({ errors }) => (
/** Displays file ingest errors and warnings */
const FileErrors: React.FC<FileIngestCompletedTask> = ({ errors, warnings }) => (
<div className='p-3'>
<div className='p-3 bg-neutral-3'>
<div className='font-bold mb-2'>Error Message(s):</div>
{errors.map((error, index) => (
<div className='[&:not(:last-child)]:mb-2' key={index}>
{error}
</div>
))}
{errors.length > 0 && (
<Alert severity='error'>
<AlertTitle>{errors.length === 1 ? 'Error Message:' : 'Error Messages:'}</AlertTitle>
{errors.map((error, index) => (
<div className='[&:not(:last-child)]:mb-2' key={index}>
{error}
</div>
))}
</Alert>
)}
{warnings.length > 0 && (
<Alert severity='warning'>
<AlertTitle>{warnings.length === 1 ? 'Warning:' : 'Warnings:'}</AlertTitle>
{warnings.map((warning, index) => (
<div className='[&:not(:last-child)]:mb-2' key={index}>
{warning}
</div>
))}
</Alert>
)}
</div>
</div>
);

const isErrorFree = (ingest: FileIngestCompletedTask | null) => ingest?.errors.length === 0;
const isErrorAndWarningFree = (ingest: FileIngestCompletedTask | null) =>
ingest?.errors.length === 0 && ingest?.warnings.length === 0;

/** Displays the ingest ID */
const IngestHeader: React.FC<FileIngestJob> = ({ id }) => <div className='ml-4'>ID {id}</div>;
Expand All @@ -62,7 +94,12 @@ const IngestContent: React.FC<FileIngestJob> = (ingest) => {

return (
<div className='max-h-[calc(100vh-16rem)] overflow-y-auto'>
<DetailsAccordion Content={FileContent} Header={FileHeader} itemDisabled={isErrorFree} items={items} />
<DetailsAccordion
Content={FileContent}
Header={FileHeader}
itemDisabled={isErrorAndWarningFree}
items={items}
/>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
//
// SPDX-License-Identifier: Apache-2.0

import type { FileIngestJob } from 'js-client-library';
import userEvent from '@testing-library/user-event';
import type { FileIngestCompletedTasksResponse, FileIngestJob } from 'js-client-library';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import * as useFileUpload from '../../hooks/useFileUploadQuery/useFileUploadQuery';
import { act, render, screen } from '../../test-utils';
import { FileIngestTable } from './FileIngestTable';

Expand Down Expand Up @@ -52,9 +54,33 @@ const MOCK_INGEST_JOB: FileIngestJob = {
},
};

const MOCK_PARTIAL_SUCCESS_INGEST_JOB: FileIngestJob = {
user_id: '1234',
user_email_address: 'spam@example.com',
status: 8,
status_message: 'Partially Completed',
start_time: '2024-08-15T21:25:21.990437Z',
end_time: '2024-08-15T21:26:43.033448Z',
last_ingest: '2024-08-15T21:26:43.033448Z',
id: 9,
total_files: 10,
failed_files: 0,
created_at: '',
updated_at: '',
deleted_at: {
Time: '',
Valid: false,
},
};

const MOCK_INGEST_JOBS_RESPONSE = {
count: 20,
data: new Array(10).fill(MOCK_INGEST_JOB).map((item, index) => ({
// fill the array with data
data: Array.from({ length: 10 }, (_, index) => {
if (index % 2 === 0) {
return MOCK_INGEST_JOB;
} else return MOCK_PARTIAL_SUCCESS_INGEST_JOB;
}).map((item, index) => ({
...item,
id: index,
status: (index % 10) - 1,
Expand All @@ -63,6 +89,25 @@ const MOCK_INGEST_JOBS_RESPONSE = {
skip: 10,
};

const MOCK_COMPLETED_TASKS_RESPONSE: FileIngestCompletedTasksResponse = {
data: [
{
file_name: 'generic-with-failed-edges.json',
parent_file_name: '',
errors: [],
warnings: [
'skipping invalid relationship. unable to resolve endpoints. source: NON2@EXISTING.NODE, target: NON1@EXISTING.NODE',
],
id: 9,
created_at: '2026-01-14T00:17:40.255611Z',
updated_at: '2026-01-14T00:17:40.255611Z',
deleted_at: {
Time: '0001-01-01T00:00:00Z',
Valid: false,
},
},
],
};
const server = setupServer(
rest.get('/api/v2/file-upload', (req, res, ctx) => res(ctx.json(MOCK_INGEST_JOBS_RESPONSE))),
rest.get('/api/v2/features', (req, res, ctx) => {
Expand All @@ -89,6 +134,9 @@ afterAll(() => {
server.resetHandlers();
});

const useFileUploadQuerySpy = vi.spyOn(useFileUpload, 'useFileUploadQuery');
useFileUploadQuerySpy.mockReturnValue({ data: MOCK_COMPLETED_TASKS_RESPONSE, isSuccess: true } as any);

describe('FileIngestTable', () => {
it('shows a loading state', () => {
checkPermissionMock.mockImplementation(() => true);
Expand All @@ -107,4 +155,27 @@ describe('FileIngestTable', () => {
const jobStatus = await screen.findByText('Complete');
expect(jobStatus).toHaveTextContent('Complete');
});
it('shows a table with partially completed jobs', async () => {
checkPermissionMock.mockImplementation(() => true);
await act(async () => render(<FileIngestTable />));
const user = userEvent.setup();

const jobID = await screen.getByRole('button', { name: 'View ingest 9 details' });
await user.click(jobID);

const jobsDropdown = await screen.getAllByTestId('0');
expect(jobsDropdown[0]).toBeInTheDocument();

await user.click(jobsDropdown[0]);

const partiallyCompletedJob = await screen.findByText('generic-with-failed-edges.json');
expect(partiallyCompletedJob).toBeInTheDocument();

await user.click(partiallyCompletedJob);

const warningText = await screen.findByText(
'skipping invalid relationship. unable to resolve endpoints. source: NON2@EXISTING.NODE, target: NON1@EXISTING.NODE'
);
expect(warningText).toBeInTheDocument();
Comment on lines +158 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd packages/javascript/bh-shared-ui && find . -name "FileIngestTable.test.tsx" -type f

Repository: SpecterOps/BloodHound

Length of output: 122


🏁 Script executed:

cat -n packages/javascript/bh-shared-ui/src/components/FileIngestTable/FileIngestTable.test.tsx | head -50

Repository: SpecterOps/BloodHound

Length of output: 2141


🏁 Script executed:

cat -n packages/javascript/bh-shared-ui/src/components/FileIngestTable/FileIngestTable.test.tsx | sed -n '158,179p'

Repository: SpecterOps/BloodHound

Length of output: 1182


🏁 Script executed:

# Check test-utils location and what it exports
find packages/javascript/bh-shared-ui -name "test-utils*" -type f

Repository: SpecterOps/BloodHound

Length of output: 116


🏁 Script executed:

# Check imports at the top of FileIngestTable.test.tsx
cat -n packages/javascript/bh-shared-ui/src/components/FileIngestTable/FileIngestTable.test.tsx | sed -n '1,20p'

Repository: SpecterOps/BloodHound

Length of output: 1035


🏁 Script executed:

# Check similar patterns in other test files
rg "screen\.(getByRole|getAllByTestId)" packages/javascript/bh-shared-ui/src/components --type tsx -n | head -20

Repository: SpecterOps/BloodHound

Length of output: 92


🏁 Script executed:

# Check for findByRole/findAllByTestId usage in test files
rg "screen\.(findByRole|findAllByTestId)" packages/javascript/bh-shared-ui/src/components --type tsx -n | head -20

Repository: SpecterOps/BloodHound

Length of output: 92


🏁 Script executed:

cat -n packages/javascript/bh-shared-ui/src/test-utils.jsx

Repository: SpecterOps/BloodHound

Length of output: 3763


🏁 Script executed:

# Try with correct file extension
rg "screen\.(getByRole|getAllByTestId)" packages/javascript/bh-shared-ui/src/components -A 2 | head -30

Repository: SpecterOps/BloodHound

Length of output: 3405


🏁 Script executed:

# Check for findByRole/findAllByTestId usage patterns
rg "screen\.find" packages/javascript/bh-shared-ui/src/components -A 1 | head -20

Repository: SpecterOps/BloodHound

Length of output: 1938


🏁 Script executed:

# Check what screen methods are being used most frequently
rg "screen\.(get|find|query)" packages/javascript/bh-shared-ui/src/components --no-heading | sed 's/.*screen\./screen\./' | cut -d'(' -f1 | sort | uniq -c | sort -rn | head -15

Repository: SpecterOps/BloodHound

Length of output: 489


Use async queries for elements that appear after async operations.
The button and dropdown elements are obtained after async render and user interactions; getByRole/getAllByTestId do not wait for elements to appear. Use findByRole/findAllByTestId instead to ensure elements are present before querying, preventing potential race conditions. This is consistent with the async pattern used elsewhere in the same test (lines 171, 176).

Suggested fix
-        const jobID = await screen.getByRole('button', { name: 'View ingest 9 details' });
+        const jobID = await screen.findByRole('button', { name: 'View ingest 9 details' });
@@
-        const jobsDropdown = await screen.getAllByTestId('0');
+        const jobsDropdown = await screen.findAllByTestId('0');
🤖 Prompt for AI Agents
In
`@packages/javascript/bh-shared-ui/src/components/FileIngestTable/FileIngestTable.test.tsx`
around lines 158 - 179, Replace non-awaiting queries with async find* variants
in the test: change the calls that fetch the job button and dropdown from
getByRole/getAllByTestId to findByRole/findAllByTestId so the test waits for
elements rendered after async actions; specifically update the variables in
FileIngestTable.test.tsx where jobID is obtained (currently using getByRole) and
where jobsDropdown is obtained (currently using getAllByTestId) to use
findByRole and findAllByTestId respectively, keeping the existing
await/user.click sequence unchanged.

});
});
1 change: 1 addition & 0 deletions packages/javascript/js-client-library/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ export type FileIngestJob = TimestampFields & {

export type FileIngestCompletedTask = TimestampFields & {
errors: string[];
warnings: string[];
file_name: string;
id: number;
parent_file_name: string;
Expand Down