diff --git a/package.json b/package.json index 6f8315d..b657e5a 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "recycle-study-extension", - "version": "1.0.0", + "version": "1.0.2", "description": "복습 URL 저장 크롬 익스텐션", "type": "module", "scripts": { "dev": "vite build --watch", "build": "vite build", + "build:dev": "vite build --mode development", "preview": "vite preview", "test": "vitest", "test:run": "vitest run" diff --git a/public/manifest.json b/public/manifest.json index 2bf0902..6dae4fd 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Recycle Study", - "version": "1.0.1", + "version": "1.0.2", "description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션", "permissions": [ "storage", diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js new file mode 100644 index 0000000..7ba2ebe --- /dev/null +++ b/src/__tests__/api.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { registerDevice, getDevices, deleteDevice, saveReviewUrl } from '../api.js'; +import { ERROR_CODES } from '../constants.js'; + +// Mock chrome.runtime.sendMessage +const sendMessageMock = vi.fn(); +global.chrome = { + runtime: { + sendMessage: sendMessageMock + } +}; + +describe('api.js', () => { + beforeEach(() => { + sendMessageMock.mockReset(); + }); + + describe('registerDevice', () => { + it('이메일로 등록 요청을 보낸다', async () => { + const email = 'test@example.com'; + const mockResponse = { success: true, data: { email, identifier: 'dev-id' } }; + sendMessageMock.mockResolvedValue(mockResponse); + + const result = await registerDevice(email); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/members', + method: 'POST', + body: { email } + } + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('getDevices', () => { + it('식별자를 헤더에 포함하여 디바이스 목록을 조회한다', async () => { + const email = 'test@example.com'; + const identifier = 'my-device-id'; + const mockResponse = { success: true, data: { devices: [] } }; + sendMessageMock.mockResolvedValue(mockResponse); + + await getDevices(email, identifier); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/members', + method: 'GET', + params: { email }, + headers: { 'X-Device-Id': identifier } + } + }); + }); + }); + + describe('deleteDevice', () => { + it('식별자를 헤더에 포함하여 디바이스 삭제 요청을 보낸다', async () => { + const email = 'test@example.com'; + const deviceIdentifier = 'my-device-id'; + const targetDeviceIdentifier = 'target-id'; + const mockResponse = { success: true, data: null }; + sendMessageMock.mockResolvedValue(mockResponse); + + await deleteDevice(email, deviceIdentifier, targetDeviceIdentifier); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/device', + method: 'DELETE', + headers: { 'X-Device-Id': deviceIdentifier }, + body: { email, targetDeviceIdentifier } + } + }); + }); + }); + + describe('saveReviewUrl', () => { + it('식별자를 헤더에 포함하여 URL 저장 요청을 보낸다', async () => { + const identifier = 'my-device-id'; + const targetUrl = 'https://example.com'; + const mockResponse = { success: true, data: { url: targetUrl } }; + sendMessageMock.mockResolvedValue(mockResponse); + + await saveReviewUrl(identifier, targetUrl); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/reviews', + method: 'POST', + headers: { 'X-Device-Id': identifier }, + body: { targetUrl } + } + }); + }); + }); + + describe('Error Handling', () => { + it('API 실패 시 에러를 던진다', async () => { + sendMessageMock.mockResolvedValue({ + success: false, + status: 400, + message: 'Bad Request' + }); + + await expect(registerDevice('test@test.com')).rejects.toThrow('Bad Request'); + }); + + it('네트워크 에러 시 NETWORK_ERROR 코드를 반환한다', async () => { + sendMessageMock.mockResolvedValue({ + success: false, + isNetworkError: true, + message: 'Network Error' + }); + + try { + await registerDevice('test@test.com'); + } catch (error) { + expect(error.code).toBe(ERROR_CODES.NETWORK_ERROR); + } + }); + }); +}); diff --git a/src/__tests__/background.test.js b/src/__tests__/background.test.js new file mode 100644 index 0000000..20c37c1 --- /dev/null +++ b/src/__tests__/background.test.js @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleApiRequest } from '../api-proxy.js'; +import { CONFIG } from '../config.js'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('handleApiRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('GET 요청을 올바르게 구성하여 보낸다', async () => { + const mockResponse = { data: 'test' }; + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse + }); + + const request = { + endpoint: '/test', + method: 'GET', + params: { q: 'hello' } + }; + + const result = await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/test?q=hello'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + expect(result).toEqual({ success: true, data: mockResponse }); + }); + + it('POST 요청과 Body를 올바르게 보낸다', async () => { + const mockResponse = { id: 1 }; + fetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => mockResponse + }); + + const request = { + endpoint: '/create', + method: 'POST', + body: { name: 'item' } + }; + + await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/create'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'item' }) + }) + ); + }); + + it('커스텀 헤더를 포함하여 요청을 보낸다', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}) + }); + + const request = { + endpoint: '/header-test', + headers: { 'X-Device-Id': '12345' } + }; + + await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Device-Id': '12345' + }) + }) + ); + }); + + it('204 응답을 올바르게 처리한다', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 204 + }); + + const result = await handleApiRequest({ endpoint: '/delete' }); + + expect(result).toEqual({ success: true, data: null }); + }); + + it('API 에러 응답을 처리한다', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ message: 'Invalid data' }) + }); + + const result = await handleApiRequest({ endpoint: '/error' }); + + expect(result).toEqual({ + success: false, + status: 400, + message: 'Invalid data' + }); + }); + + it('네트워크 에러를 처리한다', async () => { + fetch.mockRejectedValue(new Error('Network error')); + + const result = await handleApiRequest({ endpoint: '/network-fail' }); + + expect(result).toEqual({ + success: false, + status: 0, + message: 'Network error', + isNetworkError: true + }); + }); +}); diff --git a/src/api-proxy.js b/src/api-proxy.js new file mode 100644 index 0000000..5b08e2d --- /dev/null +++ b/src/api-proxy.js @@ -0,0 +1,54 @@ +import { CONFIG } from './config.js'; + +/** + * API 요청 처리 로직 + * @param {Object} request + * @returns {Promise} + */ +export async function handleApiRequest(request) { + const { endpoint, method = 'GET', body, params, headers } = request; + + let url = `${CONFIG.BASE_URL}${endpoint}`; + if (params) { + url += `?${new URLSearchParams(params)}`; + } + + const options = { + method, + headers: { + 'Content-Type': 'application/json', + ...(headers || {}) + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + + // 204 No Content + if (response.status === 204) { + return { success: true, data: null }; + } + + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error('Failed to parse JSON response:', parseError); + data = { message: 'Invalid JSON response from server.' }; + } + + if (!response.ok) { + console.error('API request failed:', { url, status: response.status, data }); + return { success: false, status: response.status, message: data.message }; + } + + return { success: true, data }; + } catch (error) { + console.error('Network request failed:', { url, error }); + return { success: false, status: 0, message: error.message, isNetworkError: true }; + } +} diff --git a/src/api.js b/src/api.js index 40bc5ee..8da19cd 100644 --- a/src/api.js +++ b/src/api.js @@ -54,7 +54,8 @@ export async function getDevices(email, identifier) { return await sendApiRequest({ endpoint: '/api/v1/members', method: 'GET', - params: { email, identifier } + params: { email }, + headers: { 'X-Device-Id': identifier } }); } @@ -68,7 +69,8 @@ export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifi return await sendApiRequest({ endpoint: '/api/v1/device', method: 'DELETE', - body: { email, deviceIdentifier, targetDeviceIdentifier } + headers: { 'X-Device-Id': deviceIdentifier }, + body: { email, targetDeviceIdentifier } }); } @@ -82,6 +84,7 @@ export async function saveReviewUrl(identifier, targetUrl) { return await sendApiRequest({ endpoint: '/api/v1/reviews', method: 'POST', - body: { identifier, targetUrl } + headers: { 'X-Device-Id': identifier }, + body: { targetUrl } }); } diff --git a/src/background.js b/src/background.js index bcf430a..cc1dd31 100644 --- a/src/background.js +++ b/src/background.js @@ -5,7 +5,7 @@ * API 요청은 CORS 우회를 위해 이 서비스 워커에서 처리한다. */ -import { CONFIG } from './config.js'; +import { handleApiRequest } from './api-proxy.js'; // ============================================ // 익스텐션 설치/업데이트 이벤트 @@ -18,53 +18,7 @@ chrome.runtime.onInstalled.addListener((details) => { } }); -// ============================================ -// API 프록시 핸들러 -// ============================================ -async function handleApiRequest(request) { - const { endpoint, method = 'GET', body, params } = request; - - let url = `${CONFIG.BASE_URL}${endpoint}`; - if (params) { - url += `?${new URLSearchParams(params)}`; - } - - const options = { - method, - headers: { 'Content-Type': 'application/json' } - }; - - if (body) { - options.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, options); - - // 204 No Content - if (response.status === 204) { - return { success: true, data: null }; - } - let data; - try { - data = await response.json(); - } catch (parseError) { - console.error('Failed to parse JSON response:', parseError); - data = { message: 'Invalid JSON response from server.' }; - } - - if (!response.ok) { - console.error('API request failed:', { url, status: response.status, data }); - return { success: false, status: response.status, message: data.message }; - } - - return { success: true, data }; - } catch (error) { - console.error('Network request failed:', { url, error }); - return { success: false, status: 0, message: error.message, isNetworkError: true }; - } -} // ============================================ // 메시지 리스너 (popup과 통신) diff --git a/src/handlers.js b/src/handlers.js index 9f9878e..7db7904 100644 --- a/src/handlers.js +++ b/src/handlers.js @@ -224,6 +224,24 @@ export async function handleLogout() { return; } + try { + showLoading(); + const storageData = await getStorageData(); + + // 인증된 상태라면 서버에 디바이스 삭제 요청 + if (storageData.email && storageData.identifier) { + await deleteDevice( + storageData.email, + storageData.identifier, + storageData.identifier // 자기 자신을 삭제 + ); + } + } catch (error) { + console.warn('서버 디바이스 삭제 실패 (로컬 로그아웃은 진행):', error); + } finally { + hideLoading(); + } + await clearStorage(); elements.saveResult.classList.add('hidden'); elements.devicesSection.classList.add('hidden');