import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { VergeApiClient } from '../../src/client/api';
describe('VergeApiClient', () => {
let client: VergeApiClient;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
// fetchをモック
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
// クライアントインスタンス作成
client = new VergeApiClient('https://api.example.com');
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ============================================================
// 認証メソッド
// ============================================================
describe('login', () => {
it('正常系: ログインに成功してトークンとユーザー情報を取得', async () => {
const mockResponse = {
token: 'mock-jwt-token',
user: {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'admin',
password_change_required: false,
created_at: '2024-01-01T00:00:00.000Z',
},
};
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
});
const result = await client.login('test@example.com', 'password123');
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/auth/login', expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'test@example.com',
password: 'password123',
}),
}));
expect(result).toEqual(mockResponse);
expect(client.getToken()).toBe('mock-jwt-token');
});
it('異常系: 401 - 認証失敗', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Invalid credentials' }),
});
await expect(client.login('test@example.com', 'wrong-password')).rejects.toThrow(
'Invalid credentials'
);
});
it('異常系: 400 - バリデーションエラー', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ error: 'Email and password are required' }),
});
await expect(client.login('', '')).rejects.toThrow('Email and password are required');
});
it('異常系: ネットワークエラー', async () => {
// リトライ機構により、maxRetries + 1回 (デフォルト4回) エラーを返す必要がある
fetchMock.mockRejectedValue(new Error('Network error'));
await expect(client.login('test@example.com', 'password123')).rejects.toThrow(
'Network error'
);
// 初回 + 3回リトライ = 4回
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
describe('getCurrentUser', () => {
beforeEach(() => {
// トークンを事前設定
client.setToken('mock-token');
});
it('正常系: 現在のユーザー情報を取得', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'admin',
password_change_required: false,
created_at: '2024-01-01T00:00:00.000Z',
};
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockUser,
});
const result = await client.getCurrentUser();
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/auth/me', expect.objectContaining({
method: 'GET',
headers: {
Authorization: 'Bearer mock-token',
},
}));
expect(result).toEqual(mockUser);
});
it('異常系: 401 - トークンが無効', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' }),
});
await expect(client.getCurrentUser()).rejects.toThrow('Unauthorized');
});
it('異常系: トークンが未設定の場合', async () => {
client.setToken(null);
await expect(client.getCurrentUser()).rejects.toThrow('No authentication token available');
});
});
// ============================================================
// 記事管理メソッド
// ============================================================
describe('listArticles', () => {
beforeEach(() => {
client.setToken('mock-token');
});
it('正常系: 記事一覧を取得', async () => {
const mockArticles = [
{
id: 1,
blog_id: 1,
title: 'Article 1',
content: 'Content 1',
slug: 'article-1',
status: 'published',
featured_image: null,
author_id: 1,
excerpt: null,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
published_at: '2024-01-01T00:00:00.000Z',
},
];
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockArticles,
});
const result = await client.listArticles(1);
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/blogs/1/articles', expect.objectContaining({
method: 'GET',
headers: {
Authorization: 'Bearer mock-token',
},
}));
expect(result).toEqual(mockArticles);
});
it('正常系: クエリパラメータ付きで記事一覧を取得', async () => {
const mockArticles = [];
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockArticles,
});
await client.listArticles(1, {
search: 'JavaScript',
status: 'published',
categories: '1,2',
tags: '3,4',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/blogs/1/articles?search=JavaScript&status=published&categories=1%2C2&tags=3%2C4',
expect.objectContaining({
method: 'GET',
headers: {
Authorization: 'Bearer mock-token',
},
})
);
});
it('異常系: 404 - ブログが見つからない', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Blog not found' }),
});
await expect(client.listArticles(999)).rejects.toThrow('Blog not found');
});
});
describe('getArticle', () => {
beforeEach(() => {
client.setToken('mock-token');
});
it('正常系: 記事詳細を取得', async () => {
const mockArticle = {
id: 1,
blog_id: 1,
title: 'Article 1',
content: 'Content 1',
slug: 'article-1',
status: 'published',
featured_image: null,
author_id: 1,
excerpt: null,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
published_at: '2024-01-01T00:00:00.000Z',
author: {
id: 1,
name: 'Author',
email: 'author@example.com',
},
categories: [],
tags: [],
};
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockArticle,
});
const result = await client.getArticle(1, 1);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/blogs/1/articles/1',
expect.objectContaining({
method: 'GET',
headers: {
Authorization: 'Bearer mock-token',
},
})
);
expect(result).toEqual(mockArticle);
});
it('異常系: 404 - 記事が見つからない', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Article not found' }),
});
await expect(client.getArticle(1, 999)).rejects.toThrow('Article not found');
});
});
describe('createArticle', () => {
beforeEach(() => {
client.setToken('mock-token');
});
it('正常系: 記事を作成', async () => {
const requestData = {
title: 'New Article',
content: 'Article content',
slug: 'new-article',
status: 'draft' as const,
};
const mockResponse = {
id: 1,
blog_id: 1,
...requestData,
featured_image: null,
author_id: 1,
excerpt: null,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
published_at: null,
};
fetchMock.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockResponse,
});
const result = await client.createArticle(1, requestData);
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/blogs/1/articles', expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
body: JSON.stringify(requestData),
}));
expect(result).toEqual(mockResponse);
});
it('異常系: 409 - slug重複エラー', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 409,
json: async () => ({ error: 'Article with this slug already exists' }),
});
await expect(
client.createArticle(1, {
title: 'New Article',
content: 'Content',
slug: 'duplicate-slug',
})
).rejects.toThrow('Article with this slug already exists');
});
});
describe('updateArticle', () => {
beforeEach(() => {
client.setToken('mock-token');
});
it('正常系: 記事を更新', async () => {
const updateData = {
title: 'Updated Title',
status: 'published' as const,
};
const mockResponse = {
id: 1,
blog_id: 1,
title: 'Updated Title',
content: 'Original content',
slug: 'article-1',
status: 'published',
featured_image: null,
author_id: 1,
excerpt: null,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-02T00:00:00.000Z',
published_at: null,
};
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
});
const result = await client.updateArticle(1, 1, updateData);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/blogs/1/articles/1',
expect.objectContaining({
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
body: JSON.stringify(updateData),
})
);
expect(result).toEqual(mockResponse);
});
it('異常系: 404 - 記事が見つからない', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Article not found' }),
});
await expect(client.updateArticle(1, 999, { title: 'Updated' })).rejects.toThrow(
'Article not found'
);
});
});
describe('deleteArticle', () => {
beforeEach(() => {
client.setToken('mock-token');
});
it('正常系: 記事を削除', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 204,
});
await client.deleteArticle(1, 1);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/blogs/1/articles/1',
expect.objectContaining({
method: 'DELETE',
headers: {
Authorization: 'Bearer mock-token',
},
})
);
});
it('異常系: 404 - 記事が見つからない', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Article not found' }),
});
await expect(client.deleteArticle(1, 999)).rejects.toThrow('Article not found');
});
});
// ============================================================
// トークン管理
// ============================================================
describe('トークン管理', () => {
it('setToken: トークンを設定できる', () => {
client.setToken('new-token');
expect(client.getToken()).toBe('new-token');
});
it('setToken: トークンをnullにできる', () => {
client.setToken('token');
client.setToken(null);
expect(client.getToken()).toBeNull();
});
it('getToken: 初期状態はnull', () => {
expect(client.getToken()).toBeNull();
});
});
});