Skip to content
250 changes: 250 additions & 0 deletions src/react/src/__tests__/components/SupplyChainNetwork.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { screen, fireEvent } from '@testing-library/react';
import renderWithProviders from '../../util/testUtils/renderWithProviders';
import SupplyChain from '../../components/ProductionLocation/Sidebar/SupplyChain/SupplyChain';

const publicContributor = {
id: 1,
contributor_name: 'Acme Brands',
contributor_type: 'Brand / Retailer',
list_name: 'Acme Supplier List 2025',
is_verified: false,
count: 1,
name: 'Acme Brands',
};

const anotherPublicContributor = {
id: 2,
contributor_name: 'Global Auditors Inc',
contributor_type: 'Auditor',
list_name: 'Verified Facilities Q1 2025',
is_verified: true,
count: 1,
name: 'Global Auditors Inc',
};

const nonPublicContributor = {
contributor_type: 'Civil Society Organization',
count: 3,
name: '3 Others',
};

const nonPublicContributorNullType = {
contributor_type: null,
count: 2,
name: '2 Others',
};

const renderSection = (contributors = []) =>
renderWithProviders(
<Router>
<SupplyChain contributors={contributors} />
</Router>,
);

describe('SupplyChainNetwork section', () => {
test('renders nothing when contributors array is empty', () => {
renderSection([]);
expect(
screen.queryByText('Supply Chain Network'),
).not.toBeInTheDocument();
});

test('renders nothing when all contributors have no name and no type', () => {
renderSection([{ id: 1, count: 1 }]);
expect(
screen.queryByText('Supply Chain Network'),
).not.toBeInTheDocument();
});

test('renders section title and subtitle when contributors exist', () => {
renderSection([publicContributor]);

expect(
screen.getByText('Supply Chain Network'),
).toBeInTheDocument();
expect(
screen.getByText(
/Organizations that have shared information about this production location/,
),
).toBeInTheDocument();
});

test('renders contributor type counts per line', () => {
renderSection([publicContributor, nonPublicContributor]);

expect(
screen.getByText(/Brand \/ Retailer/),
).toBeInTheDocument();
expect(
screen.getByText(/Civil Society Organization/),
).toBeInTheDocument();
});

test('renders public contributor names as links', () => {
renderSection([publicContributor, anotherPublicContributor]);

expect(screen.getByText('Acme Brands')).toBeInTheDocument();
expect(screen.getByText('Global Auditors Inc')).toBeInTheDocument();
});

test('renders public contributors grouped by contributor type', () => {
// Three contributors: two Brand / Retailers surrounding one Auditor in API order.
// After grouping the two Brand / Retailers must appear consecutively.
Comment on lines +93 to +94
Copy link
Collaborator

Choose a reason for hiding this comment

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

We usually try to follow the best practice of ensuring that comments explain why something was done a certain way, rather than how it works. The code itself should clearly communicate the 'how' through its logic and structure.

const brandA = {
id: 10,
contributor_name: 'Brand A',
contributor_type: 'Brand / Retailer',
list_name: 'List A',
count: 1,
};
const auditor = {
id: 11,
contributor_name: 'Solo Auditor',
contributor_type: 'Auditor',
list_name: 'Audit List',
count: 1,
};
const brandB = {
id: 12,
contributor_name: 'Brand B',
contributor_type: 'Brand / Retailer',
list_name: 'List B',
count: 1,
};

// API returns them interleaved: Brand A, Auditor, Brand B
renderSection([brandA, auditor, brandB]);

const links = screen
.getAllByRole('link')
.filter(el =>
['Brand A', 'Solo Auditor', 'Brand B'].includes(
el.textContent,
),
);

const names = links.map(el => el.textContent);
const brandAIndex = names.indexOf('Brand A');
const brandBIndex = names.indexOf('Brand B');
const auditorIndex = names.indexOf('Solo Auditor');

// The two Brand / Retailer contributors must not have the Auditor between them
expect(Math.abs(brandAIndex - brandBIndex)).toBe(1);
expect(auditorIndex).not.toBe(
Math.min(brandAIndex, brandBIndex) + 1,
);
});

test('renders "View all N data sources" trigger button', () => {
renderSection([publicContributor, nonPublicContributor]);

// totalCount = publicContributor.count(1) + nonPublicContributor.count(3) = 4
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks like it can be removed, can we delete this line?

expect(
screen.getByRole('button', { name: /View all 4 data sources/i }),
).toBeInTheDocument();
});

test('opens drawer when trigger button is clicked', () => {
renderSection([publicContributor]);

// Drawer content is in the DOM but aria-hidden when closed
const trigger = screen.getByRole('button', { name: /View all/i });
expect(trigger).toBeInTheDocument();

fireEvent.click(trigger);

// After clicking, the drawer close button should be accessible
expect(screen.getByLabelText('Close')).toBeInTheDocument();
});

test('filters out non-public contributors with null contributor_type', () => {
renderSection([publicContributor, nonPublicContributorNullType]);

fireEvent.click(
screen.getByRole('button', { name: /View all/i }),
);

// The null-type anonymous contributor should not appear in the drawer
expect(
screen.queryByText('2 Civil Society Organization'),
).not.toBeInTheDocument();
});
});

describe('SupplyChainNetworkDrawer', () => {
const openDrawer = contributors => {
renderSection(contributors);
fireEvent.click(screen.getByRole('button', { name: /View all/i }));
};

test('shows "All Data Sources" as the drawer title', () => {
openDrawer([publicContributor]);
expect(screen.getByText('All Data Sources')).toBeVisible();
});

test('shows total contributor count in subtitle', () => {
openDrawer([publicContributor, nonPublicContributor]);
// total = 1 + 3 = 4
expect(
screen.getByText(/4 organizations have shared data/i),
).toBeInTheDocument();
});

test('shows info box with explanatory text', () => {
openDrawer([publicContributor]);
expect(
screen.getByText(/Multiple organizations may have shared data/i),
).toBeInTheDocument();
});

test('shows "Learn more" link in info box', () => {
openDrawer([publicContributor]);
expect(
screen.getByText(/Learn more about our open data model/i),
).toBeInTheDocument();
});

test('shows public contributors by name under All Data Sources', () => {
openDrawer([publicContributor, anotherPublicContributor]);
// Names appear both in the section and in the drawer; getAll checks at least one
expect(
screen.getAllByText('Acme Brands').length,
).toBeGreaterThanOrEqual(1);
});

test('shows contributor type label for public contributors', () => {
openDrawer([publicContributor]);
// contributor_type "Brand / Retailer" appears as type label in drawer
expect(
screen.getAllByText('Brand / Retailer').length,
).toBeGreaterThanOrEqual(1);
});

test('shows Anonymized Data Sources section when non-public contributors exist', () => {
openDrawer([publicContributor, nonPublicContributor]);
expect(
screen.getByText('Anonymized Data Sources'),
).toBeInTheDocument();
});

test('does not show Anonymized Data Sources when only public contributors exist', () => {
openDrawer([publicContributor]);
expect(
screen.queryByText('Anonymized Data Sources'),
).not.toBeInTheDocument();
});

test('shows non-public contributors by type and count in anonymized section', () => {
openDrawer([publicContributor, nonPublicContributor]);
// "3 Civil Society Organization" may appear multiple times (type chips + anonymized section)
const matches = screen.getAllByText(/3 Civil Society Organization/);
expect(matches.length).toBeGreaterThanOrEqual(1);
});

test('drawer close button is present after opening', () => {
openDrawer([publicContributor]);
expect(screen.getByLabelText('Close')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@
<Grid className={classes.sidebar}>
<NavBar />
<ContributeFields osId={osID} />
<SupplyChain />
<SupplyChain
contributors={data?.properties?.contributors ?? []}

Check warning on line 107 in src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'data.properties' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=opensupplyhub_open-supply-hub&issues=AZzAONAZ3rRGJkHAlL83&open=AZzAONAZ3rRGJkHAlL83&pullRequest=907

Check warning on line 107 in src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'data.properties.contributors' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=opensupplyhub_open-supply-hub&issues=AZzAONAZ3rRGJkHAlL84&open=AZzAONAZ3rRGJkHAlL84&pullRequest=907
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of prop drilling, we could use Redux directly in the SupplyChain component. This would make it easier to move the component around if we need to restructure the tree later.

/>
</Grid>
</Grid>
<Grid item xs={12} md={10}>
Expand Down
Loading