From 12aa04c1d0d3cfb6aab53da4be8cc4b663edda72 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sat, 17 Jan 2026 18:50:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B4=80=EB=A0=A8=20api=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api-proxy.js | 54 +++++++++++++++++++++++++++++++++++++++++++++++ src/api.js | 9 +++++--- src/background.js | 48 +---------------------------------------- 3 files changed, 61 insertions(+), 50 deletions(-) create mode 100644 src/api-proxy.js 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과 통신) From 6f42425b61bff217c6931539f821b4d39c71ae3f Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sat, 17 Jan 2026 18:50:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EC=8B=9C=20=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20id=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20api=20=ED=98=B8=EC=B6=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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'); From a1c65c26527383b3b697daf4cf23517aed4a9033 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sat, 17 Jan 2026 18:50:30 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/api.test.js | 127 ++++++++++++++++++++++++++++++ src/__tests__/background.test.js | 130 +++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/__tests__/api.test.js create mode 100644 src/__tests__/background.test.js 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 + }); + }); +}); From 0bf01610c4407759cb5dd1740b87bff7922b610b Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sat, 17 Jan 2026 18:50:41 +0900 Subject: [PATCH 4/4] =?UTF-8?q?build:=20dev=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6f8315d..b052cb5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite build --watch", "build": "vite build", + "build:dev": "vite build --mode development", "preview": "vite preview", "test": "vitest", "test:run": "vitest run"