import { beforeAll, afterAll, afterEach } from 'vitest';
import type { Article } from '../../../src/client/types.js';
/**
* 統合テスト用のAPIモック層
* 状態を持つフェイクPlume APIを提供する
*/
// フェイクAPIの状態
interface MockApiState {
token: string | null;
users: Map<string, { id: number; email: string; password: string; name: string }>;
articles: Map<number, Article>;
nextArticleId: number;
}
const state: MockApiState = {
token: null,
users: new Map(),
articles: new Map(),
nextArticleId: 1,
};
// テストユーザー
const TEST_USER = {
id: 1,
email: 'test@example.com',
password: 'password123',
name: 'Test User',
};
const TEST_BLOG_ID = 1;
/**
* APIモック状態をリセット
*/
export function resetMockApi() {
state.token = null;
state.users.clear();
state.articles.clear();
state.nextArticleId = 1;
// テストユーザーを追加
state.users.set(TEST_USER.email, TEST_USER);
}
/**
* グローバルfetchをモック
*/
export function setupMockApi() {
beforeAll(() => {
resetMockApi();
});
afterEach(() => {
// 各テスト後に記事データのみリセット (ユーザーとトークンは維持)
state.articles.clear();
state.nextArticleId = 1;
});
afterAll(() => {
resetMockApi();
});
// グローバルfetchをモック
global.fetch = async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = url.toString();
const method = init?.method || 'GET';
const headers = (init?.headers as Record<string, string>) || {};
const body = init?.body ? JSON.parse(init.body.toString()) : undefined;
// 認証チェック
const authHeader = headers['authorization'] || headers['Authorization'];
const isAuthenticated = authHeader === `Bearer ${state.token}`;
// ログインAPI
if (urlStr.includes('/api/auth/login') && method === 'POST') {
const user = state.users.get(body.email);
if (user && user.password === body.password) {
state.token = 'mock-jwt-token';
return new Response(
JSON.stringify({
token: state.token,
user: {
id: user.id,
email: user.email,
name: user.name,
role: 'admin',
password_change_required: false,
created_at: '2024-01-01T00:00:00Z',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ error: 'Invalid credentials' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// 現在のユーザー取得
if (urlStr.includes('/api/auth/me') && method === 'GET') {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const user = Array.from(state.users.values())[0];
return new Response(
JSON.stringify({
id: user.id,
email: user.email,
name: user.name,
role: 'admin',
password_change_required: false,
created_at: '2024-01-01T00:00:00Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// 記事詳細取得 (記事一覧取得の前にチェック!)
if (urlStr.match(/\/api\/blogs\/\d+\/articles\/\d+$/) && method === 'GET') {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const articleIdMatch = urlStr.match(/\/articles\/(\d+)$/);
const articleId = articleIdMatch ? parseInt(articleIdMatch[1]) : 0;
const article = state.articles.get(articleId);
if (!article) {
return new Response(
JSON.stringify({ error: 'Article not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// ArticleWithMetadata形式で返す
const articleWithMetadata = {
...article,
author: {
id: TEST_USER.id,
name: TEST_USER.name,
email: TEST_USER.email,
},
categories: [],
tags: [],
};
return new Response(JSON.stringify(articleWithMetadata), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// 記事一覧取得
if (urlStr.includes('/api/blogs/') && urlStr.includes('/articles') && method === 'GET' && !urlStr.match(/\/articles\/\d+/)) {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const urlObj = new URL(urlStr);
const status = urlObj.searchParams.get('status');
const search = urlObj.searchParams.get('search');
let articles = Array.from(state.articles.values());
// フィルタリング
if (status) {
articles = articles.filter((a) => a.status === status);
}
if (search) {
articles = articles.filter(
(a) =>
a.title.toLowerCase().includes(search.toLowerCase()) ||
a.content.toLowerCase().includes(search.toLowerCase())
);
}
// ArticleWithMetadata形式で返す
const articlesWithMetadata = articles.map((article) => ({
...article,
author: {
id: TEST_USER.id,
name: TEST_USER.name,
email: TEST_USER.email,
},
categories: [],
tags: [],
}));
return new Response(
JSON.stringify(articlesWithMetadata),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// 記事作成
if (urlStr.match(/\/api\/blogs\/\d+\/articles$/) && method === 'POST') {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const articleId = state.nextArticleId++;
const now = new Date().toISOString();
const article: Article = {
id: articleId,
blog_id: TEST_BLOG_ID,
title: body.title,
content: body.content,
slug: body.slug,
status: body.status ?? 'draft',
author_id: TEST_USER.id,
featured_image: body.featured_image ?? null,
excerpt: body.excerpt ?? null,
published_at: body.status === 'published' ? now : null,
created_at: now,
updated_at: now,
};
state.articles.set(articleId, article);
return new Response(JSON.stringify(article), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
}
// 記事更新
if (urlStr.match(/\/api\/blogs\/\d+\/articles\/\d+$/) && method === 'PUT') {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const articleIdMatch = urlStr.match(/\/articles\/(\d+)$/);
const articleId = articleIdMatch ? parseInt(articleIdMatch[1]) : 0;
const article = state.articles.get(articleId);
if (!article) {
return new Response(
JSON.stringify({ error: 'Article not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const updated: Article = {
...article,
...body,
updated_at: new Date().toISOString(),
published_at:
body.status === 'published' && !article.published_at
? new Date().toISOString()
: article.published_at,
};
state.articles.set(articleId, updated);
return new Response(JSON.stringify(updated), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// 記事削除
if (urlStr.match(/\/api\/blogs\/\d+\/articles\/\d+$/) && method === 'DELETE') {
if (!isAuthenticated) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const articleIdMatch = urlStr.match(/\/articles\/(\d+)$/);
const articleId = articleIdMatch ? parseInt(articleIdMatch[1]) : 0;
const article = state.articles.get(articleId);
if (!article) {
return new Response(
JSON.stringify({ error: 'Article not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
state.articles.delete(articleId);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// 未実装エンドポイント
return new Response(JSON.stringify({ error: 'Not implemented' }), {
status: 501,
headers: { 'Content-Type': 'application/json' },
});
};
}
// テスト用の定数をエクスポート
export const MOCK_USER = TEST_USER;
export const MOCK_BLOG_ID = TEST_BLOG_ID;