+
+ = $this->BcAdminForm->submit('保存', [
+ 'div' => false,
+ 'class' => 'btn-red bca-btn bca-loading',
+ 'data-bca-btn-type' => 'save',
+ 'data-bca-btn-size' => 'lg',
+ 'data-bca-btn-width' => 'lg',
+ 'id' => 'BtnSave'
+ ]) ?>
+
+
+ = $this->Html->link('キャンセル', ['action' => 'index'], [
+ 'class' => 'bca-btn',
+ 'data-bca-btn-type' => 'cancel',
+ 'data-bca-btn-size' => 'lg',
+ 'data-bca-btn-width' => 'lg'
+ ]) ?>
+
+
+
+ = $this->BcAdminForm->end() ?>
+
+
+
diff --git a/plugins/bc-mcp/templates/Admin/McpServerManager/index.php b/plugins/bc-mcp/templates/Admin/McpServerManager/index.php
new file mode 100644
index 0000000000..b678723fbb
--- /dev/null
+++ b/plugins/bc-mcp/templates/Admin/McpServerManager/index.php
@@ -0,0 +1,168 @@
+
+
+
+
+
+
MCPサーバー状態
+
+
+
+
+
状態
+
+
+ 稼働中
+
+ (PID: = h($status['pid']) ?>)
+
+
+ 停止中
+
+
+
+
+
+
AIエージェント設定用URL
+
+ = h($status['proxy_url']) ?>
+
+
+
+
+
+
内部URL
+
+ = h($status['internal_url']) ?>
+
+
+
+
+
+
+
+
+
+
サーバー操作
+
+
+
+
+ = $this->BcAdminForm->postLink(
+ '停止',
+ ['action' => 'stop'],
+ [
+ 'class' => 'bca-btn bca-btn--danger',
+ 'confirm' => 'MCPサーバーを停止しますか?'
+ ]
+ ) ?>
+
+ = $this->BcAdminForm->postLink(
+ '再起動',
+ ['action' => 'restart'],
+ [
+ 'class' => 'bca-btn bca-btn--warning',
+ 'confirm' => 'MCPサーバーを再起動しますか?'
+ ]
+ ) ?>
+
+ = $this->BcAdminForm->postLink(
+ '起動',
+ ['action' => 'start'],
+ [
+ 'class' => 'bca-btn bca-btn--success'
+ ]
+ ) ?>
+
+
+Html->link(
+// '設定',
+// ['action' => 'configure'],
+// ['class' => 'bca-btn bca-btn--default']
+// ) ?>
+
+
+
+
+
+
+
+
AIエージェントでの設定方法
+
+
+
+
+
手順1
+
+ 上記の「起動」ボタンでMCPサーバーを起動してください
+
+
+
+
+
手順2
+
+ AIエージェントの設定ファイルで上記AIエージェント設定用URLを設定してください
+
+
+
+
+
手順3
+
+ AIエージェントから「ブログ記事を追加して」などの指示でbaserCMSを操作できます
+
+
+
+
+
+
利用可能な機能
+
+ - ブログ記事の、単一取得・一覧取得・追加・編集・削除
+ - カスタムエントリーの、単一取得・一覧取得・追加・編集・削除
+ - サーバー情報の取得
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php b/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php
new file mode 100644
index 0000000000..de6c9a81f9
--- /dev/null
+++ b/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php
@@ -0,0 +1,165 @@
+BcBaser->setTitle('BcMcp アプリケーション認可');
+?>
+
+
+
+
+
+
+
+ = h($client->getName()) ?> が、 に対して、以下の権限を要求しています。
+
+
+
+
要求されている権限
+
+
+ - 基本的なアクセス権限
+
+
+ - = h($this->OAuth2->getScopeDescription($scopeItem)) ?>
+
+
+
+
+
+
+
+ ログインユーザー: = h($user->email) ?>
+
+
+
+ = $this->BcAdminForm->create(null, ['type' => 'post']) ?>
+ = $this->BcAdminForm->hidden('client_id', ['value' => $clientId]) ?>
+ = $this->BcAdminForm->hidden('redirect_uri', ['value' => $redirectUri]) ?>
+ = $this->BcAdminForm->hidden('scope', ['value' => $scope]) ?>
+ = $this->BcAdminForm->hidden('state', ['value' => $state]) ?>
+
+
+
+ = $this->BcAdminForm->button('拒否', [
+ 'block' => true,
+ 'class' => 'bca-btn bca-actions__item',
+ 'data-bca-btn-type' => 'delete',
+ 'data-bca-btn-size' => 'lg',
+ 'data-bca-btn-color' => "danger",
+ 'type' => 'submit',
+ 'name' => 'action',
+ 'value' => 'deny'
+ ]) ?>
+ = $this->BcAdminForm->button('許可', [
+ 'div' => false,
+ 'class' => 'button bca-btn bca-actions__item',
+ 'data-bca-btn-type' => 'save',
+ 'data-bca-btn-size' => 'lg',
+ 'data-bca-btn-width' => 'lg',
+ 'type' => 'submit',
+ 'name' => 'action',
+ 'value' => 'approve'
+ ]) ?>
+
+
+
+ = $this->BcAdminForm->end() ?>
+
+
+
+
+
diff --git a/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php b/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php
new file mode 100644
index 0000000000..d95a393602
--- /dev/null
+++ b/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php
@@ -0,0 +1,48 @@
+setDefaultData(function(Generator $faker) {
+ return [
+ 'code' => 'c5c91c0f3dc02fff203115be82914b9e221cf69ebe43e24e81a605ea42098909be111f29c754f2ce',
+ 'user_id' => 1,
+ 'client_id' => 'mcp-client',
+ 'redirect_uris' => '[]',
+ 'scopes' => '["mcp:read","mcp:write"]',
+ 'revoked' => false,
+ 'expires_at' => FrozenTime::now()->addMinutes(10),
+ 'created' => FrozenTime::now(),
+ 'modified' => FrozenTime::now()
+ ];
+ });
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php b/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php
new file mode 100644
index 0000000000..e6965286af
--- /dev/null
+++ b/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php
@@ -0,0 +1,48 @@
+setDefaultData(function(Generator $faker) {
+ return [
+ 'name' => 'Generated from Admin Panel',
+ 'client_id' => 'mcp-client',
+ 'client_secret' => 'mcp-secret-key',
+ 'redirect_uris' => ["http://localhost"],
+ 'grants' => ["authorization_code", "refresh_token"],
+ 'scopes' => ["mcp:read", "mcp:write"],
+ 'is_confidential' => false,
+ 'created' => FrozenTime::now(),
+ 'modified' => FrozenTime::now()
+ ];
+ });
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php b/plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php
new file mode 100644
index 0000000000..c01df76e99
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php
@@ -0,0 +1,71 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Command;
+
+use BaserCore\TestSuite\BcTestCase;
+use Cake\Console\TestSuite\ConsoleIntegrationTestTrait;
+use BcMcp\Command\McpServerCommand;
+
+/**
+ * BcMcp\Command\McpServerCommand Test Case
+ *
+ * @uses \BcMcp\Command\McpServerCommand
+ */
+class McpServerCommandTest extends BcTestCase
+{
+ use ConsoleIntegrationTestTrait;
+
+ /**
+ * setUp method
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ /**
+ * Test buildOptionParser method
+ *
+ * @return void
+ */
+ public function testBuildOptionParser()
+ {
+ $command = new McpServerCommand();
+ $parser = $command->getOptionParser();
+
+ $options = $parser->options();
+ $this->assertArrayHasKey('transport', $options);
+ $this->assertArrayHasKey('host', $options);
+ $this->assertArrayHasKey('port', $options);
+ $this->assertArrayHasKey('config', $options);
+
+ $this->assertEquals('stdio', $options['transport']->defaultValue());
+ $this->assertEquals('127.0.0.1', $options['host']->defaultValue());
+ $this->assertEquals('3000', $options['port']->defaultValue());
+ }
+
+ /**
+ * Test execute method help
+ *
+ * @return void
+ */
+ public function testExecuteHelp()
+ {
+ $command = new McpServerCommand();
+ $parser = $command->getOptionParser();
+
+ $this->assertStringContainsString('baserCMS MCP サーバーを起動します', $parser->getDescription());
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php
new file mode 100644
index 0000000000..c25604a9b7
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php
@@ -0,0 +1,683 @@
+loadFixtureScenario(InitAppScenario::class);
+ // OAuth2設定をセットアップ
+ Configure::write('BcMcp.OAuth2.clients', [
+ 'mcp-client' => [
+ 'name' => 'MCP Server Client',
+ 'secret' => 'mcp-secret-key',
+ 'redirect_uris' => ['http://localhost'],
+ 'grants' => ['authorization_code'],
+ 'scopes' => ['read', 'write']
+ ]
+ ]);
+
+ Configure::write('BcMcp.OAuth2.scopes', [
+ 'read' => 'データの読み取り',
+ 'write' => 'データの書き込み',
+ 'admin' => '管理者権限'
+ ]);
+
+ Configure::write('OAuth2.accessTokenTTL', 'PT1H');
+
+ // テスト用のOAuth2キーペアが存在することを確認
+ $privateKeyPath = CONFIG . 'oauth2_private.key';
+ $publicKeyPath = CONFIG . 'oauth2_public.key';
+
+ if (!file_exists($privateKeyPath) || !file_exists($publicKeyPath)) {
+ $this->generateTestKeys($privateKeyPath, $publicKeyPath);
+ }
+
+ // Admin配下のテスト用設定
+ $this->configRequest([
+ 'environment' => [
+ 'HTTPS' => 'off'
+ ]
+ ]);
+ }
+
+ /**
+ * tearDown method
+ *
+ * 統合テストで起動した MCP サーバー(SSE 常駐プロセス)を確実に停止する。
+ * 停止しないと CI 上で孤児プロセスがステップの fd を掴んだまま残り、
+ * ジョブが次のステップへ進めず無限待機する(GHA で 8.1 がハングした原因)。
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ $mcpServerManager = new McpServerManger();
+ if ($mcpServerManager->isServerRunning()) {
+ $mcpServerManager->stopMcpServer();
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * MCPプロキシ経由の統合テスト用に、実際の MCP サーバー(SSE)を用意する。
+ * 起動していなければ起動し、プロキシが接続する 127.0.0.1:{port} へ実際に到達できるまで待つ。
+ * 到達できない場合はスキップせず明示的に失敗させる(サーバー起動の不具合を隠さない)。
+ *
+ * @return void
+ */
+ private function requireMcpServer(): void
+ {
+ // サーバー子プロセスの `bin/cake bc_mcp.server` は default 接続の DB から
+ // 有効プラグイン(plugins テーブル status=true)を読んでロードする。BcMcp は
+ // defaultInstallCorePlugins に含めない方針のため `bin/cake install` では有効化されず、
+ // console に bc_mcp.server コマンドが登録されない(CI で起動失敗→500 の原因)。
+ // そこでテスト内で BcMcp を有効化しておく(既にあればスキップ)。
+ $pluginsTable = TableRegistry::getTableLocator()->get('BaserCore.Plugins');
+ if (!$pluginsTable->exists(['name' => 'BcMcp'])) {
+ PluginFactory::make([
+ 'name' => 'BcMcp',
+ 'title' => 'baserCMS MCP Server',
+ 'status' => true,
+ 'db_init' => true,
+ 'priority' => 100,
+ ])->persist();
+ }
+
+ $mcpServerManager = new McpServerManger();
+ $config = $mcpServerManager->getServerConfig();
+ // 子プロセス(bin/cake bc_mcp.server)は別プロセスのため、テストが PluginFactory で
+ // 有効化した BcMcp を default 接続では見られない。test 接続(両プロセスで共有)を使わせ、
+ // 子プロセスの bootstrap でも BcMcp がロードされる(=コマンドが登録される)ようにする。
+ $config['connection'] = 'test';
+ if (!$mcpServerManager->isServerRunning()) {
+ $mcpServerManager->startMcpServer($config);
+ }
+ // プロセス存在だけでなく、プロキシが叩く 127.0.0.1:{port} へ実際に接続できるまで待つ。
+ // (プロセス起動直後はポート bind が間に合わず接続拒否 → 500 になることがあるため)
+ $host = $config['host'] ?? '127.0.0.1';
+ $port = (int)($config['port'] ?? 3000);
+ $deadline = microtime(true) + 15.0;
+ $reachable = false;
+ while (microtime(true) < $deadline) {
+ $conn = @fsockopen($host, $port, $errno, $errstr, 1);
+ if ($conn) {
+ fclose($conn);
+ $reachable = true;
+ break;
+ }
+ usleep(300000); // 0.3秒
+ }
+ if (!$reachable) {
+ // 失敗時はサーバーの起動ログを添えて原因を可視化する(CI で bin/cake bc_mcp.server が
+ // 起動できない理由=コマンド未登録・DB未接続・ポート競合等がここに出る)。
+ $logFile = LOGS . 'bc_mcp_server.log';
+ $serverLog = is_file($logFile) ? (string)file_get_contents($logFile) : '(bc_mcp_server.log が存在しません=サーバープロセスがログを出す前に失敗した可能性)';
+ $this->fail(sprintf(
+ "MCP サーバー(SSE / %s:%d)へ接続できませんでした。\n===== bc_mcp_server.log(末尾3000字) =====\n%s",
+ $host,
+ $port,
+ mb_substr($serverLog, -3000)
+ ));
+ }
+ }
+
+ /**
+ * テスト用のRSAキーペアを生成
+ */
+ private function generateTestKeys(string $privateKeyPath, string $publicKeyPath): void
+ {
+ $config = [
+ "digest_alg" => "sha256",
+ "private_key_bits" => 2048,
+ "private_key_type" => OPENSSL_KEYTYPE_RSA,
+ ];
+
+ $res = openssl_pkey_new($config);
+ openssl_pkey_export($res, $privKey);
+
+ $pubKey = openssl_pkey_get_details($res);
+ $publicKey = $pubKey["key"];
+
+ file_put_contents($privateKeyPath, $privKey);
+ file_put_contents($publicKeyPath, $publicKey);
+ }
+
+ /**
+ * Test authorize endpoint with authenticated user
+ * デフォルトクライアントの認証テスト(DCR前提とするため一旦廃止)
+ * @return void
+ */
+// public function testAuthorizeEndpointWithAuthenticatedUser(): void
+// {
+// $this->loginAdmin($this->getRequest());
+//
+// // 認可リクエストのパラメータ
+// $params = [
+// 'client_id' => 'mcp-client',
+// 'client_secret' => 'mcp-secret-key',
+// 'response_type' => 'code',
+// 'redirect_uri' => 'http://localhost',
+// 'scope' => 'mcp:read mcp:write',
+// 'state' => 'test-state'
+// ];
+//
+// $this->get('/baser/admin/bc-mcp/oauth2/authorize?' . http_build_query($params));
+//
+// // 認証済みユーザーなので認可画面が表示される
+// $this->assertResponseOk();
+// }
+
+ /**
+ * Test authorize endpoint without authentication
+ *
+ * @return void
+ */
+ public function testAuthorizeEndpointWithoutAuthentication(): void
+ {
+ // 認証なしでauthorizeエンドポイントにアクセス
+ $this->get('/baser/admin/bc-mcp/oauth2/authorize');
+
+ // 認証が必要なため、リダイレクトが返される
+ $this->assertResponseCode(302);
+ }
+
+ public function testIntegration(): void
+ {
+ $this->requireMcpServer();
+ // MPCサーバーの接続ポイントにGETリクエストを送信
+ $this->get('/bc-mcp');
+ $this->assertResponseCode(401);
+
+ // oauth-protected-resource にリクエストを送信
+ $this->get('/.well-known/oauth-protected-resource/bc-mcp');
+ $metadata = json_decode((string)$this->_response->getBody(), true);
+ $this->assertTextContains('/bc-mcp', $metadata['resource']);
+
+ // oauth-authorization-server にリクエストを送信
+ $this->get('/.well-known/oauth-authorization-server/bc-mcp');
+ $metadata = json_decode((string)$this->_response->getBody(), true);
+ $registrationEndpoint = $metadata['registration_endpoint'];
+
+ // クライアント登録エンドポイントにPOSTリクエストを送信
+ $this->post($registrationEndpoint, [
+ 'client_name' => 'Test Client',
+ 'client_uri' => 'http://localhost',
+ 'redirect_uris' => ['http://localhost/callback'],
+ 'grant_types' => ['authorization_code', 'refresh_token'],
+ 'response_types' => ['code'],
+ 'scope' => 'mcp:read mcp:write'
+ ]);
+ $metadata = json_decode((string)$this->_response->getBody(), true);
+ $this->assertResponseCode(201);
+ $this->assertArrayHasKey('client_id', $metadata);
+
+ // 認可リクエスト
+ $this->get('/bc-mcp/oauth2/authorize?' . http_build_query([
+ 'client_id' => $metadata['client_id'],
+ 'client_secret' => $metadata['client_secret'],
+ 'response_type' => 'code',
+ 'redirect_uri' => $metadata['redirect_uris'][0]
+ ]));
+ $this->assertResponseCode(302);
+
+ $this->loginAdmin($this->getRequest());
+ $this->get('/bc-mcp/oauth2/authorize?' . http_build_query([
+ 'client_id' => $metadata['client_id'],
+ 'client_secret' => $metadata['client_secret'],
+ 'response_type' => 'code',
+ 'redirect_uri' => $metadata['redirect_uris'][0]
+ ]));
+ $this->assertResponseCode(200);
+
+ // 認可承認
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'client_id' => $metadata['client_id'],
+ 'client_secret' => $metadata['client_secret'],
+ 'response_type' => 'code',
+ 'redirect_uri' => $metadata['redirect_uris'][0]
+ ]), ['action' => 'approve']);
+ $this->assertResponseCode(302);
+ $redirectUrl = $this->_response->getHeaderLine('Location');
+ $this->assertStringContainsString('code=', $redirectUrl);
+ // 認可コードを取得
+ $queryParams = [];
+ parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams);
+ $this->assertArrayHasKey('code', $queryParams);
+ $authCode = $queryParams['code'];
+
+ // 認可コードを使用してアクセストークンを取得
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode,
+ 'redirect_uri' => $metadata['redirect_uris'][0],
+ 'client_id' => $metadata['client_id'],
+ 'client_secret' => $metadata['client_secret'],
+ 'scope' => 'read write'
+ ]);
+ $this->assertResponseCode(200);
+ $tokenData = json_decode((string)$this->_response->getBody(), true);
+ $accessToken = $tokenData['access_token'];
+ $refreshToken = $tokenData['refresh_token'];
+
+ // リフレッシュトークンが取得できていることを確認
+ $this->assertArrayHasKey('refresh_token', $tokenData);
+ $this->assertNotEmpty($refreshToken);
+
+ // アクセストークンを使用してMCPサーバーのツールリストを取得
+ $requestConfig = [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $accessToken,
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ ]
+ ];
+
+ // MCPプロキシ経由でtools/listを呼び出し
+ $mcpRequest = [
+ 'jsonrpc' => '2.0',
+ 'id' => 'test-tools-list',
+ 'method' => 'tools/list'
+ ];
+ $this->configRequest($requestConfig);
+ $this->post('/bc-mcp', json_encode($mcpRequest));
+ $this->assertResponseCode(200);
+ $this->assertContentType('application/json');
+
+ $toolsResponse = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($toolsResponse, 'MCP tools list response should be valid JSON');
+ $this->assertArrayHasKey('result', $toolsResponse);
+ $this->assertArrayHasKey('tools', $toolsResponse['result']);
+ $this->assertIsArray($toolsResponse['result']['tools']);
+
+ // ツールリストの内、ブログ記事一覧の取得ツールを実行
+ $tools = $toolsResponse['result']['tools'];
+ // ツールリストに getBlogPostsが含まれていることを確認
+ $this->assertTrue(in_array('getBlogPosts', array_column($tools, 'name')), 'getBlogPosts tool should be available');
+
+ // ブログ記事一覧取得ツールを実行
+ $blogRequest = [
+ 'jsonrpc' => '2.0',
+ 'id' => 'test-blog-tool',
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => 'getBlogPosts',
+ 'arguments' => []
+ ]
+ ];
+ $this->configRequest($requestConfig);
+ $this->post('/bc-mcp', json_encode($blogRequest));
+ $this->assertResponseCode(200);
+
+ $blogResponse = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($blogResponse);
+ $this->assertArrayHasKey('result', $blogResponse);
+
+ // リフレッシュトークンを使用して新しいアクセストークンを取得
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refreshToken,
+ 'client_id' => $metadata['client_id'],
+ 'client_secret' => $metadata['client_secret']
+ ]);
+ $this->assertResponseCode(200);
+ $newTokenData = json_decode((string)$this->_response->getBody(), true);
+ $newAccessToken = $newTokenData['access_token'];
+
+ // 新しいアクセストークンが取得できていることを確認
+ $this->assertArrayHasKey('access_token', $newTokenData);
+ $this->assertNotEmpty($newAccessToken);
+ $this->assertNotEquals($accessToken, $newAccessToken, 'New access token should be different from the original');
+
+ // 新しいアクセストークンを使用してgetBlogPostツールを実行
+ $newRequestConfig = [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $newAccessToken,
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ ]
+ ];
+
+ // getBlogPostツールを実行(IDが必要な場合はダミーIDを使用)
+ $blogPostRequest = [
+ 'jsonrpc' => '2.0',
+ 'id' => 'test-blog-post-tool',
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => 'getBlogPost',
+ 'arguments' => [
+ 'id' => 1 // ダミーID
+ ]
+ ]
+ ];
+ $this->configRequest($newRequestConfig);
+ $this->post('/bc-mcp', json_encode($blogPostRequest));
+
+ // レスポンスコードが200または404(データが存在しない場合)であることを確認
+ $this->assertTrue(
+ in_array($this->_response->getStatusCode(), [200, 404]),
+ 'getBlogPost should return 200 (success) or 404 (not found)'
+ );
+
+ if ($this->_response->getStatusCode() === 200) {
+ $blogPostResponse = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($blogPostResponse);
+ $this->assertArrayHasKey('result', $blogPostResponse);
+ }
+ }
+
+ /**
+ * PKCE (Proof Key for Code Exchange) フローの統合テスト
+ * ChatGPTコネクタで使用されるPKCEフローをテスト
+ *
+ * @return void
+ */
+ public function testIntegrationWithPKCE(): void
+ {
+ $this->requireMcpServer();
+ // Step 1: OAuth2メタデータの取得
+ $this->get('/.well-known/oauth-authorization-server/bc-mcp');
+ $metadata = json_decode((string)$this->_response->getBody(), true);
+ $this->assertResponseOk();
+ $this->assertArrayHasKey('registration_endpoint', $metadata);
+ $this->assertArrayHasKey('code_challenge_methods_supported', $metadata);
+ $this->assertContains('S256', $metadata['code_challenge_methods_supported']);
+
+ // Step 2: 動的クライアント登録
+ $registrationEndpoint = $metadata['registration_endpoint'];
+ $this->post($registrationEndpoint, [
+ 'client_name' => 'ChatGPT Connector Test',
+ 'client_uri' => 'https://chatgpt.com',
+ 'redirect_uris' => ['https://chatgpt.com/connector_platform_oauth_redirect'],
+ 'grant_types' => ['authorization_code', 'refresh_token'],
+ 'response_types' => ['code'],
+ 'token_endpoint_auth_method' => 'none', // PKCEではclient_secretは不要
+ 'scope' => 'mcp:read mcp:write'
+ ]);
+ $this->assertResponseCode(201);
+ $clientData = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('client_id', $clientData);
+ $clientId = $clientData['client_id'];
+ $redirectUri = $clientData['redirect_uris'][0];
+
+ // Step 3: PKCE パラメータの生成
+ $codeVerifier = $this->generateCodeVerifier();
+ $codeChallenge = $this->generateCodeChallenge($codeVerifier);
+ $state = bin2hex(random_bytes(16));
+
+ // Step 4: 認可リクエスト(PKCEパラメータ付き)
+ $authParams = [
+ 'client_id' => $clientId,
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirectUri,
+ 'state' => $state,
+ 'scope' => 'mcp:read mcp:write',
+ 'code_challenge' => $codeChallenge,
+ 'code_challenge_method' => 'S256'
+ ];
+
+ // 未認証でのアクセス
+ $this->get('/bc-mcp/oauth2/authorize?' . http_build_query($authParams));
+ $this->assertResponseCode(302); // ログイン画面へリダイレクト
+
+ // 管理者でログイン
+ $this->loginAdmin($this->getRequest());
+ $this->get('/bc-mcp/oauth2/authorize?' . http_build_query($authParams));
+ $this->assertResponseOk(); // 認可画面が表示される
+
+ // Step 5: 認可承認(PKCEパラメータが保存される)
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams), [
+ 'action' => 'approve',
+ 'scope' => 'mcp:read mcp:write'
+ ]);
+ $this->assertResponseCode(302);
+
+ // リダイレクトURLから認可コードを取得
+ $redirectUrl = $this->_response->getHeaderLine('Location');
+ $this->assertStringContainsString('code=', $redirectUrl);
+ $this->assertStringContainsString('state=' . $state, $redirectUrl);
+
+ $queryParams = [];
+ parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams);
+ $this->assertArrayHasKey('code', $queryParams);
+ $this->assertEquals($state, $queryParams['state']);
+ $authCode = $queryParams['code'];
+
+ // Step 6: アクセストークン交換(PKCE検証)
+ $tokenParams = [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode,
+ 'redirect_uri' => $redirectUri,
+ 'client_id' => $clientId,
+ 'code_verifier' => $codeVerifier // client_secretの代わりにcode_verifierを使用
+ ];
+
+ $this->post('/bc-mcp/oauth2/token', $tokenParams);
+ $this->assertResponseOk();
+
+ $tokenData = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('access_token', $tokenData);
+ $this->assertArrayHasKey('token_type', $tokenData);
+ $this->assertEquals('Bearer', $tokenData['token_type']);
+ $accessToken = $tokenData['access_token'];
+
+ // Step 7: 不正なcode_verifierでのテスト(失敗することを確認)
+ $invalidTokenParams = [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode, // 同じ認可コードを再利用(実際は無効化されているはず)
+ 'redirect_uri' => $redirectUri,
+ 'client_id' => $clientId,
+ 'code_verifier' => 'invalid_verifier'
+ ];
+
+ $this->post('/bc-mcp/oauth2/token', $invalidTokenParams);
+ $this->assertResponseError(); // 400番台のエラーが返されることを確認
+
+ // Step 8: アクセストークンを使用してMCPサーバーにアクセス
+ $requestConfig = [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $accessToken,
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ ]
+ ];
+
+ // MCPプロキシ経由でtools/listを呼び出し
+ $mcpRequest = [
+ 'jsonrpc' => '2.0',
+ 'id' => 'pkce-test-tools-list',
+ 'method' => 'tools/list'
+ ];
+
+ $this->configRequest($requestConfig);
+ $this->post('/bc-mcp', json_encode($mcpRequest));
+ $this->assertResponseOk();
+ $this->assertContentType('application/json');
+
+ $toolsResponse = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($toolsResponse);
+ $this->assertArrayHasKey('result', $toolsResponse);
+ $this->assertArrayHasKey('tools', $toolsResponse['result']);
+ $this->assertIsArray($toolsResponse['result']['tools']);
+
+ // Step 9: ツール実行テスト
+ $tools = $toolsResponse['result']['tools'];
+ if (!empty($tools)) {
+ $firstTool = $tools[0];
+ $toolRequest = [
+ 'jsonrpc' => '2.0',
+ 'id' => 'pkce-test-tool-call',
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => $firstTool['name'],
+ 'arguments' => []
+ ]
+ ];
+
+ $this->configRequest($requestConfig);
+ $this->post('/bc-mcp', json_encode($toolRequest));
+ // ツールによってはパラメータが必要な場合があるので、200または400を許可
+ $this->assertTrue(
+ in_array($this->_response->getStatusCode(), [200, 400]),
+ 'Tool call should return 200 (success) or 400 (missing parameters)'
+ );
+ }
+ }
+
+ /**
+ * PKCEのcode_verifierを生成
+ * RFC 7636 に準拠した43-128文字のランダム文字列
+ *
+ * @return string
+ */
+ private function generateCodeVerifier(): string
+ {
+ $length = 43; // 最小文字数
+ $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+ $verifier = '';
+
+ for($i = 0; $i < $length; $i++) {
+ $verifier .= $characters[random_int(0, strlen($characters) - 1)];
+ }
+
+ return $verifier;
+ }
+
+ /**
+ * PKCEのcode_challengeを生成
+ * code_verifierのSHA256ハッシュをBase64URL エンコード
+ *
+ * @param string $codeVerifier
+ * @return string
+ */
+ private function generateCodeChallenge(string $codeVerifier): string
+ {
+ $hash = hash('sha256', $codeVerifier, true);
+ return rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
+ }
+
+ /**
+ * PKCEセキュリティテスト - 不正なcode_verifierでの失敗を確認
+ *
+ * @return void
+ */
+ public function testPKCESecurityFailure(): void
+ {
+ // クライアント登録
+ $this->get('/.well-known/oauth-authorization-server/bc-mcp');
+ $metadata = json_decode((string)$this->_response->getBody(), true);
+ $registrationEndpoint = $metadata['registration_endpoint'];
+
+ $this->post($registrationEndpoint, [
+ 'client_name' => 'PKCE Security Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['authorization_code'],
+ 'response_types' => ['code'],
+ 'token_endpoint_auth_method' => 'none',
+ 'scope' => 'mcp:read'
+ ]);
+ $clientData = json_decode((string)$this->_response->getBody(), true);
+ $clientId = $clientData['client_id'];
+ $redirectUri = $clientData['redirect_uris'][0];
+
+ // PKCE パラメータ生成
+ $codeVerifier = $this->generateCodeVerifier();
+ $codeChallenge = $this->generateCodeChallenge($codeVerifier);
+
+ // 認可フロー
+ $this->loginAdmin($this->getRequest());
+ $authParams = [
+ 'client_id' => $clientId,
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirectUri,
+ 'code_challenge' => $codeChallenge,
+ 'code_challenge_method' => 'S256'
+ ];
+
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams), [
+ 'action' => 'approve'
+ ]);
+
+ $redirectUrl = $this->_response->getHeaderLine('Location');
+ $queryParams = [];
+ parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams);
+ $authCode = $queryParams['code'];
+
+ // 正しいcode_verifierでトークン交換(成功するはず)
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode,
+ 'redirect_uri' => $redirectUri,
+ 'client_id' => $clientId,
+ 'code_verifier' => $codeVerifier
+ ]);
+ $this->assertResponseOk();
+ $tokenData = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('access_token', $tokenData);
+
+ // 新しい認可コードを取得(同じ認可コードは再利用できないため)
+ $codeVerifier2 = $this->generateCodeVerifier();
+ $codeChallenge2 = $this->generateCodeChallenge($codeVerifier2);
+ $authParams2 = [
+ 'client_id' => $clientId,
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirectUri,
+ 'code_challenge' => $codeChallenge2,
+ 'code_challenge_method' => 'S256'
+ ];
+
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams2), [
+ 'action' => 'approve'
+ ]);
+
+ $redirectUrl2 = $this->_response->getHeaderLine('Location');
+ $queryParams2 = [];
+ parse_str(parse_url($redirectUrl2, PHP_URL_QUERY), $queryParams2);
+ $authCode2 = $queryParams2['code'];
+
+ // 間違ったcode_verifierでトークン交換(失敗するはず)
+ $wrongVerifier = $this->generateCodeVerifier(); // 別のverifierを生成
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode2,
+ 'redirect_uri' => $redirectUri,
+ 'client_id' => $clientId,
+ 'code_verifier' => $wrongVerifier
+ ]);
+
+ // PKCE検証失敗でエラーが返されることを確認
+ $this->assertResponseError();
+ $errorResponse = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('error', $errorResponse);
+ $this->assertEquals('invalid_grant', $errorResponse['error']);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php
new file mode 100644
index 0000000000..8ee1765462
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php
@@ -0,0 +1,335 @@
+loadPlugins(['BcMcp']);
+ parent::setUp();
+
+ // CSRF保護を無効にする(CakePHP 5対応)
+ $this->enableCsrfToken();
+ $this->enableSecurityToken();
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+ }
+
+ /**
+ * Test dynamic client registration
+ *
+ * @return void
+ */
+ public function testDynamicClientRegistration(): void
+ {
+ $requestData = [
+ 'client_name' => 'Test Dynamic Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['authorization_code', 'client_credentials'],
+ 'scope' => 'mcp:read mcp:write',
+ 'token_endpoint_auth_method' => 'client_secret_basic',
+ 'contacts' => ['admin@example.com'],
+ 'client_uri' => 'https://example.com',
+ 'logo_uri' => 'https://example.com/logo.png'
+ ];
+
+ // JSONデータとして送信するための設定
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ // JSONエンコードしたデータを直接送信
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+
+ $this->assertResponseCode(201);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+
+ // Check required RFC7591 fields
+ $this->assertArrayHasKey('client_id', $response);
+ $this->assertArrayHasKey('client_secret', $response);
+ $this->assertArrayHasKey('registration_access_token', $response);
+ $this->assertArrayHasKey('registration_client_uri', $response);
+ $this->assertArrayHasKey('client_id_issued_at', $response);
+
+ // Check provided fields
+ $this->assertEquals('Test Dynamic Client', $response['client_name']);
+ $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']);
+ $this->assertEquals(['authorization_code', 'client_credentials'], $response['grant_types']);
+ $this->assertEquals('mcp:read mcp:write', $response['scope']);
+ $this->assertEquals('client_secret_basic', $response['token_endpoint_auth_method']);
+ $this->assertEquals(['admin@example.com'], $response['contacts']);
+ $this->assertEquals('https://example.com', $response['client_uri']);
+ $this->assertEquals('https://example.com/logo.png', $response['logo_uri']);
+ }
+
+ /**
+ * Test client configuration retrieval
+ *
+ * @return void
+ */
+ public function testClientConfigurationRetrieval(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Config Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials'],
+ 'scope' => 'mcp:read'
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $this->assertResponseCode(201);
+
+ $registrationResponse = json_decode((string)$this->_response->getBody(), true);
+ $clientId = $registrationResponse['client_id'];
+ $registrationToken = $registrationResponse['registration_access_token'];
+
+ // Then retrieve client configuration
+ $this->configRequest([
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $registrationToken,
+ 'Accept' => 'application/json'
+ ]
+ ]);
+
+ $this->get('/bc-mcp/oauth2/register/' . $clientId);
+ $this->assertResponseCode(200);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertEquals('Test Config Client', $response['client_name']);
+ $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']);
+ $this->assertEquals(['client_credentials'], $response['grant_types']);
+ $this->assertEquals('mcp:read', $response['scope']);
+ }
+
+ /**
+ * Test client configuration update
+ *
+ * @return void
+ */
+ public function testClientConfigurationUpdate(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Update Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials'],
+ 'scope' => 'mcp:read'
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $this->assertResponseCode(201);
+
+ $registrationResponse = json_decode((string)$this->_response->getBody(), true);
+ $clientId = $registrationResponse['client_id'];
+ $registrationToken = $registrationResponse['registration_access_token'];
+
+ // Update client configuration
+ $updateData = [
+ 'client_name' => 'Updated Client Name',
+ 'redirect_uris' => ['https://updated.com/callback'],
+ 'scope' => 'mcp:read mcp:write'
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $registrationToken,
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->put('/bc-mcp/oauth2/register/' . $clientId, json_encode($updateData));
+ $this->assertResponseCode(200);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertEquals('Updated Client Name', $response['client_name']);
+ $this->assertEquals(['https://updated.com/callback'], $response['redirect_uris']);
+ $this->assertEquals('mcp:read mcp:write', $response['scope']);
+ }
+
+ /**
+ * Test client deletion
+ *
+ * @return void
+ */
+ public function testClientDeletion(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Delete Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $this->assertResponseCode(201);
+
+ $registrationResponse = json_decode((string)$this->_response->getBody(), true);
+ $clientId = $registrationResponse['client_id'];
+ $registrationToken = $registrationResponse['registration_access_token'];
+
+ // Delete the client
+ $this->configRequest([
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $registrationToken,
+ 'Accept' => 'application/json'
+ ]
+ ]);
+
+ $this->delete('/bc-mcp/oauth2/register/' . $clientId);
+ $this->assertResponseCode(204); // No Content
+
+ // Verify client is deleted by trying to retrieve it
+ $this->get('/bc-mcp/oauth2/register/' . $clientId);
+ $this->assertResponseCode(401); // Unauthorized (client not found)
+ }
+
+ /**
+ * Test invalid client metadata
+ *
+ * @return void
+ */
+ public function testInvalidClientMetadata(): void
+ {
+ $requestData = [
+ 'client_name' => 'Invalid Client',
+ 'redirect_uris' => ['invalid-uri'], // Invalid URI
+ 'grant_types' => ['authorization_code']
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $this->assertResponseCode(400);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertEquals('invalid_client_metadata', $response['error']);
+ $this->assertStringContainsString('Invalid redirect_uri', $response['error_description']);
+ }
+
+ /**
+ * Test unsupported grant type
+ *
+ * @return void
+ */
+ public function testUnsupportedGrantType(): void
+ {
+ $requestData = [
+ 'client_name' => 'Unsupported Grant Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['unsupported_grant'] // Unsupported grant type
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $this->assertResponseCode(400);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertEquals('invalid_client_metadata', $response['error']);
+ $this->assertStringContainsString('Unsupported grant_type', $response['error_description']);
+ }
+
+ /**
+ * Test invalid registration access token
+ *
+ * @return void
+ */
+ public function testInvalidRegistrationAccessToken(): void
+ {
+ // Register a client first
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $this->configRequest([
+ 'headers' => [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ]
+ ]);
+
+ $this->post('/bc-mcp/oauth2/register', json_encode($requestData));
+ $registrationResponse = json_decode((string)$this->_response->getBody(), true);
+ $clientId = $registrationResponse['client_id'];
+
+ // Try to access with invalid token
+ $this->configRequest([
+ 'headers' => [
+ 'Authorization' => 'Bearer invalid_token',
+ 'Accept' => 'application/json'
+ ]
+ ]);
+
+ $this->get('/bc-mcp/oauth2/register/' . $clientId);
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertEquals('invalid_token', $response['error']);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php
new file mode 100644
index 0000000000..c58fde52fa
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php
@@ -0,0 +1,381 @@
+loadPlugins(['BcMcp']);
+ parent::setUp();
+
+ // OAuth2設定をセットアップ
+ Configure::write('BcMcp.OAuth2.clients', [
+ 'mcp-client' => [
+ 'name' => 'MCP Server Client',
+ 'secret' => 'mcp-secret-key',
+ 'redirect_uris' => ['http://localhost'],
+ 'grants' => ['client_credentials'],
+ 'scopes' => ['mcp:read', 'mcp:write']
+ ]
+ ]);
+
+ Configure::write('BcMcp.OAuth2.scopes', [
+ 'mcp:read' => 'データの読み取り',
+ 'mcp:write' => 'データの書き込み'
+ ]);
+
+ Configure::write('OAuth2.accessTokenTTL', 'PT1H');
+
+ // テスト用のOAuth2キーペアが存在することを確認
+ $privateKeyPath = CONFIG . 'oauth2_private.key';
+ $publicKeyPath = CONFIG . 'oauth2_public.key';
+
+ if (!file_exists($privateKeyPath) || !file_exists($publicKeyPath)) {
+ $this->generateTestKeys($privateKeyPath, $publicKeyPath);
+ }
+ }
+
+ /**
+ * テスト用のRSAキーペアを生成
+ */
+ private function generateTestKeys(string $privateKeyPath, string $publicKeyPath): void
+ {
+ $config = [
+ "digest_alg" => "sha256",
+ "private_key_bits" => 2048,
+ "private_key_type" => OPENSSL_KEYTYPE_RSA,
+ ];
+
+ $res = openssl_pkey_new($config);
+ openssl_pkey_export($res, $privKey);
+
+ $pubKey = openssl_pkey_get_details($res);
+ $publicKey = $pubKey["key"];
+
+ file_put_contents($privateKeyPath, $privKey);
+ file_put_contents($publicKeyPath, $publicKey);
+ }
+
+ /**
+ * Test token endpoint with valid client credentials (no auth required)
+ *
+ * @return void
+ */
+ public function testTokenEndpointWithValidCredentials(): void
+ {
+ Oauth2ClientFactory::make([
+ 'is_confidential' => true
+ ])->persist();
+ $this->loadFixtureScenario(InitAppScenario::class);
+
+ $this->loginAdmin($this->getRequest());
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'client_id' => 'mcp-client',
+ 'client_secret' => 'mcp-secret-key',
+ 'response_type' => 'code',
+ 'redirect_uri' => 'http://localhost',
+ 'scope' => 'mcp:read mcp:write',
+ ]), ['action' => 'approve']);
+ $redirectUrl = $this->_response->getHeaderLine('Location');
+ $queryParams = [];
+ parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams);
+ $authCode = $queryParams['code'];
+
+ // 認証なしでtokenエンドポイントをテスト
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'authorization_code',
+ 'client_id' => 'mcp-client',
+ 'redirect_uri' => 'http://localhost',
+ 'client_secret' => 'mcp-secret-key',
+ 'scope' => 'mcp:read mcp:write',
+ 'code' => $authCode
+ ]);
+
+ $this->assertResponseOk();
+ $this->assertResponseCode(200);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('access_token', $response);
+ $this->assertArrayHasKey('token_type', $response);
+ $this->assertArrayHasKey('expires_in', $response);
+ $this->assertEquals('Bearer', $response['token_type']);
+ }
+
+ /**
+ * Test authorization server metadata endpoint (no auth required)
+ *
+ * @return void
+ */
+ public function testAuthorizationServerMetadata(): void
+ {
+ $this->get('/.well-known/oauth-authorization-server/bc-mcp');
+
+ $this->assertResponseOk();
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('issuer', $response);
+ $this->assertArrayHasKey('token_endpoint', $response);
+ $this->assertArrayHasKey('authorization_endpoint', $response);
+ }
+
+ /**
+ * Test protected resource metadata endpoint (no auth required)
+ *
+ * @return void
+ */
+ public function testProtectedResourceMetadata(): void
+ {
+ $this->get('/.well-known/oauth-protected-resource/bc-mcp');
+
+ $this->assertResponseOk();
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('resource', $response);
+ $this->assertArrayHasKey('authorization_servers', $response);
+ }
+
+ /**
+ * Test client registration endpoint (no auth required)
+ *
+ * @return void
+ */
+ public function testClientRegistration(): void
+ {
+ $this->post('/bc-mcp/oauth2/register', [
+ 'client_name' => 'Test Client',
+ 'client_uri' => 'http://localhost',
+ 'redirect_uris' => ['http://localhost/callback'],
+ 'grant_types' => ['client_credentials'],
+ 'response_types' => ['code'],
+ 'scope' => 'mcp:read mcp:write'
+ ]);
+
+ $this->assertResponseCode(201);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('client_id', $response);
+ $this->assertArrayHasKey('client_secret', $response);
+ }
+
+ /**
+ * JWKSエンドポイントのテスト
+ */
+ public function testJwks(): void
+ {
+ $this->get('/bc-mcp/oauth2/jwks');
+ $this->assertResponseOk();
+ $this->assertContentType('application/json');
+ $body = json_decode((string)$this->_response->getBody(), true);
+ $this->assertArrayHasKey('keys', $body);
+ $this->assertNotEmpty($body['keys']);
+ $key = $body['keys'][0];
+ $this->assertEquals('RSA', $key['kty']);
+ $this->assertEquals('RS256', $key['alg']);
+ $this->assertEquals('sig', $key['use']);
+ $this->assertArrayHasKey('n', $key);
+ $this->assertArrayHasKey('e', $key);
+ }
+
+ /**
+ * Test verify endpoint with valid token
+ *
+ * @return void
+ */
+ public function testVerifyWithValidToken(): void
+ {
+ Oauth2ClientFactory::make([
+ 'is_confidential' => true
+ ])->persist();
+ $this->loadFixtureScenario(InitAppScenario::class);
+
+ $this->loginAdmin($this->getRequest());
+ $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([
+ 'grant_type' => 'authorization_code',
+ 'client_id' => 'mcp-client',
+ 'client_secret' => 'mcp-secret-key',
+ 'response_type' => 'code',
+ 'redirect_uri' => 'http://localhost',
+ 'scope' => 'mcp:read mcp:write',
+ ]), ['action' => 'approve']);
+ $redirectUrl = $this->_response->getHeaderLine('Location');
+ $queryParams = [];
+ parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams);
+ $authCode = $queryParams['code'];
+
+ // まず有効なトークンを取得
+ $this->post('/bc-mcp/oauth2/token', [
+ 'grant_type' => 'authorization_code',
+ 'client_id' => 'mcp-client',
+ 'redirect_uri' => 'http://localhost',
+ 'client_secret' => 'mcp-secret-key',
+ 'scope' => 'mcp:read mcp:write',
+ 'code' => $authCode
+ ]);
+
+ $this->assertResponseOk();
+ $tokenResponse = json_decode((string)$this->_response->getBody(), true);
+ $accessToken = $tokenResponse['access_token'];
+
+ // 取得したトークンでverifyエンドポイントをテスト
+ $this->configRequest([
+ 'headers' => ['Authorization' => 'Bearer ' . $accessToken]
+ ]);
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseOk();
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('valid', $response);
+ $this->assertTrue($response['valid']);
+ $this->assertArrayHasKey('client_id', $response);
+
+ // client_idが期待される形式かどうかをチェック(URLまたは元のclient_id)
+ $this->assertNotEmpty($response['client_id']);
+
+ $this->assertArrayHasKey('scope', $response);
+ $this->assertStringContainsString('mcp:read', $response['scope']);
+ $this->assertStringContainsString('mcp:write', $response['scope']);
+ }
+
+ /**
+ * Test verify endpoint with missing token
+ *
+ * @return void
+ */
+ public function testVerifyWithMissingToken(): void
+ {
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('error', $response);
+ $this->assertEquals('invalid_token', $response['error']);
+ $this->assertArrayHasKey('error_description', $response);
+ $this->assertEquals('The access token is missing or invalid.', $response['error_description']);
+ }
+
+ /**
+ * Test verify endpoint with invalid token format
+ *
+ * @return void
+ */
+ public function testVerifyWithInvalidTokenFormat(): void
+ {
+ $this->configRequest([
+ 'headers' => ['Authorization' => 'InvalidFormat token123']
+ ]);
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('error', $response);
+ $this->assertEquals('invalid_token', $response['error']);
+ $this->assertArrayHasKey('error_description', $response);
+ $this->assertEquals('The access token is missing or invalid.', $response['error_description']);
+ }
+
+ /**
+ * Test verify endpoint with invalid token
+ *
+ * @return void
+ */
+ public function testVerifyWithInvalidToken(): void
+ {
+ $this->configRequest([
+ 'headers' => ['Authorization' => 'Bearer invalid_token_string']
+ ]);
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('error', $response);
+ $this->assertEquals('invalid_token', $response['error']);
+ $this->assertArrayHasKey('error_description', $response);
+ $this->assertEquals('The access token is invalid or expired.', $response['error_description']);
+ }
+
+ /**
+ * Test verify endpoint with empty Authorization header
+ *
+ * @return void
+ */
+ public function testVerifyWithEmptyAuthorizationHeader(): void
+ {
+ $this->configRequest([
+ 'headers' => ['Authorization' => '']
+ ]);
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('error', $response);
+ $this->assertEquals('invalid_token', $response['error']);
+ $this->assertArrayHasKey('error_description', $response);
+ $this->assertEquals('The access token is missing or invalid.', $response['error_description']);
+ }
+
+ /**
+ * Test verify endpoint with Bearer but no token
+ *
+ * @return void
+ */
+ public function testVerifyWithBearerButNoToken(): void
+ {
+ $this->configRequest([
+ 'headers' => ['Authorization' => 'Bearer ']
+ ]);
+ $this->get('/bc-mcp/oauth2/verify');
+
+ $this->assertResponseCode(401);
+ $this->assertContentType('application/json');
+
+ $response = json_decode((string)$this->_response->getBody(), true);
+ $this->assertNotNull($response, 'Response should be valid JSON');
+ $this->assertArrayHasKey('error', $response);
+ $this->assertEquals('invalid_token', $response['error']);
+ $this->assertArrayHasKey('error_description', $response);
+ $this->assertEquals('The access token is invalid or expired.', $response['error_description']);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php
new file mode 100644
index 0000000000..ad2116f581
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php
@@ -0,0 +1,223 @@
+BaseMcpTool = new TestBaseMcpTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->BaseMcpTool);
+ parent::tearDown();
+ }
+
+ /**
+ * test processFileUpload with base64 data
+ */
+ public function testProcessFileUploadWithBase64()
+ {
+ // 小さなPNG画像のbase64データ(1x1ピクセルの透明PNG)
+ $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg==';
+
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'processFileUpload', [$base64Data]);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('name', $result);
+ $this->assertArrayHasKey('type', $result);
+ $this->assertEquals('image/png', $result['type']);
+ $this->assertEquals('png', $result['ext']);
+
+ // クリーンアップ
+ if (file_exists($result['tmp_name'])) {
+ unlink($result['tmp_name']);
+ }
+ }
+
+ /**
+ * test processFileUpload with URL
+ */
+ public function testProcessFileUploadWithUrl()
+ {
+ $url = 'https://basercms.net/img/basercms_logo.png';
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'processFileUpload', [$url]);
+
+ // URLの場合はそのまま返される
+ $this->assertArrayHasKey('tmp_name', $result);
+ }
+
+ /**
+ * test getMimeTypeFromExtension
+ */
+ public function testGetMimeTypeFromExtension()
+ {
+ $testCases = [
+ 'jpg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'pdf' => 'application/pdf',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'mp3' => 'audio/mpeg',
+ 'unknown' => 'application/octet-stream'
+ ];
+
+ foreach($testCases as $extension => $expectedMimeType) {
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'getMimeTypeFromExtension', [$extension]);
+ $this->assertEquals($expectedMimeType, $result, "Extension: {$extension}");
+ }
+ }
+
+ /**
+ * test getExtensionFromMimeType
+ */
+ public function testGetExtensionFromMimeType()
+ {
+ $testCases = [
+ 'image/jpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'application/pdf' => 'pdf',
+ 'text/plain' => 'txt',
+ 'application/unknown' => 'bin'
+ ];
+
+ foreach($testCases as $mimeType => $expectedExtension) {
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'getExtensionFromMimeType', [$mimeType]);
+ $this->assertEquals($expectedExtension, $result, "MIME Type: {$mimeType}");
+ }
+ }
+
+ /**
+ * test isAllowedExtension
+ */
+ public function testIsAllowedExtension()
+ {
+ $allowedExtensions = ['jpg', 'png', 'pdf', 'docx'];
+ $disallowedExtensions = ['exe', 'bat', 'sh'];
+
+ foreach($allowedExtensions as $extension) {
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'isAllowedExtension', [$extension]);
+ $this->assertTrue($result, "Extension should be allowed: {$extension}");
+ }
+
+ foreach($disallowedExtensions as $extension) {
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'isAllowedExtension', [$extension]);
+ $this->assertFalse($result, "Extension should not be allowed: {$extension}");
+ }
+ }
+
+ /**
+ * test processImageUpload
+ */
+ public function testProcessImageUpload()
+ {
+ // 画像のbase64データ
+ $imageBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg==';
+
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'processImageUpload', [$imageBase64]);
+
+ $this->assertIsArray($result);
+ $this->assertEquals('image/png', $result['type']);
+
+ // クリーンアップ
+ if (file_exists($result['tmp_name'])) {
+ unlink($result['tmp_name']);
+ }
+ }
+
+ /**
+ * test processImageUpload with non-image file should throw exception
+ */
+ public function testProcessImageUploadWithNonImageFile()
+ {
+ // PDFのbase64データ(非画像ファイル)
+ $pdfBase64 = 'data:application/pdf;base64,JVBERi0xLjQK';
+
+ try {
+ $this->execPrivateMethod($this->BaseMcpTool, 'processImageUpload', [$pdfBase64]);
+ $this->fail('例外が投げられるべきです');
+ } catch (\Exception $e) {
+ $this->assertStringContainsString('画像ファイルではありません', $e->getMessage());
+ }
+ }
+
+ /**
+ * test isFileUploadable method
+ */
+ public function testIsFileUploadable()
+ {
+ // Base64データ
+ $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg==';
+ $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$base64Data]));
+
+ // URL
+ $url = 'https://example.com/image.jpg';
+ $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$url]));
+
+ // 通常の文字列
+ $text = 'ただのテキスト';
+ $this->assertFalse($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$text]));
+
+ // 配列
+ $array = ['test' => 'value'];
+ $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$array]));
+ }
+
+ /**
+ * test executeWithErrorHandling
+ *
+ * \Exception だけではなく \Error も捕捉し、トレースを返す事を確認する
+ * (MCPサーバー側で丸められると発生箇所を追跡できなくなるため)
+ */
+ public function testExecuteWithErrorHandling()
+ {
+ // \Exception を捕捉できる事を確認
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'executeWithErrorHandling', [
+ function() {
+ throw new \Exception('例外が発生しました');
+ }
+ ]);
+ $this->assertEquals('例外が発生しました', $result['content']);
+ $this->assertArrayHasKey('trace', $result);
+
+ // \Error を捕捉できる事を確認
+ $result = $this->execPrivateMethod($this->BaseMcpTool, 'executeWithErrorHandling', [
+ function() {
+ $request = null;
+ return $request->getParam('prefix');
+ }
+ ]);
+ $this->assertStringContainsString('getParam() on null', $result['content']);
+ $this->assertArrayHasKey('trace', $result);
+ }
+}
+
+/**
+ * テスト用のBaseMcpToolクラス
+ */
+class TestBaseMcpTool extends BaseMcpTool
+{
+ // テスト用のため空実装
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php
new file mode 100644
index 0000000000..1472a01d61
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php
@@ -0,0 +1,359 @@
+fileUploadTool = new FileUploadTool();
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ // テスト用ファイルをクリーンアップ
+ $this->cleanupTestFiles();
+ unset($this->fileUploadTool);
+ parent::tearDown();
+ }
+
+ /**
+ * テスト用ファイルをクリーンアップ
+ */
+ private function cleanupTestFiles(): void
+ {
+ $uploadDir = TMP . 'mcp_uploads/';
+ if (is_dir($uploadDir)) {
+ $files = glob($uploadDir . '*');
+ foreach($files as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+ }
+ }
+
+ /**
+ * 単一チャンクファイルのアップロードテスト
+ */
+ public function testSendSingleChunk()
+ {
+ $fileId = 'test_file_' . uniqid();
+ $filename = 'test.txt';
+ $content = 'Hello, World!';
+ $chunkData = base64_encode($content);
+
+ $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, $chunkData, $filename);
+
+ $this->assertEquals('complete', $result['status']);
+ $this->assertArrayHasKey('file', $result);
+
+ // ファイルが正しく作成されているかチェック
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($finalFile));
+ $this->assertEquals($content, file_get_contents($finalFile));
+ }
+
+ /**
+ * 複数チャンクファイルのアップロードテスト
+ */
+ public function testSendMultipleChunks()
+ {
+ $fileId = 'test_multi_' . uniqid();
+ $filename = 'test_multi.txt';
+ $content1 = 'Hello, ';
+ $content2 = 'World!';
+ $totalChunks = 2;
+
+ // 最初のチャンクを送信
+ $result1 = $this->fileUploadTool->sendFileChunk($fileId, 0, $totalChunks, base64_encode($content1), $filename);
+
+ $this->assertEquals('chunk_received', $result1['status']);
+ $this->assertEquals(1, $result1['progress']);
+
+ // 2番目のチャンクを送信
+ $result2 = $this->fileUploadTool->sendFileChunk($fileId, 1, $totalChunks, base64_encode($content2), $filename);
+
+ $this->assertEquals('complete', $result2['status']);
+ $this->assertArrayHasKey('file', $result2);
+
+ // マージされたファイルが正しく作成されているかチェック
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($finalFile));
+ $this->assertEquals($content1 . $content2, file_get_contents($finalFile));
+
+ // チャンクファイルが削除されているかチェック
+ $this->assertFalse(file_exists(TMP . 'mcp_uploads/' . $fileId . '.part0'));
+ $this->assertFalse(file_exists(TMP . 'mcp_uploads/' . $fileId . '.part1'));
+ }
+
+ /**
+ * チャンク順序が異なる場合のテスト
+ */
+ public function testSendChunksOutOfOrder()
+ {
+ $fileId = 'test_order_' . uniqid();
+ $filename = 'test_order.txt';
+ $content1 = 'Hello, ';
+ $content2 = 'World!';
+ $totalChunks = 2;
+
+ // 2番目のチャンクを先に送信
+ $result1 = $this->fileUploadTool->sendFileChunk($fileId, 1, $totalChunks, base64_encode($content2), $filename);
+
+ $this->assertEquals('chunk_received', $result1['status']);
+ $this->assertEquals(2, $result1['progress']);
+
+ // 1番目のチャンクを後で送信
+ $result2 = $this->fileUploadTool->sendFileChunk($fileId, 0, $totalChunks, base64_encode($content1), $filename);
+
+ $this->assertEquals('complete', $result2['status']);
+
+ // マージされたファイルが正しい順序で作成されているかチェック
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($finalFile));
+ $this->assertEquals($content1 . $content2, file_get_contents($finalFile));
+ }
+
+ /**
+ * 大きなファイルの分割アップロードテスト
+ */
+ public function testLargeFileUpload()
+ {
+ $fileId = 'test_large_' . uniqid();
+ $filename = 'test_large.txt';
+ $chunkSize = 1024; // 1KB chunks
+ $totalSize = 3000; // 3KB total
+ $content = str_repeat('A', $totalSize);
+
+ $chunks = str_split($content, $chunkSize);
+ $totalChunks = count($chunks);
+
+ // 各チャンクを順番に送信
+ for($i = 0; $i < $totalChunks - 1; $i++) {
+ $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename);
+
+ $this->assertEquals('chunk_received', $result['status']);
+ $this->assertEquals($i + 1, $result['progress']);
+ }
+
+ // 最後のチャンクを送信
+ $lastIndex = $totalChunks - 1;
+ $result = $this->fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename);
+
+ $this->assertEquals('complete', $result['status']);
+
+ // マージされたファイルが正しく作成されているかチェック
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($finalFile));
+ $this->assertEquals($totalSize, filesize($finalFile));
+ $this->assertEquals($content, file_get_contents($finalFile));
+ }
+
+ /**
+ * 不正なbase64データのテスト
+ */
+ public function testInvalidBase64Data()
+ {
+ $fileId = 'test_invalid_' . uniqid();
+ $filename = 'test_invalid.txt';
+ $invalidBase64 = 'invalid-base64-data!@#$%';
+
+ $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, $invalidBase64, $filename);
+
+ // base64_decodeはfalseを返すが、空文字列として処理される
+ $this->assertEquals('complete', $result['status']);
+
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($finalFile));
+ // 不正なbase64は空文字列またはガベージデータになる
+ $this->assertTrue(filesize($finalFile) >= 0);
+ }
+
+ /**
+ * アップロードディレクトリが作成されることをテスト
+ */
+ public function testUploadDirectoryCreation()
+ {
+ $uploadDir = TMP . 'mcp_uploads/';
+
+ // ディレクトリが存在することを確認
+ $this->assertTrue(is_dir($uploadDir));
+ $this->assertTrue(is_writable($uploadDir));
+ }
+
+ /**
+ * 同じファイルIDで複数回アップロードした場合のテスト
+ */
+ public function testDuplicateFileId()
+ {
+ $fileId = 'test_duplicate_' . uniqid();
+ $filename = 'test_duplicate.txt';
+ $content1 = 'First upload';
+ $content2 = 'Second upload';
+
+ // 最初のアップロード
+ $result1 = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($content1), $filename);
+ $this->assertEquals('complete', $result1['status']);
+
+ // 同じファイルIDで2回目のアップロード(上書きされる)
+ $result2 = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($content2), $filename);
+ $this->assertEquals('complete', $result2['status']);
+
+ // 最後にアップロードされたファイルの内容を確認
+ $finalFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertEquals($content2, file_get_contents($finalFile));
+ }
+
+ /**
+ * 実際の画像ファイル(basercms.png)を使ったアップロードテスト
+ */
+ public function testUploadRealImageFile()
+ {
+ $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png';
+
+ // ファイルが存在することを確認
+ $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません');
+
+ $imageContent = file_get_contents($imagePath);
+ $fileId = 'test_image_' . uniqid();
+ $filename = 'basercms.png';
+
+ // 画像ファイルを単一チャンクでアップロード
+ $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($imageContent), $filename);
+
+ $this->assertEquals('complete', $result['status']);
+ $this->assertArrayHasKey('file', $result);
+
+ // アップロードされたファイルが元のファイルと同じであることを確認
+ $uploadedFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($uploadedFile));
+ $this->assertEquals(filesize($imagePath), filesize($uploadedFile));
+ $this->assertEquals($imageContent, file_get_contents($uploadedFile));
+
+ // ファイルがPNG画像として有効か確認(オプション)
+ $imageInfo = getimagesize($uploadedFile);
+ $this->assertNotFalse($imageInfo, 'アップロードされたファイルが有効な画像ではありません');
+ $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'アップロードされたファイルがPNG形式ではありません');
+ }
+
+ /**
+ * 画像ファイルを複数チャンクに分割してアップロードするテスト
+ */
+ public function testUploadImageFileInChunks()
+ {
+ $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png';
+
+ // ファイルが存在することを確認
+ $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません');
+
+ $imageContent = file_get_contents($imagePath);
+ $fileId = 'test_image_chunks_' . uniqid();
+ $filename = 'basercms_chunked.png';
+
+ // 画像を1024バイトずつのチャンクに分割
+ $chunkSize = 1024;
+ $chunks = str_split($imageContent, $chunkSize);
+ $totalChunks = count($chunks);
+
+ $this->assertGreaterThan(1, $totalChunks, '画像ファイルが小さすぎてチャンク分割できません');
+
+ // 各チャンクを順番に送信(最後のチャンク以外)
+ for($i = 0; $i < $totalChunks - 1; $i++) {
+ $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename);
+
+ $this->assertEquals('chunk_received', $result['status']);
+ $this->assertEquals($i + 1, $result['progress']);
+ }
+
+ // 最後のチャンクを送信
+ $lastIndex = $totalChunks - 1;
+ $result = $this->fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename);
+
+ $this->assertEquals('complete', $result['status']);
+
+ // マージされたファイルが元のファイルと同じであることを確認
+ $uploadedFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($uploadedFile));
+ $this->assertEquals(filesize($imagePath), filesize($uploadedFile));
+ $this->assertEquals($imageContent, file_get_contents($uploadedFile));
+
+ // ファイルがPNG画像として有効か確認
+ $imageInfo = getimagesize($uploadedFile);
+ $this->assertNotFalse($imageInfo, 'マージされたファイルが有効な画像ではありません');
+ $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'マージされたファイルがPNG形式ではありません');
+
+ // チャンクファイルが削除されているかチェック
+ for($i = 0; $i < $totalChunks; $i++) {
+ $chunkFile = TMP . 'mcp_uploads/' . $fileId . '.part' . $i;
+ $this->assertFalse(file_exists($chunkFile), "チャンクファイル {$chunkFile} が削除されていません");
+ }
+ }
+
+ /**
+ * 大きな画像ファイルのMD5ハッシュチェックテスト
+ */
+ public function testImageFileIntegrityWithMd5()
+ {
+ $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png';
+
+ // ファイルが存在することを確認
+ $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません');
+
+ $imageContent = file_get_contents($imagePath);
+ $originalMd5 = md5($imageContent);
+ $fileId = 'test_image_md5_' . uniqid();
+ $filename = 'basercms_md5.png';
+
+ // 画像を512バイトずつの小さなチャンクに分割(より細かい分割)
+ $chunkSize = 512;
+ $chunks = str_split($imageContent, $chunkSize);
+ $totalChunks = count($chunks);
+
+ // 各チャンクを順番に送信
+ for($i = 0; $i < $totalChunks; $i++) {
+ $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename);
+
+
+ if ($i < $totalChunks - 1) {
+ $this->assertEquals('chunk_received', $result['status']);
+ } else {
+ $this->assertEquals('complete', $result['status']);
+ }
+ }
+
+ // アップロードされたファイルのMD5ハッシュを確認
+ $uploadedFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($uploadedFile));
+
+ $uploadedMd5 = md5_file($uploadedFile);
+ $this->assertEquals($originalMd5, $uploadedMd5, 'アップロードされたファイルのMD5ハッシュが元のファイルと一致しません');
+
+ // ファイルサイズも確認
+ $this->assertEquals(filesize($imagePath), filesize($uploadedFile), 'ファイルサイズが一致しません');
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php
new file mode 100644
index 0000000000..5fcfa44800
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php
@@ -0,0 +1,94 @@
+searchIndexesTool = new SearchIndexesTool();
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ unset($this->searchIndexesTool);
+ parent::tearDown();
+ }
+
+ public function testFetch()
+ {
+ // テストデータを作成
+ SearchIndexFactory::make([[
+ 'id' => 1,
+ 'title' => 'テストタイトル1',
+ 'detail' => 'テスト詳細1',
+ 'url' => '/test-url-1',
+ 'status' => 0,
+ ], [
+ 'id' => 2,
+ 'title' => 'テストタイトル2',
+ 'detail' => 'テスト詳細2',
+ 'url' => '/test-url-2',
+ 'status' => 1,
+ ]])->persist();
+
+ // status=0(非公開)のデータはstatus='publish'フィルターで除外される
+ $result = $this->searchIndexesTool->fetch("1");
+ $this->assertEquals('Record not found in table `search_indexes`.', $result['content']);
+
+ // status=1(公開)のデータは取得できる
+ $result = $this->searchIndexesTool->fetch("2");
+ $this->assertArrayHasKey('type', $result);
+ $this->assertEquals('resource', $result['type']);
+ $this->assertArrayHasKey('resource', $result);
+ }
+
+ public function testSearch()
+ {
+ // テストデータを作成
+ SearchIndexFactory::make([[
+ 'id' => 1,
+ 'title' => 'テストタイトル1',
+ 'detail' => 'テスト詳細1',
+ 'url' => '/test-url-1',
+ 'status' => 0,
+ ], [
+ 'id' => 2,
+ 'title' => 'テストタイトル2',
+ 'detail' => 'テスト詳細2',
+ 'url' => '/test-url-2',
+ 'status' => 1,
+ ]])->persist();
+
+ $result = $this->searchIndexesTool->search("詳細");
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf('BcMcp\Schema\Content\ResourceLinkContent', $result[0]);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php
new file mode 100644
index 0000000000..7307da80ef
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php
@@ -0,0 +1,518 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcBlog;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcMcp\Mcp\BcBlog\BlogCategoriesTool;
+use BcBlog\Test\Factory\BlogCategoryFactory;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+
+/**
+ * BcMcp\Mcp\BcBlog\BlogCategoriesTool Test Case
+ *
+ * @uses \BcMcp\Mcp\BcBlog\BlogCategoriesTool
+ */
+class BlogCategoriesToolTest extends BcTestCase
+{
+ use ScenarioAwareTrait;
+
+ /**
+ * Test subject
+ *
+ * @var \BcMcp\Mcp\BcBlog\BlogCategoriesTool
+ */
+ protected $BlogCategoriesTool;
+
+ /**
+ * setUp method
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->BlogCategoriesTool = new BlogCategoriesTool();
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ unset($this->BlogCategoriesTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test addBlogCategory method - 基本テスト
+ *
+ * @return void
+ */
+ public function testAddBlogCategoryBasic()
+ {
+ $title = 'テストカテゴリ';
+ $blogContentId = 1;
+
+ $result = $this->BlogCategoriesTool->addBlogCategory(
+ title: $title,
+ blogContentId: $blogContentId
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ }
+
+ /**
+ * Test getBlogCategories method - 基本テスト
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesBasic()
+ {
+ // テストデータを作成
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => 'テストカテゴリ1',
+ 'name' => 'test-category-1',
+ 'status' => 1
+ ])->persist();
+
+ $result = $this->BlogCategoriesTool->getBlogCategories(1);
+
+ $this->assertIsArray($result);
+ $this->assertNotEmpty($result);
+ }
+
+ /**
+ * Test getBlogCategory method - IDによる取得
+ *
+ * @return void
+ */
+ public function testGetBlogCategoryById()
+ {
+ // テストデータを作成
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => 'テストカテゴリ',
+ 'name' => 'test-category',
+ 'status' => 1
+ ])->persist();
+
+ $result = $this->BlogCategoriesTool->getBlogCategory(1);
+
+ $this->assertIsArray($result);
+ // IDが存在する場合は成功を想定
+ $this->assertEquals(1, $result['id']);
+ }
+
+ /**
+ * Test editBlogCategory method - 編集機能
+ *
+ * @return void
+ */
+ public function testEditBlogCategory()
+ {
+ // テストデータを作成
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => 'テストカテゴリ',
+ 'name' => 'test-category',
+ 'status' => 1
+ ])->persist();
+
+ $newTitle = '編集テストカテゴリ';
+
+ $result = $this->BlogCategoriesTool->editBlogCategory(
+ id: 1,
+ title: $newTitle
+ );
+
+ $this->assertIsArray($result);
+ $this->assertEquals($newTitle, $result['title']);
+ }
+
+ /**
+ * Test deleteBlogCategory method - 削除機能
+ *
+ * @return void
+ */
+ public function testDeleteBlogCategory()
+ {
+ // テストデータを作成
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => 'テストカテゴリ',
+ 'name' => 'test-category',
+ 'status' => 1
+ ])->persist();
+
+ $result = $this->BlogCategoriesTool->deleteBlogCategory(1);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('message', $result);
+ }
+
+ /**
+ * Test addBlogCategory method - エラーテスト(空のタイトル)
+ *
+ * @return void
+ */
+ public function testAddBlogCategoryWithEmptyTitle()
+ {
+ $result = $this->BlogCategoriesTool->addBlogCategory('');
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('titleは必須です', $result['content']);
+ }
+
+ /**
+ * Test getBlogCategory method - 存在しないIDのテスト
+ *
+ * @return void
+ */
+ public function testGetBlogCategoryNotFound()
+ {
+ $nonExistentId = 999999;
+
+ $result = $this->BlogCategoriesTool->getBlogCategory($nonExistentId);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `blog_categories`.', $result['content']);
+ }
+
+ /**
+ * Test getBlogCategories method - ページネーションテスト(limit指定)
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithLimit()
+ {
+ // 複数のテストデータを作成
+ for($i = 1; $i <= 5; $i++) {
+ BlogCategoryFactory::make([
+ 'id' => $i,
+ 'blog_content_id' => 1,
+ 'title' => "テストカテゴリ{$i}",
+ 'name' => "test-category-{$i}",
+ 'status' => 1
+ ])->persist();
+ }
+
+ // limit=3で取得
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ limit: 3
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // ページネーション情報の確認
+ $this->assertEquals(1, $result['pagination']['page']);
+ $this->assertEquals(3, $result['pagination']['limit']);
+ $this->assertEquals(3, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(5, $result['pagination']['total']); // 総件数
+ }
+
+ /**
+ * Test getBlogCategories method - ページネーションテスト(page指定)
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithPage()
+ {
+ // 複数のテストデータを作成
+ for($i = 1; $i <= 10; $i++) {
+ BlogCategoryFactory::make([
+ 'id' => $i,
+ 'blog_content_id' => 1,
+ 'title' => "テストカテゴリ{$i}",
+ 'name' => "test-category-{$i}",
+ 'status' => 1
+ ])->persist();
+ }
+
+ // page=2, limit=3で取得
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ limit: 3,
+ page: 2
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // ページネーション情報の確認
+ $this->assertEquals(2, $result['pagination']['page']);
+ $this->assertEquals(3, $result['pagination']['limit']);
+ $this->assertEquals(3, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(10, $result['pagination']['total']); // 総件数
+ }
+
+ /**
+ * Test getBlogCategories method - ページネーションテスト(limit未指定)
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithoutLimit()
+ {
+ // 複数のテストデータを作成
+ for($i = 1; $i <= 5; $i++) {
+ BlogCategoryFactory::make([
+ 'id' => $i,
+ 'blog_content_id' => 1,
+ 'title' => "テストカテゴリ{$i}",
+ 'name' => "test-category-{$i}",
+ 'status' => 1
+ ])->persist();
+ }
+
+ // limitを指定せずに取得
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ page: 1
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // ページネーション情報の確認
+ $this->assertEquals(1, $result['pagination']['page']);
+ $this->assertNull($result['pagination']['limit']);
+ $this->assertEquals(5, $result['pagination']['count']);
+ $this->assertEquals(5, $result['pagination']['total']); // 総件数
+ }
+
+ /**
+ * Test getBlogCategories method - ページネーションテスト(空のページ)
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesEmptyPage()
+ {
+ // 5件のテストデータを作成
+ for($i = 1; $i <= 5; $i++) {
+ BlogCategoryFactory::make([
+ 'id' => $i,
+ 'blog_content_id' => 1,
+ 'title' => "テストカテゴリ{$i}",
+ 'name' => "test-category-{$i}",
+ 'status' => 1
+ ])->persist();
+ }
+
+ // 存在しないページ(page=10, limit=3)で取得
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ limit: 3,
+ page: 10
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // ページネーション情報の確認
+ $this->assertEquals(10, $result['pagination']['page']);
+ $this->assertEquals(3, $result['pagination']['limit']);
+ $this->assertEquals(0, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(5, $result['pagination']['total']); // 総件数
+ }
+
+ /**
+ * Test getBlogCategories method - 公開状態フィルタテスト
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithPublishStatus()
+ {
+ // BlogContentScenarioを使用してBlogContentとContentを作成
+ $this->loadFixtureScenario(\BcBlog\Test\Scenario\BlogContentScenario::class, 1, 1, 1, 'blog', '/blog/', 'ブログ');
+
+ // 2つのカテゴリを作成(1つは公開、1つは非公開)
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => '公開カテゴリ',
+ 'name' => 'public-category',
+ 'status' => 1 // 公開
+ ])->persist();
+
+ BlogCategoryFactory::make([
+ 'id' => 2,
+ 'blog_content_id' => 1,
+ 'title' => '非公開カテゴリ',
+ 'name' => 'private-category',
+ 'status' => 0 // 非公開
+ ])->persist();
+
+ // status=1を指定すると'publish'に変換される
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ status: 'publish'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // 結果の確認:公開されているカテゴリのみが取得される
+ // paginationキーを除外してカテゴリデータを取得
+ $categories = array_values(array_filter($result, function($key) {
+ return $key !== 'pagination';
+ }, ARRAY_FILTER_USE_KEY));
+
+ $this->assertCount(1, $categories); // 公開されているカテゴリのみ1件
+ $this->assertEquals('公開カテゴリ', $categories[0]['title']);
+ $this->assertEquals(1, $categories[0]['status']);
+
+ // ページネーション情報の確認
+ $this->assertEquals(1, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(1, $result['pagination']['total']); // 公開状態の総件数
+ }
+
+ /**
+ * Test getBlogCategories method - 全ての状態のカテゴリ取得テスト
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithAllStatus()
+ {
+ // 2つのカテゴリを作成(1つは公開、1つは非公開)
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => '公開カテゴリ',
+ 'name' => 'public-category',
+ 'status' => 1 // 公開
+ ])->persist();
+
+ BlogCategoryFactory::make([
+ 'id' => 2,
+ 'blog_content_id' => 1,
+ 'title' => '非公開カテゴリ',
+ 'name' => 'private-category',
+ 'status' => 0 // 非公開
+ ])->persist();
+
+ // 全ての状態のカテゴリを取得(status指定なし)
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // 結果の確認
+ $categories = array_values(array_filter($result, function($key) {
+ return $key !== 'pagination';
+ }, ARRAY_FILTER_USE_KEY));
+ $this->assertCount(2, $categories); // 公開・非公開両方取得される
+
+ // ページネーション情報の確認
+ $this->assertEquals(2, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(2, $result['pagination']['total']); // 全件数
+ }
+
+ /**
+ * Test getBlogCategories method - status=0(非公開)は対応しないテスト
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithUnpublishStatusNotSupported()
+ {
+ // 2つのカテゴリを作成(1つは公開、1つは非公開)
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => '公開カテゴリ',
+ 'name' => 'public-category',
+ 'status' => 1 // 公開
+ ])->persist();
+
+ BlogCategoryFactory::make([
+ 'id' => 2,
+ 'blog_content_id' => 1,
+ 'title' => '非公開カテゴリ',
+ 'name' => 'private-category',
+ 'status' => 0 // 非公開
+ ])->persist();
+
+ // status=0を指定(対応しないため、全てのカテゴリが取得される)
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ blogContentId: 1,
+ status: null
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // 結果の確認:status=0は対応しないため、全てのカテゴリが取得される
+ $categories = array_values(array_filter($result, function($key) {
+ return $key !== 'pagination';
+ }, ARRAY_FILTER_USE_KEY));
+ $this->assertCount(2, $categories); // 公開・非公開両方取得される
+
+ // ページネーション情報の確認
+ $this->assertEquals(2, $result['pagination']['count']); // 実際に返された件数
+ $this->assertEquals(2, $result['pagination']['total']); // 全件数
+ }
+
+ /**
+ * testGetBlogCategoriesWithTitle
+ *
+ * @return void
+ */
+ public function testGetBlogCategoriesWithTitle()
+ {
+ // 2つのカテゴリを作成(1つは公開、1つは非公開)
+ BlogCategoryFactory::make([
+ 'id' => 1,
+ 'blog_content_id' => 1,
+ 'title' => 'カテゴリ1',
+ 'name' => 'public-category',
+ 'status' => 1
+ ])->persist();
+
+ BlogCategoryFactory::make([
+ 'id' => 2,
+ 'blog_content_id' => 1,
+ 'title' => 'カテゴリ2',
+ 'name' => 'private-category',
+ 'status' => 1
+ ])->persist();
+
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ title: 'カテゴリ'
+ );
+ $categories = array_values(array_filter($result, function($key) {
+ return $key !== 'pagination';
+ }, ARRAY_FILTER_USE_KEY));
+ $this->assertCount(2, $categories);
+
+ $result = $this->BlogCategoriesTool->getBlogCategories(
+ title: '1'
+ );
+ $categories = array_values(array_filter($result, function($key) {
+ return $key !== 'pagination';
+ }, ARRAY_FILTER_USE_KEY));
+ $this->assertCount(1, $categories);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php
new file mode 100644
index 0000000000..caab5ce5ac
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php
@@ -0,0 +1,335 @@
+
+ * Copyright (c) NPO baserCMS Users Community
+ *
+ * @copyright Copyright (c) NPO baserCMS Users Community
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.0
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcBlog;
+
+use BaserCore\Test\Scenario\InitAppScenario;
+use BaserCore\Test\Scenario\SmallSetContentsScenario;
+use BaserCore\Utility\BcUtil;
+use BcBlog\Test\Scenario\BlogContentScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcMcp\Mcp\BcBlog\BlogContentsTool;
+use BaserCore\TestSuite\BcTestCase;
+use Cake\ORM\TableRegistry;
+
+/**
+ * BlogContentsToolTest
+ */
+class BlogContentsToolTest extends BcTestCase
+{
+ use ScenarioAwareTrait;
+
+ /**
+ * @var BlogContentsTool
+ */
+ public $BlogContentsTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->BlogContentsTool = new BlogContentsTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->BlogContentsTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test instantiation
+ */
+ public function testInstantiation()
+ {
+ $this->assertInstanceOf(BlogContentsTool::class, $this->BlogContentsTool);
+ $this->assertTrue(method_exists($this->BlogContentsTool, 'addBlogContent'));
+ $this->assertTrue(method_exists($this->BlogContentsTool, 'getBlogContents'));
+ }
+
+ /**
+ * test addBlogContent
+ */
+ public function testAddBlogContent()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ $this->loadFixtureScenario(SmallSetContentsScenario::class);
+ $result = $this->BlogContentsTool->addBlogContent(
+ 'test-blog',
+ 'テストブログ',
+ 1, // siteId
+ 1, // parentId
+ 'テストブログの説明' // description
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ }
+
+ /**
+ * test getBlogContents
+ */
+ public function testGetBlogContents()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+ $result = $this->BlogContentsTool->getBlogContents();
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('data', $result);
+ $this->assertCount(1, $result['data']);
+ }
+
+ /**
+ * test getBlogContent
+ */
+ public function testGetBlogContent()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+ $result = $this->BlogContentsTool->getBlogContent(1);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ }
+
+ /**
+ * test editBlogContent
+ */
+ public function testEditBlogContent()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+ // BlogContentScenario は parentId に null を渡しても `?? 1` で parent_id=1 となり、
+ // id=1 と一致して「自分自身を親にできない」(TreeBehavior, CakePHP 5.2) になるため、
+ // 正当なルート(parent_id=null)へ補正してツリーを再構築する。
+ $contentsTable = TableRegistry::getTableLocator()->get('BaserCore.Contents');
+ $contentsTable->updateAll(['parent_id' => null], ['id' => 1]);
+ $contentsTable->recover();
+
+ $result = $this->BlogContentsTool->editBlogContent(
+ 1,
+ 'updated-blog',
+ '更新されたブログ',
+ 1,
+ null,
+ '更新されたブログの説明'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ }
+
+ /**
+ * test deleteBlogContent
+ */
+ public function testDeleteBlogContent()
+ {
+ // テストではID=1のブログコンテンツが存在することを前提とする
+ $result = $this->BlogContentsTool->deleteBlogContent(1);
+
+ $this->assertIsArray($result);
+ // 削除結果のチェック(成功またはエラーのいずれか)
+ if (isset($result['message'])) {
+ // 成功の場合
+ $this->assertEquals('ブログコンテンツを削除しました', $result['message']);
+ } else {
+ // エラーの場合
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getBlogContentsWithSearch
+ */
+ public function testGetBlogContentsWithSearch()
+ {
+ $result = $this->BlogContentsTool->getBlogContents('test');
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('data', $result);
+ }
+
+ /**
+ * test getBlogContentWithInvalidId
+ */
+ public function testGetBlogContentWithInvalidId()
+ {
+ $result = $this->BlogContentsTool->getBlogContent(99999);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertIsString($result['content']); // エラーメッセージ
+ }
+
+ /**
+ * test editBlogContentWithInvalidId
+ */
+ public function testEditBlogContentWithInvalidId()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+ $result = $this->BlogContentsTool->editBlogContent(
+ 99999,
+ 'test-blog',
+ 'テストブログ',
+ 1,
+ null,
+ 'テストブログの説明'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertIsString($result['content']); // エラーメッセージ
+ }
+
+ /**
+ * test addBlogContentWithEyeCatchSize
+ */
+ public function testAddBlogContentWithEyeCatchSize()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ $this->loadFixtureScenario(SmallSetContentsScenario::class);
+
+ $result = $this->BlogContentsTool->addBlogContent(
+ name: 'eyecatch-test-blog',
+ title: 'アイキャッチテストブログ',
+ description: 'アイキャッチサイズのテスト',
+ eyeCatchSizeThumbWidth: 300,
+ eyeCatchSizeThumbHeight: 200,
+ eyeCatchSizeMobileThumbWidth: 150,
+ eyeCatchSizeMobileThumbHeight: 100
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+
+ // アイキャッチサイズの設定を確認
+ $blogContent = $result;
+ $this->assertArrayHasKey('eye_catch_size', $blogContent);
+
+ // eye_catch_sizeがbase64エンコードされたシリアライズ形式の場合は、デコードしてアンシリアライズする
+ $eyeCatchSize = $blogContent['eye_catch_size'];
+ if (is_string($eyeCatchSize)) {
+ // base64デコードしてからアンシリアライズ
+ $eyeCatchSize = BcUtil::unserialize($eyeCatchSize);
+ }
+
+ // 実際のキー名で確認(thumb_width等)
+ $this->assertEquals(300, $eyeCatchSize['thumb_width']);
+ $this->assertEquals(200, $eyeCatchSize['thumb_height']);
+ $this->assertEquals(150, $eyeCatchSize['mobile_thumb_width']);
+ $this->assertEquals(100, $eyeCatchSize['mobile_thumb_height']);
+ }
+
+ /**
+ * test editBlogContentWithEyeCatchSize
+ */
+ public function testEditBlogContentWithEyeCatchSize()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+
+ $result = $this->BlogContentsTool->editBlogContent(
+ id: 1,
+ eyeCatchSizeThumbWidth: 400,
+ eyeCatchSizeThumbHeight: 300,
+ eyeCatchSizeMobileThumbWidth: 200,
+ eyeCatchSizeMobileThumbHeight: 150
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+
+ // アイキャッチサイズの更新を確認
+ $blogContent = $result;
+ $this->assertArrayHasKey('eye_catch_size', $blogContent);
+
+ // eye_catch_sizeが文字列の場合は、アンシリアライズする
+ $eyeCatchSize = $blogContent['eye_catch_size'];
+ if (is_string($eyeCatchSize)) {
+ $eyeCatchSize = BcUtil::unserialize($eyeCatchSize);
+ }
+
+ $this->assertEquals(400, $eyeCatchSize['thumb_width']);
+ $this->assertEquals(300, $eyeCatchSize['thumb_height']);
+ $this->assertEquals(200, $eyeCatchSize['mobile_thumb_width']);
+ $this->assertEquals(150, $eyeCatchSize['mobile_thumb_height']);
+ }
+
+ /**
+ * test addBlogContentWithDefaultEyeCatchSize
+ */
+ public function testAddBlogContentWithDefaultEyeCatchSize()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ $this->loadFixtureScenario(SmallSetContentsScenario::class);
+
+ // アイキャッチサイズを指定せずにブログコンテンツを作成
+ $result = $this->BlogContentsTool->addBlogContent(
+ 'default-eyecatch-blog',
+ 'デフォルトアイキャッチブログ',
+ 1, // siteId
+ 1, // parentId
+ 'デフォルトアイキャッチサイズのテスト' // description
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+
+ // デフォルトのアイキャッチサイズが設定されることを確認
+ $blogContent = $result;
+ $this->assertArrayHasKey('eye_catch_size', $blogContent);
+
+ // eye_catch_sizeが文字列の場合は、アンシリアライズする
+ $eyeCatchSize = $blogContent['eye_catch_size'];
+ if (is_string($eyeCatchSize)) {
+ $eyeCatchSize = BcUtil::unserialize($eyeCatchSize);
+ }
+
+ $this->assertArrayHasKey('thumb_width', $eyeCatchSize);
+ $this->assertArrayHasKey('thumb_height', $eyeCatchSize);
+ $this->assertArrayHasKey('mobile_thumb_width', $eyeCatchSize);
+ $this->assertArrayHasKey('mobile_thumb_height', $eyeCatchSize);
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php
new file mode 100644
index 0000000000..1f56a8eb40
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php
@@ -0,0 +1,757 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcBlog;
+
+use BaserCore\Test\Factory\ContentFactory;
+use BaserCore\Test\Scenario\InitAppScenario;
+use BaserCore\TestSuite\BcTestCase;
+use BaserCore\Utility\BcFolder;
+use BcBlog\Test\Factory\BlogCategoryFactory;
+use BcBlog\Test\Factory\BlogContentFactory;
+use BcBlog\Test\Factory\BlogPostFactory;
+use BcBlog\Test\Scenario\BlogContentScenario;
+use BcBlog\Test\Scenario\BlogPostsAdminServiceScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcMcp\Mcp\BcBlog\BlogPostsTool;
+
+/**
+ * BlogPostsToolTest
+ */
+class BlogPostsToolTest extends BcTestCase
+{
+ use ScenarioAwareTrait;
+
+ /**
+ * Test subject
+ *
+ * @var \BcMcp\Mcp\BcBlog\BlogPostsTool
+ */
+ protected $BlogPostsTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->BlogPostsTool = new BlogPostsTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->BlogPostsTool);
+ parent::tearDown();
+ }
+
+ /**
+ * test BlogPostsTool instantiation
+ */
+ public function testInstantiation()
+ {
+ $this->assertInstanceOf(BlogPostsTool::class, $this->BlogPostsTool);
+ }
+
+ /**
+ * test addBlogPost
+ */
+ public function testAddBlogPost()
+ {
+ // テストデータが無い環境でも、メソッドが存在することを確認
+ $this->assertTrue(method_exists($this->BlogPostsTool, 'addBlogPost'));
+
+ // エラーの場合でも結果が配列で返されることを確認
+ $result = $this->BlogPostsTool->addBlogPost(
+ 'テストブログ記事',
+ 'これはテスト用のブログ記事です。',
+ 'news',
+ null,
+ 'test@example.com'
+ );
+
+ $this->assertIsArray($result);
+ // ブログ記事が追加されたかどうかの確認
+ // エラーが発生した場合はcontentキーにエラーメッセージが含まれる
+ if (isset($result['content']) && is_string($result['content'])) {
+ // エラーケース
+ $this->assertIsString($result['content']);
+ } else {
+ // 成功ケース
+ $this->assertArrayHasKey('id', $result);
+ }
+ }
+
+ /**
+ * test getBlogPosts
+ */
+ public function testGetBlogPosts()
+ {
+ BlogPostFactory::make([
+ 'id' => 1,
+ ])->persist();
+ $result = $this->BlogPostsTool->getBlogPosts(1);
+
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertArrayHasKey('data', $result);
+ $this->assertIsArray($result['data']);
+ }
+
+ /**
+ * test getBlogPosts with keyword search
+ */
+ public function testGetBlogPostsWithKeyword()
+ {
+ // テスト用のブログ記事を作成
+ BlogPostFactory::make([
+ 'id' => 1,
+ 'title' => 'テストブログ記事',
+ 'detail' => 'これはテスト用の詳細です。',
+ 'content' => 'テスト用の概要内容です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-01 00:00:00'
+ ])->persist();
+
+ BlogPostFactory::make([
+ 'id' => 2,
+ 'title' => '別の記事',
+ 'detail' => '別の内容です。',
+ 'content' => '別の概要です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-02 00:00:00'
+ ])->persist();
+
+ BlogPostFactory::make([
+ 'id' => 3,
+ 'title' => 'サンプル記事',
+ 'detail' => 'テストという単語が含まれる詳細です。',
+ 'content' => 'サンプル概要です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-03 00:00:00'
+ ])->persist();
+
+ // キーワード検索のテスト("テスト"で検索)
+ $result = $this->BlogPostsTool->getBlogPosts(1, 'テスト');
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // キーワードに一致する記事が取得されることを確認
+ // "テスト"という単語がタイトルまたは詳細に含まれる記事が検索される
+ $this->assertGreaterThan(0, count($result['data']));
+
+ // 検索結果の構造を確認
+ if (count($result['data']) > 0) {
+ $firstPost = $result['data'][0];
+ $this->assertArrayHasKey('id', $firstPost);
+ $this->assertArrayHasKey('title', $firstPost);
+ $this->assertArrayHasKey('detail', $firstPost);
+ }
+ }
+
+ /**
+ * test getBlogPosts with keyword search no results
+ */
+ public function testGetBlogPostsWithKeywordNoResults()
+ {
+ // テスト用のブログ記事を作成
+ BlogPostFactory::make([
+ 'id' => 1,
+ 'title' => 'サンプル記事',
+ 'detail' => 'サンプルの詳細です。',
+ 'content' => 'サンプル概要です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-01 00:00:00'
+ ])->persist();
+
+ // 存在しないキーワードで検索
+ $result = $this->BlogPostsTool->getBlogPosts(1, '存在しないキーワード');
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // 検索結果が0件であることを確認
+ $this->assertEquals(0, count($result['data']));
+ }
+
+ /**
+ * test getBlogPosts with empty keyword
+ */
+ public function testGetBlogPostsWithEmptyKeyword()
+ {
+ // テスト用のブログ記事を作成
+ BlogPostFactory::make([
+ 'id' => 1,
+ 'title' => 'テスト記事',
+ 'detail' => 'テストの詳細です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-01 00:00:00'
+ ])->persist();
+
+ // 空のキーワードで検索(すべての記事が取得される)
+ $result = $this->BlogPostsTool->getBlogPosts(1, '');
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertArrayHasKey('data', $result);
+
+ // 記事が取得されることを確認
+ $this->assertGreaterThan(0, count($result['data']));
+ }
+
+ /**
+ * test getBlogPosts with limit parameter
+ */
+ public function testGetBlogPostsWithLimit()
+ {
+ // 5つのテスト記事を作成
+ for($i = 1; $i <= 5; $i++) {
+ BlogPostFactory::make([
+ 'id' => $i,
+ 'title' => "テスト記事 {$i}",
+ 'detail' => "テスト記事 {$i} の詳細です。",
+ 'content' => "テスト記事 {$i} の概要です。",
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => "2023-01-0{$i} 00:00:00"
+ ])->persist();
+ }
+
+ // limit = 3 でテスト
+ $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 1);
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // limitが正しく適用されていることを確認
+ $this->assertLessThanOrEqual(3, count($result['data']));
+ $this->assertEquals(3, $result['pagination']['limit']);
+ }
+
+ /**
+ * test getBlogPosts with page parameter
+ */
+ public function testGetBlogPostsWithPage()
+ {
+ // 10個のテスト記事を作成
+ for($i = 1; $i <= 10; $i++) {
+ BlogPostFactory::make([
+ 'id' => $i,
+ 'title' => "ページテスト記事 {$i}",
+ 'detail' => "ページテスト記事 {$i} の詳細です。",
+ 'content' => "ページテスト記事 {$i} の概要です。",
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => sprintf("2023-01-%02d 00:00:00", $i)
+ ])->persist();
+ }
+
+ // 1ページ目(limit=3)
+ $result1 = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 1);
+
+ $this->assertArrayHasKey('data', $result1);
+ $this->assertArrayHasKey('pagination', $result1);
+ $this->assertArrayHasKey('data', $result1);
+ $this->assertArrayHasKey('pagination', $result1);
+
+ $this->assertEquals(1, $result1['pagination']['page']);
+ $this->assertEquals(3, $result1['pagination']['limit']);
+ $this->assertLessThanOrEqual(3, count($result1['data']));
+
+ // 2ページ目(limit=3)
+ $result2 = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 2);
+
+ $this->assertArrayHasKey('data', $result2);
+ $this->assertArrayHasKey('pagination', $result2);
+ $this->assertArrayHasKey('data', $result2);
+ $this->assertArrayHasKey('pagination', $result2);
+
+ $this->assertEquals(2, $result2['pagination']['page']);
+ $this->assertEquals(3, $result2['pagination']['limit']);
+ $this->assertLessThanOrEqual(3, count($result2['data']));
+
+ // 1ページ目と2ページ目で異なる記事が取得されることを確認
+ if (count($result1['data']) > 0 && count($result2['data']) > 0) {
+ $firstPageIds = array_column($result1['data'], 'id');
+ $secondPageIds = array_column($result2['data'], 'id');
+
+ // 1ページ目と2ページ目のIDに重複がないことを確認
+ $intersection = array_intersect($firstPageIds, $secondPageIds);
+ $this->assertEmpty($intersection, '1ページ目と2ページ目で同じ記事が重複して取得されています');
+ }
+ }
+
+ /**
+ * test getBlogPosts with limit and page combination
+ */
+ public function testGetBlogPostsWithLimitAndPage()
+ {
+ // 8個のテスト記事を作成
+ for($i = 1; $i <= 8; $i++) {
+ BlogPostFactory::make([
+ 'id' => $i,
+ 'title' => "組み合わせテスト記事 {$i}",
+ 'detail' => "組み合わせテスト記事 {$i} の詳細です。",
+ 'content' => "組み合わせテスト記事 {$i} の概要です。",
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => sprintf("2023-01-%02d 00:00:00", $i)
+ ])->persist();
+ }
+
+ // limit=2, page=3 のテスト(5〜6番目の記事が取得される想定)
+ $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 2, 3);
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+
+ // パラメータが正しく設定されていることを確認
+ $this->assertEquals(3, $result['pagination']['page']);
+ $this->assertEquals(2, $result['pagination']['limit']);
+ $this->assertLessThanOrEqual(2, count($result['data']));
+ }
+
+ /**
+ * test getBlogPosts with invalid page number
+ */
+ public function testGetBlogPostsWithInvalidPageNumber()
+ {
+ // 2個のテスト記事を作成
+ BlogPostFactory::make([
+ 'id' => 1,
+ 'title' => '無効ページテスト記事1',
+ 'detail' => '無効ページテスト記事1の詳細です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-01 00:00:00'
+ ])->persist();
+
+ BlogPostFactory::make([
+ 'id' => 2,
+ 'title' => '無効ページテスト記事2',
+ 'detail' => '無効ページテスト記事2の詳細です。',
+ 'blog_content_id' => 1,
+ 'status' => 1,
+ 'posted' => '2023-01-02 00:00:00'
+ ])->persist();
+
+ // 存在しないページ番号(page=10)でテスト
+ $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 10, 10);
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertArrayHasKey('data', $result);
+
+ // 存在しないページの場合、データが空であることを確認
+ $this->assertEquals(0, count($result['data']));
+ }
+
+ /**
+ * test getBlogPost
+ */
+ public function testGetBlogPost()
+ {
+ $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class);
+ $result = $this->BlogPostsTool->getBlogPost(1);
+
+ $this->assertArrayHasKey('id', $result);
+ $this->assertEquals(1, $result['id']);
+ }
+
+ /**
+ * test editBlogPost
+ */
+ public function testEditBlogPost()
+ {
+ $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class);
+ $result = $this->BlogPostsTool->editBlogPost(
+ 1,
+ '更新されたタイトル',
+ '更新された詳細',
+ null,
+ null,
+ null,
+ null
+ );
+
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('更新されたタイトル', $result['title']);
+ $this->assertEquals('更新された詳細', $result['detail']);
+ }
+
+ /**
+ * test deleteBlogPost
+ */
+ public function testDeleteBlogPost()
+ {
+ $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class);
+ $result = $this->BlogPostsTool->deleteBlogPost(1);
+
+ $this->assertArrayHasKey('message', $result);
+ }
+
+ /**
+ * test getBlogCategoryId
+ */
+ public function testGetBlogCategoryId()
+ {
+ BlogCategoryFactory::make([
+ 'name' => 'プログラム',
+ 'blog_content_id' => 1,
+ ])->persist();
+ $categoryId = $this->execPrivateMethod($this->BlogPostsTool, 'getBlogCategoryId', ['プログラム', 1]);
+
+ $this->assertIsInt($categoryId);
+ $this->assertGreaterThan(0, $categoryId);
+ }
+
+ /**
+ * test getBlogContentId
+ */
+ public function testGetBlogContentId()
+ {
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1,
+ null,
+ 'test-blog',
+ 'test-blog-url',
+ );
+ $contentId = $this->execPrivateMethod($this->BlogPostsTool, 'getBlogContentId', ['test-blog']);
+
+ $this->assertIsInt($contentId);
+ $this->assertGreaterThan(0, $contentId);
+ }
+
+ /**
+ * test processFileUpload with base64 data
+ */
+ public function testProcessFileUploadWithBase64()
+ {
+ // 小さなPNG画像のbase64データ(2x2ピクセルの赤いPNG)
+ $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVQIHWP8//8/AzYwOjr6PxQAAP//DyGg5r8AAAAASUVORK5CYII=';
+
+ $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$base64Data]);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('name', $result);
+ $this->assertArrayHasKey('type', $result);
+ $this->assertArrayHasKey('tmp_name', $result);
+ $this->assertArrayHasKey('error', $result);
+ $this->assertArrayHasKey('size', $result);
+ $this->assertArrayHasKey('ext', $result);
+
+ $this->assertEquals('image/png', $result['type']);
+ $this->assertEquals('png', $result['ext']);
+ $this->assertEquals(UPLOAD_ERR_OK, $result['error']);
+
+ // 一時ファイルがちゃんと作成されているかチェック
+ $this->assertTrue(file_exists($result['tmp_name']));
+
+ // クリーンアップ
+ if (file_exists($result['tmp_name'])) {
+ unlink($result['tmp_name']);
+ }
+ }
+
+ /**
+ * test processFileUpload with URL
+ */
+ public function testProcessFileUploadWithUrl()
+ {
+ $url = 'https://example.com/image.jpg';
+
+ $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$url]);
+
+ // URLの場合はダウンロードに失敗してfalseが返される(example.comは存在しない画像)
+ $this->assertFalse($result);
+ }
+
+ /**
+ * test processFileUpload with invalid base64 data
+ */
+ public function testProcessFileUploadWithInvalidBase64()
+ {
+ // より確実に無効になるbase64データ
+ $invalidBase64 = 'invalid_format_data';
+ $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$invalidBase64]);
+ // 無効なフォーマットの場合はfalseが返される
+ $this->assertFalse($result);
+ }
+
+ /**
+ * test processUrlFile with invalid URL
+ */
+ public function testProcessUrlFileWithInvalidUrl()
+ {
+ $invalidUrl = 'not_a_url';
+
+ try {
+ $this->execPrivateMethod($this->BlogPostsTool, 'processUrlFile', [$invalidUrl]);
+ $this->fail('例外が投げられるべきです');
+ } catch (\Exception $e) {
+ $this->assertStringContainsString('不正なURL形式です', $e->getMessage());
+ }
+ }
+
+ /**
+ * test processUrlFile with non-HTTP URL
+ */
+ public function testProcessUrlFileWithNonHttpUrl()
+ {
+ $ftpUrl = 'ftp://example.com/file.jpg';
+
+ try {
+ $this->execPrivateMethod($this->BlogPostsTool, 'processUrlFile', [$ftpUrl]);
+ $this->fail('例外が投げられるべきです');
+ } catch (\Exception $e) {
+ $this->assertStringContainsString('HTTPまたはHTTPSのURLのみサポートされています', $e->getMessage());
+ }
+ }
+
+ /**
+ * test processBase64File with invalid base64 format
+ */
+ public function testProcessBase64FileWithInvalidFormat()
+ {
+ // 正しくないdata:URLフォーマット
+ $invalidBase64 = 'data:image/png;base64,not_valid_base64!!!';
+
+ try {
+ $this->execPrivateMethod($this->BlogPostsTool, 'processBase64File', [$invalidBase64]);
+ $this->fail('例外が投げられるべきです');
+ } catch (\Exception $e) {
+ $this->assertStringContainsString('base64デコードに失敗しました', $e->getMessage());
+ }
+ }
+
+ /**
+ * test addBlogPost with base64 eyeCatch
+ */
+ public function testAddBlogPostWithBase64EyeCatch()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ ContentFactory::make([
+ 'name' => 'news',
+ 'type' => 'BlogContent',
+ 'plugin' => 'BcBlog',
+ 'site_id' => 1,
+ 'entity_id' => 1000,
+ ])->persist();
+ // BlogContentのテストデータを作成
+ BlogContentFactory::make([
+ 'id' => 1000,
+ 'description' => 'ニュースブログ',
+ 'template' => 'default',
+ 'list_count' => 10,
+ 'list_direction' => 'DESC',
+ 'feed_count' => 10,
+ 'tag_use' => false,
+ 'comment_use' => false,
+ 'comment_approve' => false,
+ 'widget_area' => null,
+ 'eye_catch_size_thumb_width' => 150,
+ 'eye_catch_size_thumb_height' => 150,
+ 'eye_catch_size_mobile_thumb_width' => 100,
+ 'eye_catch_size_mobile_thumb_height' => 100,
+ 'use_content' => true,
+ ])->persist();
+
+ // 2x2ピクセルの小さなPNG画像のbase64データ(テスト済み)
+ $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAbkAAABQCAYAAACEaAvWAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAEalJREFUeNrsnTF24zgShmG1gwkm0GbKhj5B0yeQfIK2T9BStpnlE9g+ge0TWJ1tZvsEpk9g9QlanSkb7XuT7xIEaFEySRTIAglS//+e+r2Z6REhAFVfFQgUjkTbGokw/nOo/2kj1mIpIAiCIIhBRy1ALYj/vIw/k/gTFvwtCbof8ec5ht4KwwRBEAT5DTkFt0cNNxst4s8tYAdBEAT5CbmRmMd/3tX4hk38mcWge8aQQRAEQf5AbpRkb1Omb5OgW2DYIAiCIIq+dAhwUufizxjM/4gIQwdBEAS1Bzl+wKWaxKALYtC9YPggCIKg5iHnDnCpQoAOgiAIMon/nZx7wGW1EGsxs2yfPJN3Hn++CXWEIdj7G6v4E8WfN7z/gyAIAuTaApwd6BTcrnX7hsTvlrs6H+LPffyMDaYLBEHQoUKuHcDRQDdKMrdHC7iJnOzuAtVYIAiCuiWed3LtAk6q+B2dOqMn2/dHje+XcPx3/Izf8TMAOgiCoIPJ5NoHXCq5nHi6UxnFTdv8Oqs3Eq+iuIpMFLf1DNMcgqBD1XEN5yqzmydhX6bLFeDOGgCcSLLCUfwnNqVAEAR5r0ENwL0yAm6jP3UAt2wwu5Sgm2L6QBAE9Q1yW8CFrFmY+mwq/b/NAg6ggyAI6iXkXAFOQkqBygZ0bQIOoIMgCOoV5FwCLhUddD4ADqCDIAjqBeT4Abf6BCk66HwCHEAHQRDUacjxA07C6bT0YHUx6HwEHEAHQRDUSci5AdwZqUTWZ9D5DDiADoIgqFOQaxNwn0G3cgw4zrqUAB0EQZAnOvYWcLugO9lrHzfgzvRvfWQEHQ6MQxAEeQc5nwCXD2B+wCmQLhMwAXQQBEG90QCA2znCIIE0Y+xfLF1CEAR5AblDBxxAB0EQ1DtllyvvDh5wWdDxL12u4u+NMOWsxz7UY7Js4dmB+HxzPI8OaS6oMcze5bjaKabu5lkux+6mQrv2L21+FupGk03DY3GXaYMM6K+ctWFrP/vj34Si1MaOdGMmOosD4HafPWUEnTTsEwcTibNQtmqn+sg+etOTZdOQAcrLbcf694Ql80t+XuJ2PTM/Xz73u35+4PS3rpkuLFZOfaL7bbg3FzaZ/vqZOFaXY6kceTqGYckY3lYCRbEjlc/8Jpq6EaXK2OXbKa+vrOZL5bNPGP21HI9LPSaBaE8fc+yI2VH2B3BuQMd/Fx0/5PL67VlPmpUDw5OOca4NY1ihbQ/x577WnFOG+SiavDaqDuS2MLmssPrynPQZZyap+u9at2lo44BqBiTXoo2rvmzHrtyHyMDjogHASRu7K/ivi7gNM4Zn3Ogx8UEfc2yQiQQBuPwJvRDqHR3H77oU3dNQj8OvZExGjMsOyvB+acMYVmzbtW7bvEYm9C78uBeR6jDftdOs8nrhPFm1GSWfsGZbhnr565fYLoG5/v3ymU9COA/uOHVZOh4jx79ju1RapGktu1Zj8uoR4HY00MszAJwZdFWuAtpXqKPeriqF3TmDo3rVkSWHYxwm36Uc99CiHaF2lkPve37bZ49My0CTBJb1g4N5g30QaqCed8xuTMGEazjMCXO8Dmi9DjgGDI3jA5wy5HevALcFne1VQC4mky+Z3ZNemqjjqFz0Q+q4Q9Jc6w7gXGabdzqotM0mX0WT71xGOgPtwnjttntCmreusjk1zykrSGHF778RfBsWnUGuTgN5Aef6Kh8/QNflTG43+rR3jk1kToEQpKW4O9EdwLkGypQ8ltv3S8OG++Cpc4CzC2pdZXNzYr+NK4xLIDxdoszqGICrALpRArqqznos+iPpHN9Im2mazZzSbPM0d34q45x637vN9tlUV+iZGWDz2HAfBMJu57cc70io3aQ+iGrvKpvj3RBEzeJsYJwVZdl4JdRmp/823O8RB+SuDg5wu6CLhP/vBtIt5DayPdMiN6MsCf1tG4mv9Oct4ywCi4wm0A75ooZxyjH+3eL4Vcleor0+sMkAi0G3tVPbQHiTGcO8NprnF60P5Hfeenj+0AYe15Z9w5XFpWNsC9lvhv++EC7P4TWQyT0lGU0dmHQRcKrdjzUAt2pwfCV8zir8vlBst6gPSXNBlJwBVJsbqMYuDeOhcAxV2y6Jmdh5srz2OdM0G+eatepNlTl2w9Jn23Nk1B2sss/yjotQYZMe61jUPnKilkYnhOddeHm43v49G182Z5fFZYEcWf794qCjbRvSGojq75cUoEaVX1i6KCN20hDgpjW+4bfwXesEjjcaXPekrKloI4p5+3J2/E4TwzBdqKuM55SYpd7l7LgMS4OQ9gEXEB2Uuc8UaDYWEf3FJzgpZ00J6iLdnhumM5XXRJuPPLWk0MFvdpPFKX1l/O0/fBmEgRC1oFANdL7XyXQHOFGzv5uG3Sb+XAlaLc/Lgu37FGNbWGfg201AS8Icneb8O5+N85qtz+yKGcwKgEFxvItk1YCrYMDIWDGj2Woh1VTl/Xv9nZbVsjhTZmarlS+DICH3UvM77EB32IATQnSwZiGtaPVnmNCMLdKZSJW7BjdE0HXnEP62mkmZnkl9Zg+4Rc53UIpFuFje/U5o78bz0SzqN1O762ZzVbI4ZcMjNr/8zZdBGAjBUv+PBjoA7rkDhlkGOtPS5b5jMpV5Uu9T6mab5u8ILA6wf225p019tiJl1hyAo8FGtufKUT8U6baVgt12PqNsA9eDIdOpns3RAssZUzZXNgZTRmDWhJxaXlg4Bx0Al07uLuvWEIXuV3QZG7+PY/zUHL41/K0x0Tjdl1mqFwHfNpLB0WDjJqMy9/99B2xlUrp6YZ6vVbM5Uxa3MCQ2NkHe0sCD96TsW7v29HGfnMl5cYDO36t8mgHcc+evV1l/7JyjGvfEkMUtGFt3b9Eu0zg8JTtCR60cPp6UZk2mM4nKofAAznzVkKvro8IerIZ8LfVfqt95szlaFpcGSSuGTI7y/nqumfA/5s/fuozf3FQqcZCJhLmWHIpAdyV4Nl10EXAbIfzYTsugBcm4Rx93ejXjrNYftyVQHOcLYQ7LoOxvJmN8Il2cq4x1WHklYFsZhCODE8J8xs7VJp2yPnjpiJ1MSgKDTSa54MzmzFncdmPQsnDMqfV1VYDTVuCeXiulCoSr4vFBWSaXvnNZMDZgF3T0TQJ9BNxZZ9/FfZ7YK0MEGhKicak3B637SYBIapyrBo3xXKhD878M0XlAmP9lgKNWR7kiXvlkiupdObi/SrNH31WeAS/3fC5PNkfN4mi2YpPNcd3QUlfSX7/nBZODPQc2cwC6CRPougy47hwboGnlyXfYOt1gzzibVqBt4q5CBlN8m7gd4GQ0z/NOy93ye9DCM5vI4vLgwpXN2WRxJlsZW8yBleApXM/FnMf9OqyDnEa7AN20JugAuP5Bru2MVBp5WxsY5gUFkcMKc9YWcDMBudaYHIhxZHP2WZww+N/Q0paoZ1aby+oyzBkUNJoTdELTtSroADjIFeiumOe5rSHOa85ZAM5PhQZ/ZgKQbTZnm8UJw+aT0HrTlapEdKpXSHwIgj/e0R2XNHqWFGvlc/yPuvjr4qOTt9X8QwDOSVYVOHpmIPoiNc83osnLP7POa5Q4oE0myr8ujdjTv9tvwL2JoiW/bB/4KAWHsNBW89oufeIoGfegNJvLW6qtlsVl/WvxM6ucoU73dmzr334V/LdoBEQfJPt0dkxwAG2BDoCj6XfpZHDjFCjLGaZobiJ8qf4iM7pRsmvvWjR7qW1a15O6s1k5nsPO4ELhd9WgiWUWlwXRo8FhRyxZ3G4wUXQOcizqFApRPtCdH9xel1VWRF6ultweExorQfdb8BUOLQLdU2aCAHA8mZzQk3jB2Gemq3je9LiudIBUJHno+cajjC5KnIgynomOFOtGoUNCQDDNQM40d8b6iifqjQBdBdyyE8FRMRyK9LM0A7LN5uplcaZ+9vq2bw3um7gP7kX5auDkmPiF8stWgu/CxM+gk4BQ0AkBODaHIPVd8L53+m7RnqgkslXVUdaebWLhqwCUzrOJKL8XbvjhvFTAtypxdFPdnxQH1OUMbtmZ4MgukzPB2TabM2Vxt6X2tU6Cuiq/wyd7TZOk9wK7GQ8svkwaPqfRPH4606CM0i3gRolT6c8mk/XHxZSiNALkXSIQREM2Hdy9E33XmlTCKSQ6QkpmqCCxJta3HHl4Q3r5Wcyw7TJRFmNpF5Da7LQ0Z3HSJ9yT5kp5gNYN0BUXJggGll/WBOjcAk6ltv0A3FamtXMumNwZIsf9Kiamdp1bFE7uskyZ4dAiMKA4rTOCLUx11vDoJejK546fwdHIWJKN4ttMAdElMYt7ID7PtDTcFRUGhwPrr2oCdG4B161byGky1ZMMC85l2fTfXJiL9b7kROQRYfxD0WfZBG7rxLlXDfRo77JVfz82boN2+mGYzzcejvSkcha3618jQ2AYMmVxUmWVT8YdsrIhH+S6CLp+Ay5dsjTBZFoZdApwpui5qHjwLWFyvnZmaaRa/wXMQUsdwE21LbQTbPLN6WsPwVxt04m9zZh21j5YBFbd3Xyyq2+8kOsS6PoOOLphpKB7t6iHFyTFhWnLQ7cF8yQiADgF3V1Llf9dy9R/+3Pp3jKbMwNOvYu+E+U7M33L6G4JPsOnpcsyu4rI32K2GdMdjfeWzyp+ThdWWVQbi+btclDry30HHT/gVsLXSib0MlWhBspr8j4sDyrq30tn+EsI0juzyFDwl1rEdS62FcXPK2RA/hmfChJMfbjcG8sNMWjJOr1pbn+NPpb2fgnagXd/lo9pc3pOKHzdxFhPrMaYJ2itm8VR2jbx3MbOC1YmPjLoI6YHTQXf8QLlFNc1t3F39ZLW9n5zpP8/20xK9sep8SgA/xyporNPkasCwHWLbUrLIeX1GQWQRWM5rDgPtkcPTH2zFkeezWk5B+U7zbdkXjZZzLl8SV8u5Z9U+M5XS8hIWzyx9lHlu83lZrILz/zcRM+H74R58a9jloeqg4xCuDpHB8BRx0GeGZkJejUMjohtRjrrpubIWAgvd/K1qQdDBhxUmMdVx9K/s3V2czrQ2epc+wFXbcqD+5gxi8tmcxOruVTNR/00rPyUwebVY9tKSuYNGAd+IXxYujxUwG3HId1C3kR7Z3o3ILVt3IW/u67yZV6eOxi7C7h25nRVTSpCpOx3R4L+Ls/uXRwdwkFHXxvI/kgqCQ2YJ2O7oDt0wDXrFKotKStHei+gDyM0ZjLuQXfvfXUUn0FnLnUX1fh26ru5h8p+yrysO+mgfc3S/hg4mIztgA6Ay3MKJ4K/zt9KqHdwixptk879wvPI3DXg6BuYtqC7d9COmR6PQ57TLrM4U6bEkc1tGOZGWRvHHbOvnRWmgaPJ2CzoALhi57hOnCPXHU+3GnBLhrY9a4d1aFndSlTZoavG8krDjmMsF7WDlX7MaQ59LR3v+n7ElM09MDyjD+fllnlzeuBwMroA3Q0AV3Es1O6uWYWocqWN7CQp1M3ZL1vHfaKfsRL91YYlSFCFnNOxjCq0YaHHcuZdcezm5nSTmRxHQCjH+bnEPjkCxfLNJ36fX13p7C3Xto6dT0TeXZeyysFfmS3OAJx94LHQL5LTCw2DAmf4U6jtw8sG2iUn6Y1QV2cEgu+qm7zflWcgkaNflvbj0mqDjt1YDjNjGZa0Iaqwpd5l37joh4nuAxcXdeZLPXdVEqC9MD0pTRjO98bngslfRYaxDnKAvWlxfqwy87rURx01NBGmgveMlJzYVwAcBEEHpVHmVuy15wGIJzpqcHC4QbdhjNYAOAiCoB7qS2NP+icGyZ/JDeNcV6v8AcBBEARBfmRy24xO1U4UXrzIBOAgCIIAuV6CDoCDIAgC5HoJOgAOgiAIkOsl6AA4CIIgQK6XoAPgIAiCALlegg6AgyAIAuR6CToADoIg6AA18KYl7q7SeAbgIAiCkMn1MaNbeH9PFgRBEHQAmdxuRncq6hf+vAXgIAiCDltfvGzVP2ITf37oMmCmW3f3JeEoK3P/B8MLQRB02DrqRCtHSb3Lbxp4YQHY5JUWz52+IwuCIAhi1f8FGACAMsToDJhC1gAAAABJRU5ErkJggg==';
+
+ $result = $this->BlogPostsTool->addBlogPost(
+ 'テストブログ記事(アイキャッチ付き)',
+ 'これはアイキャッチ画像付きのテスト記事です。',
+ 'news', // blogContent
+ null, // name
+ 'これは概要です。', // content
+ null, // category
+ null, // email
+ 0, // status
+ '2025/01/01 00:00:00', // posted
+ null, // publishBegin
+ null, // publishEnd
+ $base64Data, // eyeCatch,
+ 1
+ );
+
+ $this->assertIsArray($result);
+
+ // エラーが発生しないことを明確にテスト
+
+ // 成功時のレスポンス内容をテスト
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('テストブログ記事(アイキャッチ付き)', $result['title']);
+ $filePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $result['eye_catch'];
+ $this->assertFileExists($filePath);
+ (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1000');
+ }
+
+ public function testAddBlogPostWithUrlEyeCatch()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ ContentFactory::make([
+ 'name' => 'news',
+ 'type' => 'BlogContent',
+ 'plugin' => 'BcBlog',
+ 'site_id' => 1,
+ 'entity_id' => 1000,
+ ])->persist();
+ // BlogContentのテストデータを作成
+ BlogContentFactory::make([
+ 'id' => 1000,
+ 'description' => 'ニュースブログ',
+ 'template' => 'default',
+ 'list_count' => 10,
+ 'list_direction' => 'DESC',
+ 'feed_count' => 10,
+ 'tag_use' => false,
+ 'comment_use' => false,
+ 'comment_approve' => false,
+ 'widget_area' => null,
+ 'eye_catch_size_thumb_width' => 150,
+ 'eye_catch_size_thumb_height' => 150,
+ 'eye_catch_size_mobile_thumb_width' => 100,
+ 'eye_catch_size_mobile_thumb_height' => 100,
+ 'use_content' => true,
+ ])->persist();
+
+ $result = $this->BlogPostsTool->addBlogPost(
+ 'テストブログ記事(アイキャッチ付き)',
+ 'これはアイキャッチ画像付きのテスト記事です。',
+ 'news', // blogContent
+ null, // name
+ 'これは概要です。', // content
+ null, // category
+ null, // email
+ 0, // status
+ null, // posted
+ null, // publishBegin
+ null, // publishEnd
+ 'https://basercms.net/img/basercms_logo.png', // eyeCatch,
+ 1
+ );
+
+ $this->assertIsArray($result);
+
+ // エラーが発生しないことを明確にテスト
+
+ // 成功時のレスポンス内容をテスト
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('テストブログ記事(アイキャッチ付き)', $result['title']);
+ $this->assertTrue(isset($result['eye_catch']));
+ $filePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $result['eye_catch'];
+ $this->assertFileExists($filePath);
+ (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1000');
+ }
+
+ /**
+ * basercms.png をチャンク分割して送信し、その画像を使ってブログ記事を追加するテスト
+ */
+ public function testAddBlogPostWithChunkedImageUpload()
+ {
+ // 初期設定とファクトリー設定
+ $this->loadFixtureScenario(InitAppScenario::class);
+ ContentFactory::make([
+ 'name' => 'news',
+ 'type' => 'BlogContent',
+ 'plugin' => 'BcBlog',
+ 'site_id' => 1,
+ 'entity_id' => 1000,
+ ])->persist();
+ BlogContentFactory::make(['id' => 1000, 'name' => 'news'])->persist();
+
+ // basercms.pngファイルを読み込み
+ $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png';
+ $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません');
+
+ $imageContent = file_get_contents($imagePath);
+ $fileId = 'test_blog_image_' . uniqid();
+ $filename = 'basercms_blog.png';
+
+ // FileUploadToolのインスタンスを作成
+ $fileUploadTool = new \BcMcp\Mcp\BaserCore\FileUploadTool();
+
+ // 画像を1024バイトずつのチャンクに分割
+ $chunkSize = 1024;
+ $chunks = str_split($imageContent, $chunkSize);
+ $totalChunks = count($chunks);
+
+ $this->assertGreaterThan(1, $totalChunks, '画像ファイルが小さすぎてチャンク分割できません');
+
+ // 各チャンクを順番に送信(最後のチャンク以外)
+ for($i = 0; $i < $totalChunks - 1; $i++) {
+ $result = $fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename);
+
+ $this->assertEquals('chunk_received', $result['status']);
+ $this->assertEquals($i + 1, $result['progress']);
+ }
+
+ // 最後のチャンクを送信
+ $lastIndex = $totalChunks - 1;
+ $result = $fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename);
+
+ $this->assertEquals('complete', $result['status']);
+
+ // アップロードされたファイルが正しく作成されていることを確認
+ $uploadedFile = TMP . 'mcp_uploads/' . $filename;
+ $this->assertTrue(file_exists($uploadedFile), 'アップロードされたファイルが見つかりません');
+ $this->assertEquals($imageContent, file_get_contents($uploadedFile), 'アップロードされたファイルの内容が一致しません');
+
+ // アップロードしたファイル名を使ってブログ記事を作成
+ $blogResult = $this->BlogPostsTool->addBlogPost(
+ 'チャンク分割画像付きブログ記事', // title
+ '
チャンク分割でアップロードした画像を使用したテスト記事です。
', // detail
+ 'news', // blogContent
+ null, // name
+ 'チャンク分割画像のテスト概要', // content
+ null, // category
+ null, // email
+ 1, // status (公開)
+ null, // posted
+ null, // publishBegin
+ null, // publishEnd
+ $filename, // eyeCatch (アップロードしたファイル名)
+ 1 // loginUserId
+ );
+
+ $this->assertNotEmpty($blogResult['eye_catch'], 'アイキャッチ画像が設定されていません');
+
+ // アップロードされた画像ファイルが正しい場所に配置されていることを確認
+ $blogImagePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $blogResult['eye_catch'];
+ $this->assertTrue(file_exists($blogImagePath), 'ブログ用のアイキャッチ画像ファイルが見つかりません');
+
+ // アイキャッチ画像がPNG形式として有効か確認
+ $imageInfo = getimagesize($blogImagePath);
+ $this->assertNotFalse($imageInfo, 'アイキャッチ画像が有効な画像ではありません');
+ $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'アイキャッチ画像がPNG形式ではありません');
+
+ // テスト後のクリーンアップ
+ if (file_exists($uploadedFile)) {
+ unlink($uploadedFile);
+ }
+ if (file_exists($blogImagePath)) {
+ (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1');
+ }
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php
new file mode 100644
index 0000000000..6a0b7e56a6
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php
@@ -0,0 +1,196 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcBlog;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcBlog\Test\Scenario\BlogTagsScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcMcp\Mcp\BcBlog\BlogTagsTool;
+
+/**
+ * BlogTagsToolTest
+ */
+class BlogTagsToolTest extends BcTestCase
+{
+
+ use ScenarioAwareTrait;
+
+ /**
+ * @var BlogTagsTool
+ */
+ public $BlogTagsTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->BlogTagsTool = new BlogTagsTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->BlogTagsTool);
+ parent::tearDown();
+ }
+
+ /**
+ * test addBlogTag
+ */
+ public function testAddBlogTag()
+ {
+
+ $result = $this->BlogTagsTool->addBlogTag('テストタグ');
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ $this->assertEquals('テストタグ', $result['name']);
+ }
+
+ /**
+ * test getBlogTags
+ */
+ public function testGetBlogTags()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->getBlogTags();
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertArrayHasKey('data', $result);
+ $this->assertIsArray($result['data']);
+ }
+
+ /**
+ * test getBlogTag
+ */
+ public function testGetBlogTag()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->getBlogTag(1);
+
+ $this->assertIsArray($result);
+ $this->assertEquals(1, $result['id']);
+ }
+
+ /**
+ * test editBlogTag
+ */
+ public function testEditBlogTag()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->editBlogTag(1, '更新されたタグ');
+
+ $this->assertIsArray($result);
+ $this->assertEquals('更新されたタグ', $result['name']);
+ }
+
+ /**
+ * test deleteBlogTag
+ */
+ public function testDeleteBlogTag()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->deleteBlogTag(1);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('message', $result);
+ $this->assertEquals('ブログタグを削除しました', $result['message']);
+ }
+
+ /**
+ * test getBlogTags with search parameters
+ */
+ public function testGetBlogTagsWithSearch()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->getBlogTags(
+ name: 'tag1'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertEquals(1, $result['pagination']['page']);
+ $this->assertEquals(10, $result['pagination']['limit']);
+ }
+
+ /**
+ * test getBlogTags with limit parameter
+ */
+ public function testGetBlogTagsWithLimit()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->getBlogTags(null, 2, 1);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertEquals(1, $result['pagination']['page']);
+ $this->assertEquals(2, $result['pagination']['limit']);
+ $this->assertArrayHasKey('data', $result);
+ $this->assertLessThanOrEqual(2, count($result['data']));
+ }
+
+ /**
+ * test getBlogTags with page parameter
+ */
+ public function testGetBlogTagsWithPage()
+ {
+ $this->loadFixtureScenario(BlogTagsScenario::class);
+ $result = $this->BlogTagsTool->getBlogTags(null, 2, 2);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertEquals(2, $result['pagination']['page']);
+ $this->assertEquals(2, $result['pagination']['limit']);
+ }
+
+ /**
+ * test getBlogTag with invalid ID
+ */
+ public function testGetBlogTagWithInvalidId()
+ {
+ $result = $this->BlogTagsTool->getBlogTag(999);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `blog_tags`.', $result['content']);
+ }
+
+ /**
+ * test editBlogTag with invalid ID
+ */
+ public function testEditBlogTagWithInvalidId()
+ {
+ $result = $this->BlogTagsTool->editBlogTag(999, 'Test Tag');
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `blog_tags`.', $result['content']);
+ }
+
+ /**
+ * test deleteBlogTag with invalid ID
+ */
+ public function testDeleteBlogTagWithInvalidId()
+ {
+ $result = $this->BlogTagsTool->deleteBlogTag(999);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `blog_tags`.', $result['content']);
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php
new file mode 100644
index 0000000000..832463632e
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php
@@ -0,0 +1,195 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcCustomContent;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcMcp\Mcp\BcCustomContent\CustomContentsTool;
+
+/**
+ * CustomContentsToolTest
+ */
+class CustomContentsToolTest extends BcTestCase
+{
+ /**
+ * @var CustomContentsTool
+ */
+ public $CustomContentsTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->CustomContentsTool = new CustomContentsTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->CustomContentsTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test instantiation
+ */
+ public function testInstantiation()
+ {
+ $this->assertInstanceOf(CustomContentsTool::class, $this->CustomContentsTool);
+ $this->assertTrue(method_exists($this->CustomContentsTool, 'addCustomContent'));
+ $this->assertTrue(method_exists($this->CustomContentsTool, 'getCustomContents'));
+ }
+
+ /**
+ * test addCustomContent
+ */
+ public function testAddCustomContent()
+ {
+ $result = $this->CustomContentsTool->addCustomContent(
+ name: 'test-content',
+ title: 'テストカスタムコンテンツ',
+ customTableId: 1,
+ description: 'テスト用のカスタムコンテンツです',
+ authorId: 1,
+ status: true,
+ listOrder: 'id',
+ );
+
+ $this->assertIsArray($result);
+ // エラーの場合はcontentキーにエラーメッセージが文字列として含まれる
+ if (isset($result['content']) && is_string($result['content'])) {
+ $this->assertIsString($result['content']);
+ } else {
+ // 成功の場合は直接データがアクセス可能
+ $this->assertArrayHasKey('id', $result);
+ }
+ }
+
+ /**
+ * test getCustomContents
+ */
+ public function testGetCustomContents()
+ {
+ $result = $this->CustomContentsTool->getCustomContents(
+ status: 'publish',
+ limit: 10,
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomContent
+ */
+ public function testGetCustomContent()
+ {
+ $result = $this->CustomContentsTool->getCustomContent(1);
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test editCustomContent
+ */
+ public function testEditCustomContent()
+ {
+ $result = $this->CustomContentsTool->editCustomContent(
+ id: 1,
+ name: 'updated-name',
+ title: '更新されたタイトル',
+ description: '更新された説明',
+ template: 'custom',
+ listCount: 20,
+ listDirection: 'ASC',
+ listOrder: 'name',
+ status: true
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test deleteCustomContent
+ */
+ public function testDeleteCustomContent()
+ {
+ $result = $this->CustomContentsTool->deleteCustomContent(1);
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomContents with search parameters
+ */
+ public function testGetCustomContentsWithSearch()
+ {
+ $result = $this->CustomContentsTool->getCustomContents(
+ status: 'publish',
+ limit: 5
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomContent with invalid ID
+ */
+ public function testGetCustomContentWithInvalidId()
+ {
+ $result = $this->CustomContentsTool->getCustomContent(999);
+
+ $this->assertIsArray($result);
+ if (isset($result['error'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test editCustomContent with invalid ID
+ */
+ public function testEditCustomContentWithInvalidId()
+ {
+ $result = $this->CustomContentsTool->editCustomContent(999, 'test', 'Test Title');
+
+ $this->assertIsArray($result);
+ if (isset($result['error'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php
new file mode 100644
index 0000000000..9122429dc4
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php
@@ -0,0 +1,611 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcCustomContent;
+
+use BaserCore\TestSuite\BcTestCase;
+use BaserCore\Utility\BcContainerTrait;
+use BcCustomContent\Service\CustomEntriesService;
+use BcCustomContent\Service\CustomEntriesServiceInterface;
+use BcCustomContent\Service\CustomTablesService;
+use BcCustomContent\Test\Factory\CustomFieldFactory;
+use BcMcp\Mcp\BcCustomContent\CustomEntriesTool;
+use BaserCore\Service\BcDatabaseServiceInterface;
+use BcCustomContent\Test\Factory\CustomTableFactory;
+use BcCustomContent\Test\Scenario\CustomFieldsScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcCustomContent\Service\CustomTablesServiceInterface;
+use BcCustomContent\Test\Scenario\CustomContentsScenario;
+use PhpMcp\Server\ServerBuilder;
+
+/**
+ * BcMcp\Mcp\BcCustomContent\CustomEntriesTool Test Case
+ *
+ * @uses \BcMcp\Mcp\BcCustomContent\CustomEntriesTool
+ */
+class CustomEntriesToolTest extends BcTestCase
+{
+ use ScenarioAwareTrait;
+ use BcContainerTrait;
+
+ /**
+ * Test subject
+ *
+ * @var \BcMcp\Mcp\BcCustomContent\CustomEntriesTool
+ */
+ protected $CustomEntriesTool;
+
+ /**
+ * setUp method
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->CustomEntriesTool = new CustomEntriesTool();
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ unset($this->CustomEntriesTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test addCustomEntry method - 基本テスト
+ * CustomTablesに依存するため、適切なセットアップが必要
+ *
+ * @return void
+ */
+ public function testAddCustomEntryBasic()
+ {
+ $dataBaseService = $this->getService(BcDatabaseServiceInterface::class);
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+
+ // CustomFieldsScenarioを読み込み
+ $this->loadFixtureScenario(CustomFieldsScenario::class);
+
+ $customTableId = 1;
+ $title = 'テストカスタムエントリー';
+
+ // カスタムテーブルを作成
+ $customTablesService->create([
+ 'type' => 'contact',
+ 'name' => 'contact',
+ 'title' => 'お問い合わせタイトル',
+ 'display_field' => 'お問い合わせ'
+ ]);
+
+ $result = $this->CustomEntriesTool->addCustomEntry(
+ customTableId: $customTableId,
+ title: $title,
+ name: 'test_entry',
+ status: true,
+ creatorId: 1
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals($title, $result['title']);
+ $this->assertEquals($customTableId, $result['custom_table_id']);
+ }
+
+ // テーブルをクリーンアップ
+ $dataBaseService->dropTable('custom_entry_1_contact');
+ }
+
+ /**
+ * Test addCustomEntry method - ファイルアップロード付きテスト
+ *
+ * @return void
+ */
+ public function testAddCustomEntryWithFileUpload()
+ {
+ // Base64画像データ(1x1ピクセルの透明PNG)
+ $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+
+ $dataBaseService = $this->getService(BcDatabaseServiceInterface::class);
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+
+ $this->loadFixtureScenario(CustomFieldsScenario::class);
+
+ $customTableId = 1;
+ $title = 'ファイルアップロード付きエントリー';
+ $customFields = [
+ 'image_field' => $base64Image,
+ 'text_field' => 'テキスト値'
+ ];
+
+ // カスタムテーブルを作成
+ $customTablesService->create([
+ 'type' => 'contact',
+ 'name' => 'contact_with_files',
+ 'title' => 'ファイル付きお問い合わせ',
+ 'display_field' => 'お問い合わせ'
+ ]);
+
+ $result = $this->CustomEntriesTool->addCustomEntry(
+ customTableId: $customTableId,
+ title: $title,
+ customFields: $customFields
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals($title, $result['title']);
+ // ファイルアップロードが処理されていることを確認
+ $this->assertNotEquals($base64Image, $result['image_field'] ?? '');
+ $this->assertEquals('テキスト値', $result['text_field'] ?? '');
+ } else {
+ // エラーケースでもレスポンス構造をテスト
+ }
+
+ // テーブルをクリーンアップ
+ $dataBaseService->dropTable('custom_entry_1_contact_with_files');
+ }
+
+ /**
+ * Test addCustomEntry method - 外部画像URL指定テスト
+ *
+ * @return void
+ */
+ public function testAddCustomEntryWithImageUrl()
+ {
+ $dataBaseService = $this->getService(BcDatabaseServiceInterface::class);
+ /** @var CustomTablesService $customTablesService */
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+
+ CustomFieldFactory::make([
+ 'id' => 1,
+ 'title' => 'ファイル',
+ 'name' => 'image_field',
+ 'type' => 'BcCcFile',
+ ])->persist();
+
+ $customTableId = 1;
+ $title = '外部画像URL付きエントリー';
+ // GitHubのアバター画像(確実にアクセス可能)
+ $imageUrl = 'https://github.com/github.png';
+ $customFields = [
+ 'image_field' => $imageUrl
+ ];
+
+ // カスタムテーブルを作成
+ $customTable = $customTablesService->create([
+ 'type' => 'contact',
+ 'name' => 'contact_with_image_url',
+ 'title' => '画像URL付きお問い合わせ',
+ 'display_field' => 'お問い合わせ'
+ ]);
+ $customTablesService->update($customTable, [
+ 'id' => $customTable->id,
+ 'custom_links' => [
+ 'new-2' => [
+ 'custom_field_id' => 1,
+ 'title' => 'ファイル',
+ 'name' => 'image_field',
+ 'type' => 'BcCcFile',
+ 'status' => true,
+ ]
+ ]
+ ]);
+
+ /** @var CustomEntriesService $customEntriesService */
+ $customEntriesService = $this->getService(CustomEntriesServiceInterface::class);
+ $customEntriesService->setup(1);
+
+ $result = $this->CustomEntriesTool->addCustomEntry(
+ customTableId: $customTableId,
+ title: $title,
+ customFields: $customFields,
+ status: true
+ );
+
+ $this->assertIsArray($result);
+ // 登録が成功したことを確認
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals($title, $result['title']);
+ // 外部画像URLが正しく保存されていることを確認(保存先は現在年月のディレクトリ)
+ $this->assertEquals(date('Y/m') . '/00000001_image_field.png', $result['image_field'] ?? '');
+ $this->assertTrue($result['status'] ?? false);
+
+ // テーブルをクリーンアップ
+ $dataBaseService->dropTable('custom_entry_1_contact_with_image_url');
+ }
+
+ /**
+ * Test addCustomEntry method - カスタムフィールド付きテスト
+ *
+ * @return void
+ */
+ public function testAddCustomEntryWithCustomFields()
+ {
+ $dataBaseService = $this->getService(BcDatabaseServiceInterface::class);
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+
+ $this->loadFixtureScenario(CustomFieldsScenario::class);
+
+ $customTableId = 1;
+ $title = 'カスタムフィールド付きエントリー';
+ $customFields = [
+ 'custom_field1' => 'カスタム値1',
+ 'custom_field2' => 'カスタム値2'
+ ];
+
+ // カスタムテーブルを作成
+ $customTablesService->create([
+ 'type' => 'contact',
+ 'name' => 'contact',
+ 'title' => 'お問い合わせタイトル',
+ 'display_field' => 'お問い合わせ'
+ ]);
+
+ $result = $this->CustomEntriesTool->addCustomEntry(
+ customTableId: $customTableId,
+ title: $title,
+ customFields: $customFields
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals($title, $result['title']);
+ }
+
+ // テーブルをクリーンアップ
+ $dataBaseService->dropTable('custom_entry_1_contact');
+ }
+
+ /**
+ * Test addCustomEntry method - エラーテスト(空のタイトル)
+ *
+ * @return void
+ */
+ public function testAddCustomEntryWithEmptyTitle()
+ {
+ $result = $this->CustomEntriesTool->addCustomEntry(
+ customTableId: 1,
+ title: ''
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test getCustomEntries method - 基本的な一覧取得テスト
+ *
+ * @return void
+ */
+ public function testGetCustomEntriesBasic()
+ {
+ // テストデータを作成
+ CustomTableFactory::make([
+ 'id' => 1,
+ 'name' => 'test_table',
+ 'display_name' => 'テストテーブル',
+ 'status' => 1
+ ])->persist();
+
+ $this->loadFixtureScenario(CustomContentsScenario::class);
+
+ $result = $this->CustomEntriesTool->getCustomEntries(
+ customTableId: 1,
+ limit: 10,
+ page: 1
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ $this->assertArrayHasKey('pagination', $result);
+ $this->assertEquals(10, $result['pagination']['limit']);
+ $this->assertEquals(1, $result['pagination']['page']);
+ } else {
+ // エラーケースでもレスポンス構造をテスト
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * Test getCustomEntries method - ステータスフィルタリングテスト
+ *
+ * @return void
+ */
+ public function testGetCustomEntriesWithStatusFilter()
+ {
+ // テストデータを作成
+ CustomTableFactory::make([
+ 'id' => 1,
+ 'name' => 'test_table',
+ 'display_name' => 'テストテーブル',
+ 'status' => 1
+ ])->persist();
+
+ $this->loadFixtureScenario(CustomContentsScenario::class);
+
+ $result = $this->CustomEntriesTool->getCustomEntries(
+ customTableId: 1,
+ status: 'publish',
+ limit: 5
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals(5, $result['pagination']['limit']);
+ } else {
+ // エラーケースでもレスポンス構造をテスト
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * Test getCustomEntry method - IDによる単一取得テスト
+ *
+ * @return void
+ */
+ public function testGetCustomEntryById()
+ {
+ $result = $this->CustomEntriesTool->getCustomEntry(
+ customTableId: 1,
+ id: 1
+ );
+
+ $this->assertIsArray($result);
+ // 存在しないエントリーの場合はエラーが返される
+ if (isset($result['error']) && $result['error']) {
+ $this->assertArrayHasKey('content', $result);
+ } else if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals(1, $result['id']);
+ }
+ }
+
+ /**
+ * Test getCustomEntry method - 存在しないIDのテスト
+ *
+ * @return void
+ */
+ public function testGetCustomEntryNotFound()
+ {
+ $nonExistentId = 999999;
+
+ $result = $this->CustomEntriesTool->getCustomEntry(
+ customTableId: 1,
+ id: $nonExistentId
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test editCustomEntry method - 基本的な編集テスト
+ *
+ * @return void
+ */
+ public function testEditCustomEntryBasic()
+ {
+ $newTitle = '編集されたタイトル';
+ $newStatus = true;
+
+ $result = $this->CustomEntriesTool->editCustomEntry(
+ customTableId: 1,
+ id: 1,
+ title: $newTitle,
+ status: $newStatus
+ );
+
+ $this->assertIsArray($result);
+ // 存在しないエントリーの場合はエラーが返される
+ if (isset($result['error']) && $result['error']) {
+ $this->assertArrayHasKey('content', $result);
+ } else if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals($newTitle, $result['title']);
+ }
+ }
+
+ /**
+ * Test editCustomEntry method - カスタムフィールド編集テスト
+ *
+ * @return void
+ */
+ public function testEditCustomEntryWithCustomFields()
+ {
+ $customFields = [
+ 'custom_field1' => '更新されたカスタム値1',
+ 'custom_field2' => '更新されたカスタム値2'
+ ];
+
+ $result = $this->CustomEntriesTool->editCustomEntry(
+ customTableId: 1,
+ id: 1,
+ customFields: $customFields
+ );
+
+ $this->assertIsArray($result);
+ // 存在しないエントリーの場合はエラーが返される
+ if (isset($result['error']) && $result['error']) {
+ $this->assertArrayHasKey('content', $result);
+ } else if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * Test editCustomEntry method - 存在しないエントリーの編集テスト
+ *
+ * @return void
+ */
+ public function testEditCustomEntryNotFound()
+ {
+ $nonExistentId = 999999;
+
+ $result = $this->CustomEntriesTool->editCustomEntry(
+ customTableId: 1,
+ id: $nonExistentId,
+ title: '新しいタイトル'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test deleteCustomEntry method - 削除機能テスト
+ *
+ * @return void
+ */
+ public function testDeleteCustomEntryBasic()
+ {
+ $result = $this->CustomEntriesTool->deleteCustomEntry(
+ customTableId: 1,
+ id: 1
+ );
+
+ $this->assertIsArray($result);
+ // 削除処理は存在しないエントリーでもエラーハンドリングされる
+ if (isset($result['error']) && $result['error']) {
+ $this->assertArrayHasKey('content', $result);
+ } else if (isset($result['success']) && $result['success']) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * Test deleteCustomEntry method - 存在しないエントリーの削除テスト
+ *
+ * @return void
+ */
+ public function testDeleteCustomEntryNotFound()
+ {
+ $nonExistentId = 999999;
+
+ $result = $this->CustomEntriesTool->deleteCustomEntry(
+ customTableId: 1,
+ id: $nonExistentId
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test addToolsToBuilder method - ServerBuilderへのツール追加テスト
+ *
+ * @return void
+ */
+ public function testAddToolsToBuilder()
+ {
+ // ServerBuilderがfinalクラスのため、実際のインスタンスを使用
+ $serverBuilder = new ServerBuilder();
+
+ $result = $this->CustomEntriesTool->addToolsToBuilder($serverBuilder);
+
+ $this->assertInstanceOf(ServerBuilder::class, $result);
+ // ServerBuilderが返されることを確認(チェーンメソッドパターン)
+ $this->assertSame($serverBuilder, $result);
+ }
+
+ /**
+ * Test processCustomFields method - ファイル処理テスト
+ *
+ * @return void
+ */
+ public function testProcessCustomFields()
+ {
+ // Base64画像データ(1x1ピクセルの透明PNG)
+ $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+
+ $customFields = [
+ 'text_field' => 'テキスト値',
+ 'number_field' => 123,
+ 'image_field' => $base64Image,
+ 'array_field' => ['値1', '値2']
+ ];
+
+ // リフレクションを使ってプライベートメソッドをテスト
+ $reflection = new \ReflectionClass($this->CustomEntriesTool);
+ $method = $reflection->getMethod('processCustomFields');
+
+ $result = $method->invoke($this->CustomEntriesTool, $customFields, 1); // customTableId = 1 を追加
+
+ $this->assertIsArray($result);
+ $this->assertEquals('テキスト値', $result['text_field']);
+ $this->assertEquals(123, $result['number_field']);
+ // フィールドタイプがBcCcFileでない場合、ファイルアップロード処理は行われない
+ $this->assertEquals($base64Image, $result['image_field']); // そのまま残る
+ $this->assertEquals(['値1', '値2'], $result['array_field']);
+ }
+
+ /**
+ * test getCustomFieldType method
+ */
+ public function testGetCustomFieldType()
+ {
+ $customTableId = 1;
+ $fieldName = 'test_field';
+
+ // リフレクションを使ってプライベートメソッドをテスト
+ $reflection = new \ReflectionClass($this->CustomEntriesTool);
+ $method = $reflection->getMethod('getCustomFieldType');
+
+ // フィールドタイプが取得できない場合はnullを返す
+ $result = $method->invoke($this->CustomEntriesTool, $customTableId, $fieldName);
+ $this->assertNull($result);
+ }
+
+ /**
+ * test isFileUploadField method
+ */
+ public function testIsFileUploadField()
+ {
+ $customTableId = 1;
+ $fieldName = 'test_field';
+
+ // リフレクションを使ってプライベートメソッドをテスト
+ $reflection = new \ReflectionClass($this->CustomEntriesTool);
+ $method = $reflection->getMethod('isFileUploadField');
+
+ // フィールドタイプが取得できない場合はfalseを返す
+ $result = $method->invoke($this->CustomEntriesTool, $customTableId, $fieldName);
+ $this->assertFalse($result);
+ }
+
+ /**
+ * test isFileUpload method
+ */
+ public function testIsFileUpload()
+ {
+ $customTableId = 1;
+ $fieldName = 'test_field';
+ $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg==';
+
+ // リフレクションを使ってプライベートメソッドをテスト
+ $reflection = new \ReflectionClass($this->CustomEntriesTool);
+ $method = $reflection->getMethod('isFileUpload');
+
+ // フィールドタイプが取得できない場合、ファイルアップロード形式でもfalseを返す
+ $result = $method->invoke($this->CustomEntriesTool, $base64Data, $customTableId, $fieldName);
+ $this->assertFalse($result);
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php
new file mode 100644
index 0000000000..50534ceb03
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php
@@ -0,0 +1,214 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcCustomContent;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcMcp\Mcp\BcCustomContent\CustomFieldsTool;
+
+/**
+ * CustomFieldsToolTest
+ */
+class CustomFieldsToolTest extends BcTestCase
+{
+ /**
+ * @var CustomFieldsTool
+ */
+ public $CustomFieldsTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->CustomFieldsTool = new CustomFieldsTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->CustomFieldsTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test instantiation
+ */
+ public function testInstantiation()
+ {
+ $this->assertInstanceOf(CustomFieldsTool::class, $this->CustomFieldsTool);
+ $this->assertTrue(method_exists($this->CustomFieldsTool, 'addCustomField'));
+ $this->assertTrue(method_exists($this->CustomFieldsTool, 'getCustomFields'));
+ }
+
+ /**
+ * test addCustomField
+ */
+ public function testAddCustomField()
+ {
+ $result = $this->CustomFieldsTool->addCustomField(
+ name: 'test_field',
+ title: 'テストフィールド',
+ type: 'text'
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomFields
+ */
+ public function testGetCustomFields()
+ {
+ $result = $this->CustomFieldsTool->getCustomFields();
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomField
+ */
+ public function testGetCustomField()
+ {
+ $result = $this->CustomFieldsTool->getCustomField(1);
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test editCustomField
+ */
+ public function testEditCustomField()
+ {
+ $result = $this->CustomFieldsTool->editCustomField(
+ 1,
+ 'updated_field',
+ '更新されたフィールド',
+ 'textarea'
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test deleteCustomField
+ */
+ public function testDeleteCustomField()
+ {
+ $result = $this->CustomFieldsTool->deleteCustomField(1);
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomFields with search parameters
+ */
+ public function testGetCustomFieldsWithSearch()
+ {
+ $result = $this->CustomFieldsTool->getCustomFields(
+ name: 'test',
+ title: 'text',
+ status: 1
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test getCustomField with invalid ID
+ */
+ public function testGetCustomFieldWithInvalidId()
+ {
+ $result = $this->CustomFieldsTool->getCustomField(999);
+
+ $this->assertIsArray($result);
+ if (isset($result['error'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test editCustomField with invalid ID
+ */
+ public function testEditCustomFieldWithInvalidId()
+ {
+ $result = $this->CustomFieldsTool->editCustomField(999, 'test', 'Test Field', 'text');
+
+ $this->assertIsArray($result);
+ if (isset($result['error'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test deleteCustomField with invalid ID
+ */
+ public function testDeleteCustomFieldWithInvalidId()
+ {
+ $result = $this->CustomFieldsTool->deleteCustomField(999);
+
+ $this->assertIsArray($result);
+ if (isset($result['error'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+
+ /**
+ * test addCustomField with minimal parameters
+ */
+ public function testAddCustomFieldWithMinimalParameters()
+ {
+ $result = $this->CustomFieldsTool->addCustomField(
+ 'minimal_field',
+ 'ミニマルフィールド',
+ 'text'
+ );
+
+ $this->assertIsArray($result);
+ if (isset($result['success'])) {
+ }
+ if (isset($result['content'])) {
+ $this->assertArrayHasKey('content', $result);
+ }
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php
new file mode 100644
index 0000000000..22c6dabfac
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php
@@ -0,0 +1,244 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcCustomContent;
+
+use BaserCore\TestSuite\BcTestCase;
+use BaserCore\Utility\BcContainerTrait;
+use BcMcp\Mcp\BcCustomContent\CustomLinksTool;
+use BaserCore\Service\BcDatabaseServiceInterface;
+use BcCustomContent\Test\Factory\CustomLinkFactory;
+use BcCustomContent\Test\Factory\CustomTableFactory;
+use BcCustomContent\Test\Scenario\CustomFieldsScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcCustomContent\Service\CustomTablesServiceInterface;
+use BcCustomContent\Test\Scenario\CustomContentsScenario;
+
+/**
+ * BcMcp\Mcp\BcCustomContent\CustomLinksTool Test Case
+ *
+ * @uses \BcMcp\Mcp\BcCustomContent\CustomLinksTool
+ */
+class CustomLinksToolTest extends BcTestCase
+{
+ use ScenarioAwareTrait;
+ use BcContainerTrait;
+
+ /**
+ * Test subject
+ *
+ * @var \BcMcp\Mcp\BcCustomContent\CustomLinksTool
+ */
+ protected $CustomLinksTool;
+
+ /**
+ * setUp method
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->CustomLinksTool = new CustomLinksTool();
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ public function tearDown(): void
+ {
+ unset($this->CustomLinksTool);
+ parent::tearDown();
+ }
+
+ /**
+ * Test instantiation
+ */
+ public function testInstantiation()
+ {
+ $this->assertInstanceOf(CustomLinksTool::class, $this->CustomLinksTool);
+ $this->assertTrue(method_exists($this->CustomLinksTool, 'addCustomLink'));
+ $this->assertTrue(method_exists($this->CustomLinksTool, 'getCustomLink'));
+ $this->assertTrue(method_exists($this->CustomLinksTool, 'getCustomLinks'));
+ }
+
+ /**
+ * Test addCustomLink method - 基本テスト (簡略版)
+ * 複雑な依存関係のため、メソッドの存在のみをテスト
+ *
+ * @return void
+ */
+ public function testAddCustomLinkBasic()
+ {
+ // メソッドが存在することを確認
+ $this->assertTrue(method_exists($this->CustomLinksTool, 'addCustomLink'));
+
+ // メソッドのパラメータ数を確認
+ $reflection = new \ReflectionMethod($this->CustomLinksTool, 'addCustomLink');
+ $this->assertGreaterThanOrEqual(4, $reflection->getNumberOfParameters());
+
+ // 必須パラメータが正しく定義されていることを確認
+ $parameters = $reflection->getParameters();
+ $this->assertEquals('name', $parameters[0]->getName());
+ $this->assertEquals('title', $parameters[1]->getName());
+ $this->assertEquals('customTableId', $parameters[2]->getName());
+ $this->assertEquals('customFieldId', $parameters[3]->getName());
+ }
+
+ /**
+ * Test getCustomLink method - IDによる取得
+ *
+ * @return void
+ */
+ public function testGetCustomLink()
+ {
+ // テストデータを作成
+ CustomLinkFactory::make([
+ 'id' => 1,
+ 'custom_table_id' => 1,
+ 'custom_field_id' => 1,
+ 'name' => 'test_link', // ハイフンをアンダースコアに変更
+ 'title' => 'テストリンク',
+ 'status' => 1
+ ])->persist();
+
+ $result = $this->CustomLinksTool->getCustomLink(1);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('id', $result);
+ $this->assertEquals(1, $result['id']);
+ }
+
+ /**
+ * Test editCustomLink method - 編集機能
+ *
+ * @return void
+ */
+ public function testEditCustomLink()
+ {
+ // テストデータを作成
+ CustomLinkFactory::make([
+ 'id' => 1,
+ 'custom_table_id' => 1,
+ 'custom_field_id' => 1,
+ 'name' => 'test_link',
+ 'title' => 'テストリンク',
+ 'status' => 1
+ ])->persist();
+
+ $newTitle = '編集テストリンク';
+
+ $result = $this->CustomLinksTool->editCustomLink(
+ id: 1,
+ title: $newTitle
+ );
+
+ $this->assertIsArray($result);
+ // エラーでない場合はタイトルが更新されたことを確認
+ if (!isset($result['content']) || !is_string($result['content'])) {
+ $this->assertEquals($newTitle, $result['title']);
+ }
+ }
+
+ /**
+ * Test deleteCustomLink method - 削除機能
+ *
+ * @return void
+ */
+ public function testDeleteCustomLink()
+ {
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+ $databaseService = $this->getService(BcDatabaseServiceInterface::class);
+ // テストデータを作成
+ CustomLinkFactory::make([
+ 'id' => 1,
+ 'custom_table_id' => 1,
+ 'custom_field_id' => 1,
+ 'name' => 'test_link',
+ 'title' => 'テストリンク',
+ 'status' => 1
+ ])->persist();
+ $customTablesService->create([
+ 'type' => 'contact',
+ 'name' => 'contact',
+ 'title' => 'お問い合わせタイトル',
+ 'display_field' => 'お問い合わせ'
+ ]);
+ $databaseService->addColumn('custom_entry_1_contact', 'test_link', 'text');
+ $result = $this->CustomLinksTool->deleteCustomLink(1);
+ $this->assertArrayHasKey('message', $result);
+ $databaseService->dropTable('custom_entry_1_contact');
+ }
+
+ /**
+ * Test addCustomLink method - エラーテスト(空の名前)
+ *
+ * @return void
+ */
+ public function testAddCustomLinkWithEmptyName()
+ {
+ $result = $this->CustomLinksTool->addCustomLink(
+ name: '',
+ title: 'テストタイトル',
+ customTableId: 1,
+ customFieldId: 1
+ );
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test getCustomLink method - 存在しないIDのテスト
+ *
+ * @return void
+ */
+ public function testGetCustomLinkNotFound()
+ {
+ $nonExistentId = 999999;
+
+ $result = $this->CustomLinksTool->getCustomLink($nonExistentId);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('content', $result);
+ }
+
+ /**
+ * Test getCustomLinks method - フィルタリングテスト
+ *
+ * @return void
+ */
+ public function testGetCustomLinks()
+ {
+ CustomTableFactory::make([
+ 'id' => 1,
+ 'name' => 'test_table',
+ 'display_name' => 'テストテーブル',
+ 'status' => 1
+ ])->persist();
+ $this->loadFixtureScenario(CustomContentsScenario::class);
+ $this->loadFixtureScenario(CustomFieldsScenario::class);
+
+ // ステータス1でフィルタリング
+ $result = $this->CustomLinksTool->getCustomLinks(
+ customTableId: 1,
+ status: 'publish',
+ limit: 10
+ );
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result['results']);
+ $this->assertArrayHasKey('pagination', $result);
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php
new file mode 100644
index 0000000000..b730919fcd
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php
@@ -0,0 +1,196 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @since 5.0.7
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp\BcCustomContent;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcCustomContent\Service\CustomEntriesServiceInterface;
+use BcCustomContent\Service\CustomTablesServiceInterface;
+use BcCustomContent\Test\Factory\CustomFieldFactory;
+use BcCustomContent\Test\Scenario\CustomTablesScenario;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use BcMcp\Mcp\BcCustomContent\CustomTablesTool;
+
+/**
+ * CustomTablesToolTest
+ */
+class CustomTablesToolTest extends BcTestCase
+{
+
+ use ScenarioAwareTrait;
+
+ /**
+ * @var CustomTablesTool
+ */
+ public $CustomTablesTool;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->CustomTablesTool = new CustomTablesTool();
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->CustomTablesTool);
+ parent::tearDown();
+ }
+
+ /**
+ * test addCustomTable
+ */
+ public function testAddCustomTable()
+ {
+ CustomFieldFactory::make([
+ 'name' => 'field1'
+ ])->persist();
+ CustomFieldFactory::make([
+ 'name' => 'field2'
+ ])->persist();
+ $result = $this->CustomTablesTool->addCustomTable(
+ name: 'test_table',
+ title: 'テストテーブル',
+ customFieldNames: ['field1', 'field2']
+ );
+
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('test_table', $result['name']);
+ $this->assertEquals('テストテーブル', $result['title']);
+ }
+
+ /**
+ * test getCustomTables
+ */
+ public function testGetCustomTables()
+ {
+ $this->loadFixtureScenario(CustomTablesScenario::class);
+ $result = $this->CustomTablesTool->getCustomTables();
+
+ $this->assertIsArray($result);
+ $this->assertNotEmpty($result);
+ }
+
+ /**
+ * test getCustomTable
+ */
+ public function testGetCustomTable()
+ {
+ $this->loadFixtureScenario(CustomTablesScenario::class);
+ $result = $this->CustomTablesTool->getCustomTable(2);
+
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals(2, $result['id']);
+ }
+
+ /**
+ * test editCustomTable
+ */
+ public function testEditCustomTable()
+ {
+ $this->loadFixtureScenario(CustomTablesScenario::class);
+ $result = $this->CustomTablesTool->editCustomTable(
+ id: 2,
+ name: 'updated_table',
+ title: '更新されたテーブル',
+ customFieldNames: ['field3', 'field4']
+ );
+
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('updated_table', $result['name']);
+ $this->assertEquals('更新されたテーブル', $result['title']);
+ }
+
+ /**
+ * test deleteCustomTable
+ */
+ public function testDeleteCustomTable()
+ {
+ $customEntriesService = $this->getService(CustomEntriesServiceInterface::class);
+ $customTablesService = $this->getService(CustomTablesServiceInterface::class);
+ $customTablesService->create([
+ 'name' => 'test_table',
+ 'title' => 'テストテーブル',
+ 'type' => 'default'
+ ]);
+ $customEntriesService->setup(1);
+ $result = $this->CustomTablesTool->deleteCustomTable(1);
+
+ $this->assertArrayHasKey('message', $result);
+ $this->assertEquals('カスタムテーブルを削除しました', $result['message']);
+ }
+
+ /**
+ * test getCustomTables with search parameters
+ */
+ public function testGetCustomTablesWithSearch()
+ {
+ $this->loadFixtureScenario(CustomTablesScenario::class);
+ $result = $this->CustomTablesTool->getCustomTables(2, 1, 'default', 10, 1);
+
+ $this->assertIsArray($result);
+ $this->assertNotEmpty($result);
+ }
+
+ /**
+ * test getCustomTable with invalid ID
+ */
+ public function testGetCustomTableWithInvalidId()
+ {
+ $result = $this->CustomTablesTool->getCustomTable(999);
+
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `custom_tables`.', $result['content']);
+ }
+
+ /**
+ * test editCustomTable with invalid ID
+ */
+ public function testEditCustomTableWithInvalidId()
+ {
+ $result = $this->CustomTablesTool->editCustomTable(999, 'test', 'Test Table');
+
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `custom_tables`.', $result['content']);
+ }
+
+ /**
+ * test deleteCustomTable with invalid ID
+ */
+ public function testDeleteCustomTableWithInvalidId()
+ {
+ $result = $this->CustomTablesTool->deleteCustomTable(999);
+
+ $this->assertArrayHasKey('content', $result);
+ $this->assertEquals('Record not found in table `custom_tables`.', $result['content']);
+ }
+
+ /**
+ * test addCustomTable without customFieldNames
+ */
+ public function testAddCustomTableWithoutCustomFieldNames()
+ {
+ $result = $this->CustomTablesTool->addCustomTable(
+ name: 'simple_table',
+ title: 'シンプルテーブル'
+ );
+
+ $this->assertArrayHasKey('title', $result);
+ $this->assertEquals('simple_table', $result['name']);
+ $this->assertEquals('シンプルテーブル', $result['title']);
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php
new file mode 100644
index 0000000000..c4d2d32b52
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php
@@ -0,0 +1,81 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp;
+
+use BaserCore\TestSuite\BcTestCase;
+use BcMcp\Mcp\McpLogger;
+
+/**
+ * McpLoggerTest
+ */
+class McpLoggerTest extends BcTestCase
+{
+
+ /**
+ * ログファイルのパス
+ * @var string
+ */
+ private string $logFile;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->logFile = TMP . 'bc_mcp_logger_test.log';
+ if (file_exists($this->logFile)) unlink($this->logFile);
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ if (file_exists($this->logFile)) unlink($this->logFile);
+ parent::tearDown();
+ }
+
+ /**
+ * test log
+ *
+ * 例外のトレースまで記録される事を確認する
+ */
+ public function testLog()
+ {
+ $logger = new McpLogger($this->logFile);
+ $logger->error('Tool execution failed.', [
+ 'tool' => 'addBlogPost',
+ 'exception' => new \Exception('Call to a member function getParam() on null')
+ ]);
+
+ $log = file_get_contents($this->logFile);
+ $this->assertStringContainsString('Tool execution failed.', $log);
+ $this->assertStringContainsString('(tool: addBlogPost)', $log);
+ $this->assertStringContainsString('Call to a member function getParam() on null', $log);
+ // トレースが記録されている事を確認
+ $this->assertStringContainsString('#0 ', $log);
+ }
+
+ /**
+ * test log with unrecorded level
+ *
+ * 記録対象外のログレベルは記録されない事を確認する
+ */
+ public function testLogWithUnrecordedLevel()
+ {
+ $logger = new McpLogger($this->logFile);
+ $logger->debug('デバッグメッセージ');
+ $this->assertFalse(file_exists($this->logFile));
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php
new file mode 100644
index 0000000000..f0d90d0341
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php
@@ -0,0 +1,159 @@
+
+ * Copyright (c) NPO baser foundation
+ *
+ * @copyright Copyright (c) NPO baser foundation
+ * @link https://basercms.net baserCMS Project
+ * @license https://basercms.net/license/index.html MIT License
+ */
+
+namespace BcMcp\Test\TestCase\Mcp;
+
+use BaserCore\Test\Scenario\InitAppScenario;
+use BaserCore\TestSuite\BcTestCase;
+use BcBlog\Test\Scenario\BlogContentScenario;
+use BcMcp\Mcp\McpServer;
+use CakephpFixtureFactories\Scenario\ScenarioAwareTrait;
+use PhpMcp\Schema\Request\CallToolRequest;
+use PhpMcp\Server\Dispatcher;
+use PhpMcp\Server\Session\SubscriptionManager;
+
+/**
+ * McpServerToolCallTest
+ *
+ * MCPサーバーを別プロセスで起動する事なく、JSON-RPC の tools/call と同じ経路
+ * (スキーマ検証 → 引数マッピング → ツール実行)をプロセス内で実行するテスト
+ */
+class McpServerToolCallTest extends BcTestCase
+{
+
+ use ScenarioAwareTrait;
+
+ /**
+ * @var Dispatcher
+ */
+ protected $dispatcher;
+
+ /**
+ * Set up
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $server = (new McpServer())->getServer();
+ $configuration = $server->getConfiguration();
+ $this->dispatcher = new Dispatcher(
+ $configuration,
+ $server->getRegistry(),
+ new SubscriptionManager($configuration->logger)
+ );
+ }
+
+ /**
+ * Tear down
+ */
+ public function tearDown(): void
+ {
+ unset($this->dispatcher);
+ parent::tearDown();
+ }
+
+ /**
+ * tools/call を実行する
+ *
+ * @param string $name ツール名
+ * @param array $arguments 引数
+ * @return array [デコード済みの戻り値, エラーかどうか]
+ */
+ private function callTool(string $name, array $arguments): array
+ {
+ $result = $this->dispatcher->handleToolCall(
+ new CallToolRequest('test-' . $name, $name, $arguments)
+ );
+ $text = $result->content[0]->text ?? '';
+ return [json_decode($text, true) ?? $text, $result->isError];
+ }
+
+ /**
+ * test tools/call addBlogPost
+ *
+ * 本番環境にて `Call to a member function getParam() on null` が発生した
+ * リクエストと同じ引数で、ブログ記事が登録できる事を確認する
+ */
+ public function testCallToolAddBlogPost()
+ {
+ $this->loadFixtureScenario(InitAppScenario::class);
+ $this->loadFixtureScenario(BlogContentScenario::class,
+ 1, // id
+ 1, // siteId
+ null, // parentId
+ 'news', // name
+ '/news/' // url
+ );
+
+ [$result, $isError] = $this->callTool('addBlogPost', [
+ 'title' => 'BcMcpについて',
+ 'name' => 'about-bcmcp',
+ 'status' => 0,
+ 'content' => '
BcMcpは、baserCMSを外部のAIエージェントから直接操作できるようにするMCP(Model Context Protocol)サーバーです。ブログ記事やカテゴリ、タグの管理はもちろん、カスタムテーブル・カスタムコンテンツ・カスタムエントリー・カスタムリンクといったbaserCMSの柔軟な拡張機能まで、AIアシスタント経由で読み書きできます。
',
+ 'detail' => $this->getDetail(),
+ 'loginUserId' => 1,
+ ]);
+
+ // ツール実行時に例外が発生していない事を確認
+ $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE)));
+ // ブログ記事が登録されている事を確認
+ $this->assertArrayHasKey('id', $result, 'ブログ記事の登録に失敗しました。' . json_encode($result, JSON_UNESCAPED_UNICODE));
+ $this->assertEquals('BcMcpについて', $result['title']);
+ $this->assertEquals('about-bcmcp', $result['name']);
+ $this->assertEquals(1, $result['blog_content_id']);
+ $this->assertEquals(1, $result['user_id']);
+ $this->assertFalse($result['status']);
+ }
+
+ /**
+ * 本番環境で送信された記事詳細を取得する
+ *
+ * @return string
+ */
+ private function getDetail(): string
+ {
+ return <<
BcMcpとは
+BcMcpは、baserCMSをAIエージェントから直接操作するためのMCP(Model Context Protocol)サーバーです。MCPは、AIアシスタントと外部システムを標準化された方法でつなぐプロトコルであり、BcMcpはこの仕組みを使ってbaserCMSのAPIをAIエージェント向けに公開しています。
+これにより、ChatやAIエージェントとの対話の中で「ブログ記事を書いて」「このカテゴリを追加して」といった指示を出すだけで、baserCMSサイトの更新が完結するようになります。
+
+BcMcpでできること
+BcMcpは、baserCMSが持つ主要な機能をひととおりカバーしています。
+
+- ブログ管理:ブログコンテンツの作成・取得、記事の追加・編集・削除、カテゴリの管理、タグの管理
+- カスタムコンテンツ管理:カスタムテーブルと紐づくカスタムコンテンツの作成・編集・削除
+- カスタムエントリー管理:カスタムテーブルに登録されたデータ(エントリー)の一覧取得・追加・編集・削除
+- カスタムフィールド管理:カスタムエントリーの入力項目定義の作成・編集・削除
+- カスタムテーブル管理:カスタムフィールドを組み合わせたテーブル自体の作成・編集・削除
+- カスタムリンク管理:サイト内の任意のリンク項目の作成・編集・削除
+
+つまり、記事の投稿だけでなく、baserCMSの汎用データベース機能(カスタムテーブル)を使った独自コンテンツの管理まで、AIエージェント経由でひととおり行えるようになっています。
+
+なぜBcMcpを作ったのか
+baserCMSは2010年の誕生以来、オープンソースのCMSとして進化を続けてきました。近年のAIエージェントの普及を受けて、baserCMSを「人が管理画面を操作するCMS」から一歩進めて、「AIエージェントが自律的に運用できるCMS(Agentic CMS)」として位置づけ直す取り組みの一環がBcMcpです。
+管理画面にログインして手作業で更新する代わりに、AIエージェントに指示を出すだけでサイト更新が完結する。BcMcpはそのための土台となるインターフェースです。
+
+活用イメージ
+ClaudeのようなAIアシスタントにBcMcpを接続すると、たとえば次のようなことが会話ベースで行えるようになります。
+
+- 新しいブログ記事の下書きを作ってもらい、そのまま下書き状態でサイトに登録する
+- 既存記事の内容を要約・修正してもらい、そのまま更新する
+- お知らせやFAQなど、カスタムテーブルで管理しているデータをまとめて追加・更新する
+- サイト内の各種リンク項目を整理・更新する
+
+これにより、コンテンツ更新のたびに管理画面を開いて手作業を行う必要がなくなり、AIとの対話の延長でサイト運用が進むようになります。
+
+まとめ
+BcMcpは、baserCMSをAIエージェントから直接操作できるようにするMCPサーバーです。ブログ管理からカスタムテーブルを使った独自コンテンツ管理まで幅広くカバーしており、baserCMSをAIネイティブに運用していくための重要なピースとなっています。
+EOF;
+ }
+
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php
new file mode 100644
index 0000000000..9bf5381dca
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php
@@ -0,0 +1,267 @@
+service = new OAuth2ClientRegistrationService($clientRepository);
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ protected function tearDown(): void
+ {
+ unset($this->service);
+ parent::tearDown();
+ }
+
+ /**
+ * Test registerClient method
+ *
+ * @return void
+ */
+ public function testRegisterClient(): void
+ {
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['authorization_code'],
+ 'scope' => 'mcp:read mcp:write',
+ 'token_endpoint_auth_method' => 'client_secret_basic',
+ 'contacts' => ['admin@example.com']
+ ];
+
+ $baseUrl = 'https://localhost';
+ $client = $this->service->registerClient($requestData, $baseUrl);
+
+ $this->assertNotNull($client);
+ $this->assertEquals('Test Client', $client->getName());
+ $this->assertEquals(['https://example.com/callback'], $client->getRedirectUri());
+ $this->assertEquals(['authorization_code'], $client->getGrants());
+ $this->assertEquals(['mcp:read', 'mcp:write'], $client->getScopes());
+ $this->assertEquals('client_secret_basic', $client->getTokenEndpointAuthMethod());
+ $this->assertEquals(['admin@example.com'], $client->getContacts());
+ $this->assertNotNull($client->getRegistrationAccessToken());
+ $this->assertNotNull($client->getRegistrationClientUri());
+ $this->assertNotNull($client->getClientIdIssuedAt());
+ }
+
+ /**
+ * Test registerClient with invalid redirect URI
+ *
+ * @return void
+ */
+ public function testRegisterClientWithInvalidRedirectUri(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Invalid redirect_uri: invalid-uri');
+
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['invalid-uri'],
+ 'grant_types' => ['authorization_code']
+ ];
+
+ $this->service->registerClient($requestData, 'https://localhost');
+ }
+
+ /**
+ * Test registerClient with unsupported grant type
+ *
+ * @return void
+ */
+ public function testRegisterClientWithUnsupportedGrantType(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Unsupported grant_type: unsupported_grant');
+
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['unsupported_grant']
+ ];
+
+ $this->service->registerClient($requestData, 'https://localhost');
+ }
+
+ /**
+ * Test getClient method
+ *
+ * @return void
+ */
+ public function testGetClient(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $client = $this->service->registerClient($requestData, 'https://localhost');
+ $clientId = $client->getIdentifier();
+ $registrationToken = $client->getRegistrationAccessToken();
+
+ // Then retrieve it
+ $retrievedClient = $this->service->getClient($clientId, $registrationToken);
+
+ $this->assertNotNull($retrievedClient);
+ $this->assertEquals($clientId, $retrievedClient->getIdentifier());
+ $this->assertEquals('Test Client', $retrievedClient->getName());
+ }
+
+ /**
+ * Test getClient with invalid token
+ *
+ * @return void
+ */
+ public function testGetClientWithInvalidToken(): void
+ {
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $client = $this->service->registerClient($requestData, 'https://localhost');
+ $clientId = $client->getIdentifier();
+
+ // Try to retrieve with invalid token
+ $retrievedClient = $this->service->getClient($clientId, 'invalid_token');
+ $this->assertNull($retrievedClient);
+ }
+
+ /**
+ * Test updateClient method
+ *
+ * @return void
+ */
+ public function testUpdateClient(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $client = $this->service->registerClient($requestData, 'https://localhost');
+ $clientId = $client->getIdentifier();
+ $registrationToken = $client->getRegistrationAccessToken();
+
+ // Update the client
+ $updateData = [
+ 'client_name' => 'Updated Client',
+ 'redirect_uris' => ['https://updated.com/callback'],
+ 'scope' => 'mcp:read'
+ ];
+
+ $updatedClient = $this->service->updateClient($clientId, $registrationToken, $updateData);
+
+ $this->assertNotNull($updatedClient);
+ $this->assertEquals('Updated Client', $updatedClient->getName());
+ $this->assertEquals(['https://updated.com/callback'], $updatedClient->getRedirectUri());
+ $this->assertEquals(['mcp:read'], $updatedClient->getScopes());
+ }
+
+ /**
+ * Test deleteClient method
+ *
+ * @return void
+ */
+ public function testDeleteClient(): void
+ {
+ // First register a client
+ $requestData = [
+ 'client_name' => 'Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['client_credentials']
+ ];
+
+ $client = $this->service->registerClient($requestData, 'https://localhost');
+ $clientId = $client->getIdentifier();
+ $registrationToken = $client->getRegistrationAccessToken();
+
+ // Delete the client
+ $result = $this->service->deleteClient($clientId, $registrationToken);
+ $this->assertTrue($result);
+
+ // Verify it's deleted
+ $retrievedClient = $this->service->getClient($clientId, $registrationToken);
+ $this->assertNull($retrievedClient);
+ }
+
+ /**
+ * Test RFC7591 compliance response
+ *
+ * @return void
+ */
+ public function testRfc7591ComplianceResponse(): void
+ {
+ $requestData = [
+ 'client_name' => 'RFC7591 Test Client',
+ 'redirect_uris' => ['https://example.com/callback'],
+ 'grant_types' => ['authorization_code', 'client_credentials'],
+ 'scope' => 'mcp:read mcp:write',
+ 'token_endpoint_auth_method' => 'client_secret_post',
+ 'contacts' => ['admin@example.com', 'support@example.com'],
+ 'client_uri' => 'https://example.com',
+ 'logo_uri' => 'https://example.com/logo.png',
+ 'tos_uri' => 'https://example.com/tos',
+ 'policy_uri' => 'https://example.com/policy',
+ 'software_id' => 'test-software-123',
+ 'software_version' => '1.0.0'
+ ];
+
+ $client = $this->service->registerClient($requestData, 'https://localhost');
+ $response = $client->toRegistrationResponse();
+
+ // Check required fields
+ $this->assertArrayHasKey('client_id', $response);
+ $this->assertArrayHasKey('client_secret', $response);
+ $this->assertArrayHasKey('registration_access_token', $response);
+ $this->assertArrayHasKey('registration_client_uri', $response);
+ $this->assertArrayHasKey('client_id_issued_at', $response);
+
+ // Check optional fields
+ $this->assertEquals('RFC7591 Test Client', $response['client_name']);
+ $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']);
+ $this->assertEquals(['authorization_code', 'client_credentials'], $response['grant_types']);
+ $this->assertEquals('mcp:read mcp:write', $response['scope']);
+ $this->assertEquals('client_secret_post', $response['token_endpoint_auth_method']);
+ $this->assertEquals(['admin@example.com', 'support@example.com'], $response['contacts']);
+ $this->assertEquals('https://example.com', $response['client_uri']);
+ $this->assertEquals('https://example.com/logo.png', $response['logo_uri']);
+ $this->assertEquals('https://example.com/tos', $response['tos_uri']);
+ $this->assertEquals('https://example.com/policy', $response['policy_uri']);
+ $this->assertEquals('test-software-123', $response['software_id']);
+ $this->assertEquals('1.0.0', $response['software_version']);
+ }
+}
diff --git a/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php
new file mode 100644
index 0000000000..9771ea7836
--- /dev/null
+++ b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php
@@ -0,0 +1,80 @@
+ [
+ 'name' => 'Test Client',
+ 'secret' => null,
+ 'redirect_uris' => ['http://localhost'],
+ 'grants' => ['client_credentials'],
+ 'scopes' => ['read', 'write']
+ ]
+ ]);
+
+ Configure::write('BcMcp.OAuth2.scopes', [
+ 'read' => 'データの読み取り',
+ 'write' => 'データの書き込み'
+ ]);
+
+ $this->oauth2Service = new OAuth2Service();
+ }
+
+ /**
+ * Test OAuth2 authorization server creation
+ *
+ * @return void
+ */
+ public function testAuthorizationServerCreation(): void
+ {
+ $server = $this->oauth2Service->getAuthorizationServer();
+ $this->assertInstanceOf(\League\OAuth2\Server\AuthorizationServer::class, $server);
+ }
+
+ /**
+ * Test OAuth2 resource server creation
+ *
+ * @return void
+ */
+ public function testResourceServerCreation(): void
+ {
+ $server = $this->oauth2Service->getResourceServer();
+ $this->assertInstanceOf(\League\OAuth2\Server\ResourceServer::class, $server);
+ }
+
+ /**
+ * Test access token validation with invalid token
+ *
+ * @return void
+ */
+ public function testValidateAccessTokenWithInvalidToken(): void
+ {
+ $result = $this->oauth2Service->validateAccessToken('invalid-token');
+ $this->assertNull($result);
+ }
+}
diff --git a/plugins/bc-mcp/webroot/.gitkeep b/plugins/bc-mcp/webroot/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 874a27b87f..ae0121c3ce 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -108,6 +108,7 @@
['plugin' => 'BcCustomContent'],
['plugin' => 'BcFavorite'],
['plugin' => 'BcMail'],
+ ['plugin' => 'BcMcp'],
['plugin' => 'BcSearchIndex'],
['plugin' => 'BcSeo'],
['plugin' => 'BcThemeConfig'],