richie-education 2.25.0-b2.dev32 → 2.25.0-b2.dev35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/js/api/joanie.ts +26 -3
  2. package/js/api/lms/dummy.spec.ts +9 -1
  3. package/js/api/lms/dummy.ts +12 -2
  4. package/js/contexts/SessionContext/BaseSessionProvider.tsx +4 -12
  5. package/js/contexts/SessionContext/JoanieSessionProvider.tsx +4 -11
  6. package/js/contexts/SessionContext/index.spec.tsx +2 -4
  7. package/js/hooks/useContractArchive/index.download.spec.tsx +119 -0
  8. package/js/hooks/useContractArchive/index.spec.tsx +91 -0
  9. package/js/hooks/useContractArchive/index.ts +83 -0
  10. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.spec.tsx +136 -0
  11. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.timer.spec.tsx +144 -0
  12. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.tsx +73 -0
  13. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +166 -0
  14. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.tsx +23 -8
  15. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +74 -0
  16. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.spec.tsx +124 -0
  17. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.tsx +73 -0
  18. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.spec.ts +85 -0
  19. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.ts +50 -0
  20. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.spec.tsx +266 -0
  21. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +153 -0
  22. package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.spec.tsx +100 -0
  23. package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx +23 -0
  24. package/js/settings.ts +7 -0
  25. package/js/types/Joanie.ts +5 -0
  26. package/js/utils/errors/HttpError.ts +1 -0
  27. package/package.json +1 -1
package/js/api/joanie.ts CHANGED
@@ -12,7 +12,7 @@ import queryString from 'query-string';
12
12
  import type * as Joanie from 'types/Joanie';
13
13
  import { AuthenticationApi } from 'api/authentication';
14
14
  import context from 'utils/context';
15
- import { HttpError } from 'utils/errors/HttpError';
15
+ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
16
16
  import { JOANIE_API_VERSION } from 'settings';
17
17
  import { ResourcesQuery } from 'hooks/useResources';
18
18
  import { ObjectHelper } from 'utils/ObjectHelper';
@@ -20,14 +20,15 @@ import { Maybe } from 'types/utils';
20
20
 
21
21
  interface CheckStatusOptions {
22
22
  fallbackValue: any;
23
- ignoredErrorStatus: number[];
23
+ ignoredErrorStatus: (number | HttpStatusCode)[];
24
24
  }
25
25
 
26
26
  export function getResponseBody(response: Response) {
27
27
  if (response.headers.get('Content-Type') === 'application/json') {
28
28
  return response.json();
29
29
  }
30
- if (response.headers.get('Content-Type') === 'application/pdf') {
30
+ const fileType = ['application/pdf', 'application/zip'];
31
+ if (fileType.includes(response.headers.get('Content-Type') || '')) {
31
32
  return response.blob();
32
33
  }
33
34
  return response.text();
@@ -154,6 +155,10 @@ export const getRoutes = () => {
154
155
  contracts: {
155
156
  get: `${baseUrl}/contracts/:id/`,
156
157
  download: `${baseUrl}/contracts/:id/download/`,
158
+ zip_archive: {
159
+ create: `${baseUrl}/contracts/zip-archive/`,
160
+ get: `${baseUrl}/contracts/zip-archive/:id/`,
161
+ },
157
162
  },
158
163
  },
159
164
  organizations: {
@@ -379,6 +384,24 @@ const API = (): Joanie.API => {
379
384
  method: 'GET',
380
385
  }).then(checkStatus);
381
386
  },
387
+ zip_archive: {
388
+ check: async (id) => {
389
+ return fetchWithJWT(ROUTES.user.contracts.zip_archive.get.replace(':id', id), {
390
+ method: 'OPTIONS',
391
+ });
392
+ },
393
+ create: async (payload) => {
394
+ return fetchWithJWT(ROUTES.user.contracts.zip_archive.create, {
395
+ method: 'POST',
396
+ body: JSON.stringify(payload),
397
+ }).then(checkStatus);
398
+ },
399
+ get: async (id) => {
400
+ return fetchWithJWT(ROUTES.user.contracts.zip_archive.get.replace(':id', id), {
401
+ method: 'GET',
402
+ }).then(checkStatus);
403
+ },
404
+ },
382
405
  },
383
406
  },
384
407
  organizations: {
@@ -36,7 +36,15 @@ describe('Dummy API', () => {
36
36
 
37
37
  describe('user', () => {
38
38
  it('simulates that authenticated user is admin', async () => {
39
- const response = await BaseAPI.user.me();
39
+ // Not logged-in.
40
+ let response = await BaseAPI.user.me();
41
+ expect(response).toBeNull();
42
+
43
+ // Log-in.
44
+ await BaseAPI.user.login();
45
+
46
+ // Is logged-in.
47
+ response = await BaseAPI.user.me();
40
48
  expect(response?.username).toBe('admin');
41
49
  expect(response?.access_token).toBeDefined();
42
50
  });
@@ -38,6 +38,8 @@ const JOANIE_DEV_DEMO_USER_JWT_TOKENS = {
38
38
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzMzOTI4MjE0LCJpYXQiOjE3MDIzOTIyMTQsImp0aSI6ImNkZjAyMGM4ODdjOTQxYzU5ZmExN2FkZGExNjNjMDIzIiwiZW1haWwiOiJqZWFuLWJhcHRpc3RlLnBlbnJhdGgrc3R1ZGVudF91c2VyQGZ1bi1tb29jLmZyIiwibGFuZ3VhZ2UiOiJmci1mciIsInVzZXJuYW1lIjoic3R1ZGVudF91c2VyIiwiZnVsbF9uYW1lIjoiXHUwMGM5dHVkaWFudCJ9.JMdnC2VXwq2VbNPrIYxj8PEq0oJJ4LZZT_ywWyE1lBM',
39
39
  };
40
40
 
41
+ export const RICHIE_DUMMY_IS_LOGGED_IN = 'RICHIE_DUMMY_IS_LOGGED_IN';
42
+
41
43
  function getUserInfo(username: keyof typeof JOANIE_DEV_DEMO_USER_JWT_TOKENS): Maybe<User> {
42
44
  const accessToken = JOANIE_DEV_DEMO_USER_JWT_TOKENS[username];
43
45
  const JWTPayload: JWTPayload = JSON.parse(base64Decode(accessToken.split('.')[1]));
@@ -71,11 +73,19 @@ const API = (APIConf: LMSBackend | AuthenticationBackend): APILms => {
71
73
  "username": "admin",
72
74
  }
73
75
  */
76
+ if (!localStorage.getItem(RICHIE_DUMMY_IS_LOGGED_IN)) {
77
+ return null;
78
+ }
74
79
  return getUserInfo(CURRENT_JOANIE_DEV_DEMO_USER) || null;
75
80
  },
76
- login: () => location.reload(),
81
+ login: () => {
82
+ localStorage.setItem(RICHIE_DUMMY_IS_LOGGED_IN, 'true');
83
+ location.reload();
84
+ },
77
85
  register: () => location.reload(),
78
- logout: async () => undefined,
86
+ logout: async () => {
87
+ localStorage.removeItem(RICHIE_DUMMY_IS_LOGGED_IN);
88
+ },
79
89
  accessToken: () => sessionStorage.getItem(RICHIE_USER_TOKEN),
80
90
  },
81
91
  enrollment: {
@@ -47,29 +47,21 @@ const BaseSessionProvider = ({ children }: PropsWithChildren<any>) => {
47
47
  AuthenticationApi!.register();
48
48
  }, [queryClient]);
49
49
 
50
- const invalidate = useCallback(() => {
51
- /*
52
- Invalidate all queries except 'user' as we can set it to null manually
53
- after logout to avoid extra requests
54
- */
50
+ const destroy = useCallback(async () => {
51
+ await AuthenticationApi!.logout();
52
+ sessionStorage.removeItem(REACT_QUERY_SETTINGS.cacheStorage.key);
55
53
  queryClient.removeQueries({
56
54
  predicate: (query: any) =>
57
55
  query.options.queryKey.includes('user') && query.options.queryKey.length > 1,
58
56
  });
59
57
  queryClient.setQueryData(['user'], null);
60
- }, [queryClient]);
61
-
62
- const destroy = useCallback(async () => {
63
- invalidate();
64
- await AuthenticationApi!.logout();
65
- }, [invalidate]);
58
+ }, []);
66
59
 
67
60
  const context = useMemo(
68
61
  () => ({
69
62
  user,
70
63
  isPending,
71
64
  destroy,
72
- invalidate,
73
65
  login,
74
66
  register,
75
67
  }),
@@ -75,23 +75,16 @@ const JoanieSessionProvider = ({ children }: React.PropsWithChildren<{}>) => {
75
75
  AuthenticationApi!.register();
76
76
  }, [queryClient]);
77
77
 
78
- const invalidate = useCallback(() => {
79
- /*
80
- Invalidate all queries except 'user' as we can set it to null manually
81
- after logout to avoid extra requests
82
- */
78
+ const destroy = useCallback(async () => {
79
+ await AuthenticationApi!.logout();
80
+ sessionStorage.removeItem(REACT_QUERY_SETTINGS.cacheStorage.key);
83
81
  sessionStorage.removeItem(RICHIE_USER_TOKEN);
84
82
  queryClient.removeQueries({
85
83
  predicate: (query: any) =>
86
84
  query.options.queryKey.includes('user') && query.options.queryKey.length > 1,
87
85
  });
88
86
  queryClient.setQueryData(['user'], null);
89
- }, [queryClient]);
90
-
91
- const destroy = useCallback(async () => {
92
- invalidate();
93
- await AuthenticationApi!.logout();
94
- }, [invalidate]);
87
+ }, []);
95
88
 
96
89
  useEffect(() => {
97
90
  if (user) {
@@ -169,10 +169,8 @@ describe('SessionProvider', () => {
169
169
  jest.runOnlyPendingTimers();
170
170
  });
171
171
 
172
- expect(result.current.user).toBeNull();
173
- expect(sessionStorage.getItem(REACT_QUERY_SETTINGS.cacheStorage.key)).toMatch(
174
- /"data":null,.*"queryKey":\["user"]/,
175
- );
172
+ await waitFor(() => expect(result.current.user).toBeNull());
173
+ expect(sessionStorage.getItem(REACT_QUERY_SETTINGS.cacheStorage.key)).toBeNull();
176
174
  });
177
175
 
178
176
  it('does not make request if there is a valid session in cache', async () => {
@@ -0,0 +1,119 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { PropsWithChildren } from 'react';
3
+ import { QueryClientProvider } from '@tanstack/react-query';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { fireEvent, renderHook, waitFor } from '@testing-library/react';
6
+ import { faker } from '@faker-js/faker';
7
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
8
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
9
+ import { handle } from 'utils/errors/handle';
10
+ import { SessionProvider } from 'contexts/SessionContext';
11
+ import { Deferred } from 'utils/test/deferred';
12
+ import { HttpStatusCode } from 'utils/errors/HttpError';
13
+ import useContractArchive from '.';
14
+
15
+ jest.mock('utils/errors/handle');
16
+ jest.mock('utils/context', () => ({
17
+ __esModule: true,
18
+ default: mockRichieContextFactory({
19
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
20
+ joanie_backend: { endpoint: 'https://joanie.test' },
21
+ }).one(),
22
+ }));
23
+
24
+ const mockHandle = handle as jest.MockedFn<typeof handle>;
25
+
26
+ describe('useContractArchive', () => {
27
+ beforeEach(() => {
28
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
29
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
30
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
31
+ });
32
+
33
+ beforeAll(() => {
34
+ // eslint-disable-next-line compat/compat
35
+ URL.createObjectURL = jest.fn();
36
+ // eslint-disable-next-line compat/compat
37
+ URL.revokeObjectURL = jest.fn();
38
+ HTMLAnchorElement.prototype.click = jest.fn();
39
+ });
40
+
41
+ afterEach(() => {
42
+ jest.clearAllMocks();
43
+ fetchMock.restore();
44
+ });
45
+
46
+ const Wrapper = ({ children }: PropsWithChildren) => {
47
+ return (
48
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
49
+ <IntlProvider locale="en">
50
+ <SessionProvider>{children}</SessionProvider>
51
+ </IntlProvider>
52
+ </QueryClientProvider>
53
+ );
54
+ };
55
+
56
+ it('downloads the certificate', async () => {
57
+ const archiveId = faker.string.uuid();
58
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/contracts/zip-archive/${archiveId}/`;
59
+ const deferred = new Deferred();
60
+ fetchMock.get(DOWNLOAD_URL, deferred.promise);
61
+
62
+ const { result } = renderHook(() => useContractArchive(), {
63
+ wrapper: Wrapper,
64
+ });
65
+ await waitFor(() => expect(result.current).not.toBeNull());
66
+
67
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
68
+ // eslint-disable-next-line compat/compat
69
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
70
+ // eslint-disable-next-line compat/compat
71
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
72
+
73
+ result.current.methods.get(archiveId);
74
+ deferred.resolve(HttpStatusCode.OK);
75
+
76
+ await waitFor(() => {
77
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
78
+ // eslint-disable-next-line compat/compat
79
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
80
+ // eslint-disable-next-line compat/compat
81
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
82
+ });
83
+
84
+ // - A event listener should have attached to window to know when window is blurred.
85
+ // This event is triggered in browser when the download pop up is displayed.
86
+ fireEvent.blur(window);
87
+ // eslint-disable-next-line compat/compat
88
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it('handles an error if certificate download request fails', async () => {
92
+ const archiveId = faker.string.uuid();
93
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/contracts/zip-archive/${archiveId}/`;
94
+ fetchMock.get(DOWNLOAD_URL, HttpStatusCode.UNAUTHORIZED);
95
+
96
+ const { result } = renderHook(() => useContractArchive(), {
97
+ wrapper: Wrapper,
98
+ });
99
+ await waitFor(() => expect(result.current).not.toBeNull());
100
+
101
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
102
+ expect(mockHandle).not.toHaveBeenCalled();
103
+ // eslint-disable-next-line compat/compat
104
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
105
+ // eslint-disable-next-line compat/compat
106
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
107
+
108
+ await result.current.methods.get(archiveId);
109
+
110
+ await waitFor(() => {
111
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
112
+ expect(mockHandle).toHaveBeenNthCalledWith(1, new Error('Unauthorized'));
113
+ // eslint-disable-next-line compat/compat
114
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
115
+ // eslint-disable-next-line compat/compat
116
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,91 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import fetchMock from 'fetch-mock';
3
+ import { renderHook } from '@testing-library/react';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { PropsWithChildren } from 'react';
6
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
+ import JoanieApiProvider from 'contexts/JoanieApiContext';
8
+ import { HttpStatusCode } from 'utils/errors/HttpError';
9
+ import useContractArchive from '.';
10
+
11
+ jest.mock('utils/context', () => ({
12
+ __esModule: true,
13
+ default: mockRichieContextFactory({
14
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
15
+ joanie_backend: { endpoint: 'https://joanie.test' },
16
+ }).one(),
17
+ }));
18
+
19
+ describe('useContractArchive', () => {
20
+ const Wrapper = ({ children }: PropsWithChildren) => {
21
+ return (
22
+ <IntlProvider locale="en">
23
+ <JoanieApiProvider>{children}</JoanieApiProvider>
24
+ </IntlProvider>
25
+ );
26
+ };
27
+ afterEach(() => {
28
+ fetchMock.restore();
29
+ });
30
+ it.each([
31
+ {
32
+ label: `response code: ${HttpStatusCode.NO_CONTENT}`,
33
+ statusCode: HttpStatusCode.NO_CONTENT,
34
+ expectedValue: true,
35
+ },
36
+ {
37
+ label: `response code: ${HttpStatusCode.NOT_FOUND}`,
38
+ statusCode: HttpStatusCode.NOT_FOUND,
39
+ expectedValue: false,
40
+ },
41
+ {
42
+ label: `response code: ${HttpStatusCode.FORBIDDEN}`,
43
+ statusCode: HttpStatusCode.FORBIDDEN,
44
+ expectedValue: false,
45
+ },
46
+ {
47
+ label: `response code: ${HttpStatusCode.INTERNAL_SERVER_ERROR}`,
48
+ statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
49
+ expectedValue: false,
50
+ },
51
+ {
52
+ label: `response code: ${HttpStatusCode.UNAUTHORIZED}`,
53
+ statusCode: HttpStatusCode.UNAUTHORIZED,
54
+ expectedValue: false,
55
+ },
56
+ ])(
57
+ 'check method should return the right value for response code: $label',
58
+ async ({ statusCode, expectedValue }) => {
59
+ const contractArchiveId = faker.string.uuid();
60
+ fetchMock.mock(
61
+ (url, options) => {
62
+ return (
63
+ options.method === 'OPTIONS' &&
64
+ url === `https://joanie.test/api/v1.0/contracts/zip-archive/${contractArchiveId}/`
65
+ );
66
+ },
67
+ new Response('', { status: statusCode }),
68
+ );
69
+
70
+ const { result } = renderHook(useContractArchive, {
71
+ wrapper: Wrapper,
72
+ });
73
+ const response = await result.current.methods.check(contractArchiveId);
74
+ expect(response).toBe(expectedValue);
75
+ },
76
+ );
77
+
78
+ it('create should return a contractArchiveId', async () => {
79
+ const contractArchiveId = faker.string.uuid();
80
+ fetchMock.post('https://joanie.test/api/v1.0/contracts/zip-archive/', {
81
+ url: `http://test.url/uri/${contractArchiveId}/uri/?toto=tata`,
82
+ });
83
+ const { result } = renderHook(useContractArchive, {
84
+ wrapper: Wrapper,
85
+ });
86
+
87
+ const organizationId = faker.string.uuid();
88
+ const response = await result.current.methods.create(organizationId);
89
+ expect(response).toBe(contractArchiveId);
90
+ });
91
+ });
@@ -0,0 +1,83 @@
1
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
2
+ import { HttpStatusCode } from 'utils/errors/HttpError';
3
+ import { handle } from 'utils/errors/handle';
4
+
5
+ const extractArchiveId = (url: string): string => {
6
+ const uuidRegex = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/;
7
+ const match = url.match(uuidRegex);
8
+
9
+ if (match === null) {
10
+ const error = new Error(
11
+ 'Unable to extract `contractArchiveId` from `contract.zip_archive.create` response',
12
+ );
13
+ handle(error);
14
+ throw error;
15
+ }
16
+
17
+ return match[0];
18
+ };
19
+
20
+ // TODO: should be factorized with useDownloadCertificate
21
+ // and maybe DownloadContractButton
22
+ const buildArchiveFromBlob = (fileName: string, blob: Blob) => {
23
+ // eslint-disable-next-line compat/compat
24
+ const url = URL.createObjectURL(blob);
25
+ const $link = document.createElement('a');
26
+ $link.href = url;
27
+ $link.download = fileName;
28
+
29
+ const revokeObject = () => {
30
+ // eslint-disable-next-line compat/compat
31
+ URL.revokeObjectURL(url);
32
+ window.removeEventListener('blur', revokeObject);
33
+ };
34
+
35
+ window.addEventListener('blur', revokeObject);
36
+ $link.click();
37
+ };
38
+
39
+ const useContractArchive = () => {
40
+ const api = useJoanieApi();
41
+ return {
42
+ methods: {
43
+ check: async (archiveId: string): Promise<boolean> => {
44
+ const response = await api.user.contracts.zip_archive.check(archiveId);
45
+
46
+ if (response.ok) {
47
+ if (response.status === HttpStatusCode.NO_CONTENT) {
48
+ return true;
49
+ }
50
+ handle(
51
+ new Error(
52
+ `Unknown success code ${response.status} for OPTION request to contract's zip_archive endpoint.`,
53
+ ),
54
+ );
55
+ }
56
+
57
+ return false;
58
+ },
59
+ get: async (archiveId: string): Promise<true | void> => {
60
+ try {
61
+ const response = api.user.contracts.zip_archive.get(archiveId);
62
+ buildArchiveFromBlob('contracts.zip', await response);
63
+ return true;
64
+ } catch (error) {
65
+ handle(error);
66
+ }
67
+
68
+ // FIXME: two thing could happen here with HttpStatusCode.NOT_FOUND response:
69
+ // * nothing found because the zip is generating.
70
+ // * nothing found because the zip doesn't and will never exist.
71
+ },
72
+ create: async (organizationId: string): Promise<string> => {
73
+ const response = await api.user.contracts.zip_archive.create({
74
+ organization_id: organizationId,
75
+ });
76
+
77
+ return extractArchiveId(response.url);
78
+ },
79
+ },
80
+ };
81
+ };
82
+
83
+ export default useContractArchive;
@@ -0,0 +1,136 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { PropsWithChildren } from 'react';
5
+ import { QueryClientProvider } from '@tanstack/react-query';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
8
+ import JoanieApiProvider from 'contexts/JoanieApiContext';
9
+
10
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
11
+ import { ContractDownloadStatus } from 'pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive';
12
+
13
+ import BulkDownloadContractButton from '.';
14
+
15
+ jest.mock('utils/context', () => ({
16
+ __esModule: true,
17
+ default: mockRichieContextFactory({
18
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
19
+ joanie_backend: { endpoint: 'https://joanie.test' },
20
+ }).one(),
21
+ }));
22
+
23
+ let mockDownloadContractArchive = jest.fn(() => Promise<void>);
24
+ let mockCreateContractArchive = jest.fn(() => Promise<void>);
25
+ let mockDownloadContractArchiveStatus: ContractDownloadStatus;
26
+ jest.mock('pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive', () => ({
27
+ __esModule: true,
28
+ ...jest.requireActual('pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive'),
29
+ default: () => ({
30
+ downloadContractArchive: mockDownloadContractArchive,
31
+ createContractArchive: mockCreateContractArchive,
32
+ status: mockDownloadContractArchiveStatus,
33
+ }),
34
+ }));
35
+
36
+ let mockHasContractToDownload: boolean;
37
+ jest.mock('pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx', () => ({
38
+ __esModule: true,
39
+ default: () => mockHasContractToDownload,
40
+ }));
41
+
42
+ describe('TeacherDashboardContractsLayout/BulkDownloadContractButton', () => {
43
+ const Wrapper = ({ children }: PropsWithChildren) => {
44
+ return (
45
+ <IntlProvider locale="en">
46
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
47
+ <JoanieApiProvider>{children}</JoanieApiProvider>
48
+ </QueryClientProvider>
49
+ </IntlProvider>
50
+ );
51
+ };
52
+
53
+ beforeEach(() => {
54
+ // useDownloadContractArchive mocked values
55
+ mockHasContractToDownload = false;
56
+ mockDownloadContractArchive = jest.fn(() => Promise<void>);
57
+ mockCreateContractArchive = jest.fn(() => Promise<void>);
58
+ mockDownloadContractArchiveStatus = ContractDownloadStatus.IDLE;
59
+ });
60
+
61
+ afterEach(() => {
62
+ jest.resetAllMocks();
63
+ });
64
+
65
+ it("shouldn't render generate archive button for archive generation IDLE", async () => {
66
+ mockDownloadContractArchiveStatus = ContractDownloadStatus.IDLE;
67
+
68
+ render(
69
+ <Wrapper>
70
+ <BulkDownloadContractButton organizationId={faker.string.uuid()} />
71
+ </Wrapper>,
72
+ );
73
+
74
+ const $button = screen.queryByRole('button', { name: /Request contracts archive/ });
75
+ expect($button).toBeInTheDocument();
76
+ expect($button).toBeEnabled();
77
+
78
+ const user = userEvent.setup();
79
+ await user.click($button!);
80
+ expect(mockDownloadContractArchive).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it("shouldn't render waiting archive button for archive generation PENDING", async () => {
84
+ mockDownloadContractArchiveStatus = ContractDownloadStatus.PENDING;
85
+
86
+ render(
87
+ <Wrapper>
88
+ <BulkDownloadContractButton organizationId={faker.string.uuid()} />
89
+ </Wrapper>,
90
+ );
91
+
92
+ const $button = screen.queryByRole('button', { name: /Generating contracts archive.../ });
93
+ expect($button).toBeInTheDocument();
94
+ expect($button).toBeDisabled();
95
+
96
+ const user = userEvent.setup();
97
+ await user.click($button!);
98
+ expect(mockDownloadContractArchive).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("shouldn't render download button for archive is READY", async () => {
102
+ mockDownloadContractArchiveStatus = ContractDownloadStatus.READY;
103
+
104
+ render(
105
+ <Wrapper>
106
+ <BulkDownloadContractButton organizationId={faker.string.uuid()} />
107
+ </Wrapper>,
108
+ );
109
+
110
+ const $button = screen.queryByRole('button', { name: /Download contracts archive/ });
111
+ expect($button).toBeInTheDocument();
112
+ expect($button).toBeEnabled();
113
+
114
+ const user = userEvent.setup();
115
+ await user.click($button!);
116
+ expect(mockDownloadContractArchive).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('should render disabled download button when INITIALIZING', async () => {
120
+ mockDownloadContractArchiveStatus = ContractDownloadStatus.INITIALIZING;
121
+
122
+ render(
123
+ <Wrapper>
124
+ <BulkDownloadContractButton organizationId={faker.string.uuid()} />
125
+ </Wrapper>,
126
+ );
127
+
128
+ const $button = screen.queryByRole('button', { name: /Download contracts archive/ });
129
+ expect($button).toBeInTheDocument();
130
+ expect($button).toBeDisabled();
131
+
132
+ const user = userEvent.setup();
133
+ await user.click($button!);
134
+ expect(mockDownloadContractArchive).not.toHaveBeenCalled();
135
+ });
136
+ });