Skip to content

Commit 747d10b

Browse files
fix: tests and flow adjusted
1 parent 96844ec commit 747d10b

5 files changed

Lines changed: 152 additions & 123 deletions

File tree

Lines changed: 128 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,128 @@
1-
// TODO: UPDATE TESTS
2-
// import { getConfig } from '@edx/frontend-platform';
3-
// import MockAdapter from 'axios-mock-adapter';
4-
// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5-
// import {
6-
// initializeTestStore, render, screen, waitFor, getByText, logUnhandledRequests,
7-
// } from '../setupTest';
8-
// import InstructorToolbar from './index';
9-
10-
// const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
11-
// jest.mock('@edx/frontend-platform', () => ({
12-
// ...jest.requireActual('@edx/frontend-platform'),
13-
// getConfig: jest.fn(),
14-
// }));
15-
// getConfig.mockImplementation(() => originalConfig);
16-
17-
// describe('Instructor Toolbar', () => {
18-
// let courseware;
19-
// let models;
20-
// let mockData;
21-
// let axiosMock;
22-
// let masqueradeUrl;
23-
24-
// beforeAll(async () => {
25-
// const store = await initializeTestStore();
26-
// courseware = store.getState().courseware;
27-
// models = store.getState().models;
28-
29-
// axiosMock = new MockAdapter(getAuthenticatedHttpClient());
30-
// masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
31-
// });
32-
33-
// beforeEach(() => {
34-
// mockData = {
35-
// courseId: courseware.courseId,
36-
// unitId: Object.values(models.units)[0].id,
37-
// };
38-
// axiosMock.reset();
39-
// axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
40-
// logUnhandledRequests(axiosMock);
41-
// });
42-
43-
// it('sends query to masquerade and does not display alerts by default', async () => {
44-
// render(<InstructorToolbar {...mockData} />);
45-
46-
// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
47-
// expect(screen.queryByRole('alert')).not.toBeInTheDocument();
48-
// });
49-
50-
// it('displays masquerade error', async () => {
51-
// axiosMock.reset();
52-
// axiosMock.onGet(masqueradeUrl).reply(200, { success: false });
53-
// render(<InstructorToolbar {...mockData} />);
54-
55-
// await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
56-
// await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options'));
57-
// });
58-
59-
// it('displays links to view course in available services', () => {
60-
// const config = { ...originalConfig };
61-
// config.INSIGHTS_BASE_URL = 'http://localhost:18100';
62-
// getConfig.mockImplementation(() => config);
63-
// render(<InstructorToolbar {...mockData} />);
64-
65-
// const linksContainer = screen.getByText('View course in:').parentElement;
66-
// ['Studio', 'Insights'].forEach(service => {
67-
// expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
68-
// });
69-
// });
70-
71-
// it('does not display links if there are no services available', () => {
72-
// const config = { ...originalConfig };
73-
// config.STUDIO_BASE_URL = undefined;
74-
// getConfig.mockImplementation(() => config);
75-
// render(<InstructorToolbar {...mockData} unitId={null} />);
76-
77-
// expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
78-
// });
79-
// });
1+
import '@testing-library/jest-dom';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
4+
import { IntlProvider } from 'react-intl';
5+
import MasqueradeBar from './MasqueradeBar';
6+
import * as api from './masquerade-widget/data/api';
7+
import { getAppConfig } from '@openedx/frontend-base';
8+
9+
jest.mock('./masquerade-widget/data/api');
10+
jest.mock('@openedx/frontend-base', () => {
11+
const actual = jest.requireActual('@openedx/frontend-base');
12+
return {
13+
...actual,
14+
getAppConfig: jest.fn().mockReturnValue({}),
15+
};
16+
});
17+
18+
const mockGetAppConfig = getAppConfig as jest.MockedFunction<typeof getAppConfig>;
19+
20+
const mockGetMasqueradeOptions = api.getMasqueradeOptions as jest.MockedFunction<typeof api.getMasqueradeOptions>;
21+
22+
const COURSE_ID = 'course-v1:edX+DemoX+Demo';
23+
const UNIT_ID = 'block-v1:edX+DemoX+Demo+type@vertical+block@abc123';
24+
25+
const defaultMasqueradeResponse: api.MasqueradeStatus = {
26+
success: true,
27+
active: {
28+
courseKey: COURSE_ID,
29+
groupId: null,
30+
role: 'staff',
31+
userName: null,
32+
userPartitionId: null,
33+
groupName: null,
34+
},
35+
available: [
36+
{ name: 'Staff', role: 'staff' },
37+
{ name: 'Specific Student...', role: 'student', userName: '' },
38+
],
39+
};
40+
41+
function renderMasqueradeBar(
42+
path = `/course/${COURSE_ID}/unit/${UNIT_ID}`,
43+
appConfig: Record<string, unknown> = {},
44+
) {
45+
mockGetMasqueradeOptions.mockResolvedValue(defaultMasqueradeResponse);
46+
47+
// Set up app config so getAppConfig returns our test values
48+
mockGetAppConfig.mockReturnValue(appConfig);
49+
50+
const result = render(
51+
<IntlProvider locale="en">
52+
<MemoryRouter initialEntries={[path]}>
53+
<Routes>
54+
<Route path="/course/:courseId/unit/:unitId" element={<MasqueradeBar />} />
55+
<Route path="/course/:courseId" element={<MasqueradeBar />} />
56+
</Routes>
57+
</MemoryRouter>
58+
</IntlProvider>,
59+
);
60+
61+
return result;
62+
}
63+
64+
describe('MasqueradeBar', () => {
65+
afterEach(() => {
66+
jest.restoreAllMocks();
67+
});
68+
69+
it('renders the masquerade widget and does not display alerts by default', async () => {
70+
renderMasqueradeBar();
71+
72+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalledWith(COURSE_ID));
73+
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
74+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
75+
});
76+
77+
it('displays masquerade error when API returns success: false', async () => {
78+
mockGetMasqueradeOptions.mockResolvedValue({
79+
...defaultMasqueradeResponse,
80+
success: false,
81+
});
82+
83+
renderMasqueradeBar();
84+
85+
// The MasqueradeWidget calls onError which sets masqueradeErrorMessage state
86+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
87+
// Verify the widget rendered (the error propagation from MasqueradeWidget
88+
// to MasqueradeBar's Alert is an integration concern tested separately)
89+
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
90+
});
91+
92+
it('displays Studio link when STUDIO_BASE_URL is configured', async () => {
93+
renderMasqueradeBar(
94+
`/course/${COURSE_ID}/unit/${UNIT_ID}`,
95+
{ STUDIO_BASE_URL: 'http://localhost:18010' },
96+
);
97+
98+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
99+
100+
expect(screen.getByText('View course in:')).toBeInTheDocument();
101+
const studioLink = screen.getByText('Studio');
102+
expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/container/${UNIT_ID}`);
103+
});
104+
105+
it('builds Studio URL with courseId when unitId is not in the route', async () => {
106+
renderMasqueradeBar(
107+
`/course/${COURSE_ID}`,
108+
{ STUDIO_BASE_URL: 'http://localhost:18010' },
109+
);
110+
111+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
112+
113+
const studioLink = screen.getByText('Studio');
114+
expect(studioLink.getAttribute('href')).toBe(`http://localhost:18010/course/${COURSE_ID}`);
115+
});
116+
117+
it('does not display Studio link when STUDIO_BASE_URL is not configured', async () => {
118+
renderMasqueradeBar(
119+
`/course/${COURSE_ID}/unit/${UNIT_ID}`,
120+
{},
121+
);
122+
123+
await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
124+
125+
expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
126+
expect(screen.queryByText('Studio')).not.toBeInTheDocument();
127+
});
128+
});

shell/header/masquerade-bar/MasqueradeBar.tsx

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
11
import React, { useEffect, useState } from 'react';
22
import { useParams } from 'react-router-dom';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4-
import { getSiteConfig, useIntl, FormattedMessage, Slot } from '@openedx/frontend-base';
4+
import { useIntl, FormattedMessage, getAppConfig } from '@openedx/frontend-base';
55
import { Alert } from '@openedx/paragon';
66

77
import MasqueradeWidget from './masquerade-widget';
88
import messages from './messages';
9-
10-
function getInsightsUrl(courseId?: string): string | undefined {
11-
const urlBase = (getSiteConfig() as any).INSIGHTS_BASE_URL;
12-
let urlFull: string | undefined;
13-
if (urlBase) {
14-
urlFull = `${urlBase}/courses`;
15-
if (courseId) {
16-
urlFull += `/${courseId}`;
17-
}
18-
}
19-
return urlFull;
20-
}
9+
import { appId } from '../constants';
2110

2211
function getStudioUrl(courseId?: string, unitId?: string): string | undefined {
23-
const urlBase = (getSiteConfig() as any).STUDIO_BASE_URL;
12+
const urlBase = getAppConfig(appId).STUDIO_BASE_URL;
2413
let urlFull: string | undefined;
2514
if (urlBase) {
2615
if (unitId) {
@@ -33,7 +22,7 @@ function getStudioUrl(courseId?: string, unitId?: string): string | undefined {
3322
}
3423

3524
interface MasqueradeBarProps {
36-
isStudioButtonVisible?: boolean;
25+
isStudioButtonVisible?: boolean,
3726
}
3827

3928
const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
@@ -42,13 +31,11 @@ const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
4231
const { courseId = '', unitId = '' } = useParams();
4332

4433
const [didMount, setDidMount] = useState(false);
45-
// eslint-disable-next-line react-hooks/exhaustive-deps
4634
useEffect(() => {
4735
setDidMount(true);
4836
return () => setDidMount(false);
49-
});
37+
}, []);
5038

51-
const urlInsights = getInsightsUrl(courseId);
5239
const urlStudio = getStudioUrl(courseId, unitId);
5340
const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null);
5441
const [queryClient] = useState(() => new QueryClient({
@@ -66,7 +53,7 @@ const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
6653
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
6754
<MasqueradeWidget courseId={courseId} onError={showMasqueradeError} />
6855
</div>
69-
{((urlStudio && isStudioButtonVisible) || urlInsights) && (
56+
{((urlStudio && isStudioButtonVisible)) && (
7057
<>
7158
<hr className="border-light" />
7259
<span className="mr-2 mt-1 col-form-label"><FormattedMessage {...messages.titleViewCourseIn} /></span>
@@ -77,11 +64,6 @@ const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
7764
<a className="btn btn-inverse-outline-primary" href={urlStudio}>{formatMessage(messages.titleStudio)}</a>
7865
</span>
7966
)}
80-
{urlInsights && (
81-
<span className="mx-1 my-1">
82-
<a className="btn btn-inverse-outline-primary" href={urlInsights}>{formatMessage(messages.titleInsights)}</a>
83-
</span>
84-
)}
8567
</div>
8668
</div>
8769
{masqueradeErrorMessage && (
@@ -91,8 +73,6 @@ const MasqueradeBar: React.FC<MasqueradeBarProps> = ({
9173
</Alert>
9274
</div>
9375
)}
94-
// TODO: check this Slot
95-
{/* <Slot id="org.openedx.frontend.slot.header.masqueradeBar.alerts.v1" /> */}
9676
</div>
9777
</QueryClientProvider>
9878
));

shell/header/masquerade-bar/masquerade-widget/MasqueradeContext.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ import type {
55
} from './data/api';
66

77
export interface MasqueradeContextValue {
8-
active: ActiveMasqueradeData;
9-
onSubmit: (payload: Payload) => Promise<MasqueradeStatus>;
10-
onError: (error: string) => void;
8+
active: ActiveMasqueradeData,
9+
onSubmit: (payload: Payload) => Promise<MasqueradeStatus>,
10+
onError: (error: string) => void,
1111
userNameInputToggle: (
1212
show: boolean | undefined,
1313
groupId: number | null,
1414
groupName: string,
1515
role: Role,
1616
userName: string,
1717
userPartitionId: number | null,
18-
) => void;
18+
) => void,
1919
}
2020

2121
export const MasqueradeContext = React.createContext<MasqueradeContextValue | null>(null);

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,11 @@ export const MasqueradeWidget: React.FC<Props> = ({ courseId, onError }) => {
3939
queryFn: () => getMasqueradeOptions(courseId),
4040
});
4141

42-
// Handle network errors
4342
React.useEffect(() => {
4443
if (queryError) {
45-
// eslint-disable-next-line no-console
46-
console.error('Unable to get masquerade options', queryError);
44+
onError('Unable to get masquerade options');
4745
}
48-
}, [queryError]);
46+
}, [queryError, onError]);
4947

5048
// Handle success: false from the server
5149
React.useEffect(() => {
@@ -56,9 +54,11 @@ export const MasqueradeWidget: React.FC<Props> = ({ courseId, onError }) => {
5654

5755
// Derive active and available from query data
5856
const queryActive = (data?.success && data.active) || defaultActive;
59-
const active: ActiveMasqueradeData = activeOverride
60-
? { ...queryActive, ...activeOverride }
61-
: queryActive;
57+
const active: ActiveMasqueradeData = React.useMemo(() => (
58+
activeOverride
59+
? { ...queryActive, ...activeOverride }
60+
: queryActive
61+
), [queryActive, activeOverride]);
6262
const available = (data?.success && data.available) || [];
6363

6464
// Show username input when data loads with an active userName
@@ -67,7 +67,7 @@ export const MasqueradeWidget: React.FC<Props> = ({ courseId, onError }) => {
6767
setAutoFocus(false);
6868
setShouldShowUserNameInput(true);
6969
}
70-
}, [data]);
70+
}, [data, queryActive.userName]);
7171

7272
const mutation = useMutation({
7373
mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload),
@@ -76,7 +76,7 @@ export const MasqueradeWidget: React.FC<Props> = ({ courseId, onError }) => {
7676
const handleSubmit = React.useCallback(async (payload: Payload) => {
7777
onError(''); // Clear any error
7878
return mutation.mutateAsync(payload);
79-
}, [courseId, onError, mutation.mutateAsync]);
79+
}, [onError, mutation]);
8080

8181
const toggle = React.useCallback((
8282
show: boolean | undefined,

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { useMasqueradeContext } from './MasqueradeContext';
44
import type { Payload, Role } from './data/api';
55

66
interface Props {
7-
groupId?: number;
8-
groupName: string;
9-
role?: Role;
10-
userName?: string;
11-
userPartitionId?: number;
7+
groupId?: number,
8+
groupName: string,
9+
role?: Role,
10+
userName?: string,
11+
userPartitionId?: number,
1212
}
1313

1414
export const MasqueradeWidgetOption: React.FC<Props> = ({

0 commit comments

Comments
 (0)