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.
- package/js/api/joanie.ts +26 -3
- package/js/api/lms/dummy.spec.ts +9 -1
- package/js/api/lms/dummy.ts +12 -2
- package/js/contexts/SessionContext/BaseSessionProvider.tsx +4 -12
- package/js/contexts/SessionContext/JoanieSessionProvider.tsx +4 -11
- package/js/contexts/SessionContext/index.spec.tsx +2 -4
- package/js/hooks/useContractArchive/index.download.spec.tsx +119 -0
- package/js/hooks/useContractArchive/index.spec.tsx +91 -0
- package/js/hooks/useContractArchive/index.ts +83 -0
- package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.spec.tsx +136 -0
- package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.timer.spec.tsx +144 -0
- package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.tsx +73 -0
- package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +166 -0
- package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.tsx +23 -8
- package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +74 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.spec.tsx +124 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.tsx +73 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.spec.ts +85 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.ts +50 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.spec.tsx +266 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +153 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.spec.tsx +100 -0
- package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx +23 -0
- package/js/settings.ts +7 -0
- package/js/types/Joanie.ts +5 -0
- package/js/utils/errors/HttpError.ts +1 -0
- 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
|
-
|
|
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: {
|
package/js/api/lms/dummy.spec.ts
CHANGED
|
@@ -36,7 +36,15 @@ describe('Dummy API', () => {
|
|
|
36
36
|
|
|
37
37
|
describe('user', () => {
|
|
38
38
|
it('simulates that authenticated user is admin', async () => {
|
|
39
|
-
|
|
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
|
});
|
package/js/api/lms/dummy.ts
CHANGED
|
@@ -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: () =>
|
|
81
|
+
login: () => {
|
|
82
|
+
localStorage.setItem(RICHIE_DUMMY_IS_LOGGED_IN, 'true');
|
|
83
|
+
location.reload();
|
|
84
|
+
},
|
|
77
85
|
register: () => location.reload(),
|
|
78
|
-
logout: async () =>
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
}, [
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}, [
|
|
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)).
|
|
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
|
+
});
|