Skip to content

Commit 149aaea

Browse files
authored
Merge pull request #49 from frontend-opensource-project/URH-62/use-permission
[URH-62] usePermission 신규
2 parents d84fcf5 + f8d06bc commit 149aaea

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed

src/hooks/usePermission.test.tsx

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {
2+
act,
3+
fireEvent,
4+
render,
5+
renderHook,
6+
screen,
7+
} from '@testing-library/react';
8+
import usePermission from './usePermission';
9+
import React, { useState } from 'react';
10+
import '@testing-library/jest-dom';
11+
12+
let originalNavigator: typeof window.navigator;
13+
14+
beforeEach(() => {
15+
originalNavigator = global.navigator;
16+
Object.assign(navigator, {
17+
permissions: {
18+
query: jest.fn().mockResolvedValue({
19+
state: 'prompt',
20+
onchange: null,
21+
}),
22+
},
23+
geolocation: {
24+
getCurrentPosition: (
25+
fn: (arg: { coords: { latitude: number; longitude: number } }) => void
26+
) => {
27+
fn({
28+
coords: {
29+
latitude: 11111111,
30+
longitude: 22222222,
31+
},
32+
});
33+
},
34+
},
35+
});
36+
});
37+
38+
afterEach(() => {
39+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40+
(global.navigator.permissions as any) = originalNavigator;
41+
jest.clearAllMocks();
42+
});
43+
44+
describe('usePermission hook spec', () => {
45+
test('훅 초기 상태는 "prompt" 상태이어야 한다.', async () => {
46+
const { result } = renderHook(() =>
47+
usePermission({ permission: 'geolocation' })
48+
);
49+
50+
await act(async () => {
51+
expect(result.current.status).toBe('prompt');
52+
});
53+
});
54+
55+
test('Permissions API가 지원되지 않는 경우 “notSupported”를 반환해야 한다.', async () => {
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
(global.navigator.permissions as any) = undefined;
58+
59+
const { result } = renderHook(() =>
60+
usePermission({ permission: 'geolocation' })
61+
);
62+
63+
await act(async () => {
64+
expect(result.current.status).toBe('notSupported');
65+
});
66+
});
67+
68+
test('권한 상태에 따라 상태를 업데이트해야 한다.', async () => {
69+
Object.assign(navigator, {
70+
permissions: {
71+
query: jest.fn().mockResolvedValue({
72+
state: 'granted',
73+
onchange: null,
74+
}),
75+
},
76+
});
77+
const setStatusSpy = jest.spyOn(React, 'useState');
78+
79+
const { result } = renderHook(() =>
80+
usePermission({ permission: 'geolocation' })
81+
);
82+
83+
await act(async () => {});
84+
85+
const permissionsQuery = await navigator.permissions.query({
86+
name: 'geolocation',
87+
});
88+
89+
expect(typeof permissionsQuery.onchange).toBe('function');
90+
expect(result.current.status).toBe('granted');
91+
expect(setStatusSpy).toHaveBeenCalledTimes(2);
92+
});
93+
94+
test('인수로 전달된 권한이름이 존재하지 않는다면 "notSupported" 상태를 반환해야 한다.', async () => {
95+
Object.assign(navigator, {
96+
permissions: {
97+
query: jest.fn().mockRejectedValue(undefined),
98+
},
99+
});
100+
101+
const { result } = renderHook(() =>
102+
usePermission({ permission: 'geolocation' })
103+
);
104+
105+
await act(async () => {});
106+
107+
expect(result.current.status).toBe('notSupported');
108+
});
109+
});
110+
111+
describe('usePermission를 사용한 컴포넌트 테스트', () => {
112+
const TestComponent = () => {
113+
const { status } = usePermission({ permission: 'geolocation' });
114+
const [location, setLocation] = useState<{
115+
latitude: number;
116+
longitude: number;
117+
} | null>(null);
118+
119+
const handleGetLocation = () => {
120+
if (status === 'prompt' || status === 'granted') {
121+
navigator.geolocation.getCurrentPosition(
122+
(position) => {
123+
setLocation({
124+
latitude: position.coords.latitude,
125+
longitude: position.coords.longitude,
126+
});
127+
},
128+
(error) => {
129+
console.error('위치 정보를 가져오는데 실패했습니다.', error);
130+
}
131+
);
132+
}
133+
};
134+
135+
return (
136+
<div>
137+
<h2 aria-label="permission-display">위치 정보 권한 상태: {status}</h2>
138+
<button onClick={handleGetLocation}>위치 정보 가져오기</button>
139+
{location && (
140+
<div>
141+
<p aria-label="latitude-display">위도: {location.latitude}</p>
142+
<p aria-label="longitude-display">경도: {location.longitude}</p>
143+
</div>
144+
)}
145+
{status === 'prompt' && (
146+
<p aria-label="loading-display">
147+
위치 정보 접근 권한을 요청 중입니다...
148+
</p>
149+
)}
150+
{status === 'denied' && (
151+
<p aria-label="error-display">
152+
위치 정보 접근 권한이 거부되었습니다. 설정에서 권한을 허용해 주세요.
153+
</p>
154+
)}
155+
{status === 'notSupported' && (
156+
<p aria-label="notSupported-display">
157+
이 브라우저는 위치 정보 접근 권한을 지원하지 않습니다.
158+
</p>
159+
)}
160+
</div>
161+
);
162+
};
163+
164+
describe('전달된 권한에 대한 상태에 따라 상태를 변화시켜야 한다.', () => {
165+
test('prompt', async () => {
166+
render(<TestComponent />);
167+
168+
expect(screen.getByLabelText('loading-display')).toHaveTextContent(
169+
'위치 정보 접근 권한을 요청 중입니다...'
170+
);
171+
172+
await act(async () => {});
173+
174+
expect(screen.getByLabelText('permission-display')).toHaveTextContent(
175+
'위치 정보 권한 상태: prompt'
176+
);
177+
178+
fireEvent.click(screen.getByText('위치 정보 가져오기'));
179+
180+
expect(
181+
await screen.findByLabelText('latitude-display')
182+
).toHaveTextContent('위도: 11111111');
183+
expect(
184+
await screen.findByLabelText('longitude-display')
185+
).toHaveTextContent('경도: 22222222');
186+
});
187+
188+
test('denied', async () => {
189+
Object.assign(navigator, {
190+
permissions: {
191+
query: jest.fn().mockResolvedValue({
192+
state: 'denied',
193+
onchange: null,
194+
}),
195+
},
196+
});
197+
198+
render(<TestComponent />);
199+
200+
await act(async () => {});
201+
202+
expect(screen.getByLabelText('permission-display')).toHaveTextContent(
203+
'위치 정보 권한 상태: denied'
204+
);
205+
206+
fireEvent.click(screen.getByText('위치 정보 가져오기'));
207+
208+
expect(await screen.findByLabelText('error-display')).toHaveTextContent(
209+
'위치 정보 접근 권한이 거부되었습니다. 설정에서 권한을 허용해 주세요.'
210+
);
211+
});
212+
213+
test('notSupported', async () => {
214+
Object.assign(navigator, {
215+
permissions: undefined,
216+
});
217+
218+
render(<TestComponent />);
219+
220+
await act(async () => {});
221+
222+
expect(screen.getByLabelText('permission-display')).toHaveTextContent(
223+
'위치 정보 권한 상태: notSupported'
224+
);
225+
226+
fireEvent.click(screen.getByText('위치 정보 가져오기'));
227+
228+
expect(
229+
await screen.findByLabelText('notSupported-display')
230+
).toHaveTextContent(
231+
'이 브라우저는 위치 정보 접근 권한을 지원하지 않습니다.'
232+
);
233+
});
234+
});
235+
});

src/hooks/usePermission.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useEffect, useState } from 'react';
2+
import { validators } from '../utils';
3+
4+
interface UsePermissionProps {
5+
permission: Permission; // 정의된 타입과 문자열 타입 모두 허용
6+
}
7+
8+
interface UsePermissionReturns {
9+
status: PermissionState;
10+
}
11+
12+
type PermissionState = 'granted' | 'prompt' | 'denied' | 'notSupported';
13+
14+
/**
15+
* 사용자의 권한 상태를 확인하고 추적하는 커스텀 훅.
16+
*
17+
* @param {object} props permission {Permission}: 확인하려는 권한의 이름.
18+
*
19+
* @returns {object}
20+
* - `status`: 현재 권한의 상태. ‘granted’, ‘prompt’, ‘denied’, ‘notSupported’
21+
*
22+
* @description
23+
* - 이 훅은 주어진 권한에 대한 상태를 확인하고, 권한 상태가 변경될 때마다 업데이트합니다.
24+
*/
25+
const usePermission = ({
26+
permission,
27+
}: UsePermissionProps): UsePermissionReturns => {
28+
const [status, setStatus] = useState<PermissionState>('prompt');
29+
30+
const monitorPermissionStatus = async (permission: PermissionName) => {
31+
const updateStatusOnPermissionChange = (
32+
permissionStatus: PermissionStatus
33+
) => {
34+
setStatus(permissionStatus.state);
35+
36+
permissionStatus.onchange = () => {
37+
setStatus(permissionStatus.state);
38+
};
39+
};
40+
41+
try {
42+
if (!validators.isClient() || !navigator.permissions) {
43+
throw new PermissionError('The Permissions API is not supported.');
44+
}
45+
46+
const permissionStatus = await navigator.permissions.query({
47+
name: permission,
48+
});
49+
50+
updateStatusOnPermissionChange(permissionStatus);
51+
} catch {
52+
setStatus('notSupported');
53+
}
54+
};
55+
56+
useEffect(() => {
57+
monitorPermissionStatus(permission as PermissionName);
58+
}, [permission]);
59+
60+
return { status };
61+
};
62+
63+
// eslint-disable-next-line @typescript-eslint/ban-types
64+
type Permission = PredefinedPermissionName | (string & {});
65+
66+
// Firefox, Chromium, WebKit
67+
type PredefinedPermissionName =
68+
| 'accessibility-events'
69+
| 'accelerometer'
70+
| 'ambient-light-sensor'
71+
| 'background-fetch'
72+
| 'background-sync'
73+
| 'bluetooth'
74+
| 'camera'
75+
| 'captured-surface-control'
76+
| 'clipboard-read'
77+
| 'clipboard-write'
78+
| 'display-capture'
79+
| 'fullscreen'
80+
| 'geolocation'
81+
| 'gyroscope'
82+
| 'idle-detection'
83+
| 'keyboard-lock'
84+
| 'local-fonts'
85+
| 'magnetometer'
86+
| 'microphone'
87+
| 'midi'
88+
| 'nfc'
89+
| 'notifications'
90+
| 'payment-handler'
91+
| 'periodic-background-sync'
92+
| 'persistent-storage'
93+
| 'pointer-lock'
94+
| 'push'
95+
| 'screen-wake-lock'
96+
| 'speaker-selection'
97+
| 'storage-access'
98+
| 'system-wake-lock'
99+
| 'top-level-storage-access'
100+
| 'window-management';
101+
102+
class PermissionError extends Error {
103+
constructor(message: string) {
104+
super(message);
105+
this.message = message;
106+
this.name = 'PermissionError';
107+
}
108+
}
109+
110+
export default usePermission;

0 commit comments

Comments
 (0)