Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Recycle Study",
"version": "1.0.1",
"version": "1.0.2",
"description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션",
"permissions": [
"storage",
Expand Down
127 changes: 127 additions & 0 deletions src/__tests__/api.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
130 changes: 130 additions & 0 deletions src/__tests__/background.test.js
Original file line number Diff line number Diff line change
@@ -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
});
});
});
54 changes: 54 additions & 0 deletions src/api-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CONFIG } from './config.js';

/**
* API 요청 처리 로직
* @param {Object} request
* @returns {Promise<Object>}
*/
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 };
}
}
9 changes: 6 additions & 3 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
});
}

Expand All @@ -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 }
});
}

Expand All @@ -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 }
});
}
Loading